Commit Graph

72 Commits

Author SHA1 Message Date
teknium1
c1e0fd83f9 fix(shared): GatewayEventMap drops phantom keys and types child_session_id
Re-verified against the tui_gateway emitters:
- SubagentEventPayload.cost_usd / .iteration: not in
  tool_progress.py::_SUBAGENT_FIELDS, never emitted → removed; the TUI's
  turnController no longer copies them (its SubagentProgress keeps the
  fields for spawn-history persistence).
- SubagentEventPayload.child_session_id: emitted (in _SUBAGENT_FIELDS, read
  by agent_callbacks.py::_mirror_subagent_to_child) but untyped → added.
- ToolCompletePayload.error: _on_tool_complete never sets it → removed;
  the TUI's completeTool drops its dead `error` parameter and renders the
  trail line as non-error (which is what it always did on the wire).
- ToolStartPayload.todos: not on the wire either, but the TUI handler and
  its fixtures exercise recordTodos from tool.start; kept with a comment
  saying so rather than churning the handler.
- MessageCompletePayload.failure_reason: prompt_turn.py passes
  result.get("failure_reason") through → `string | null`.
2026-09-13 05:42:31 -07:00
teknium1
2435131573 fix(shared): JSON-RPC channel ignores non-object frames and keeps the TUI's pong-based liveness
handleFrame guarded JSON.parse but then read `frame.id` on whatever came
back, so a stdout line of `null`/`42`/`"str"` threw a TypeError out of the
readline handler — an uncaughtException in the Ink process, where main's
TUI had caught and logged it. Non-object frames now return null (the owner
logs a protocol error, as for non-JSON).

The shared heartbeat counted ANY inbound frame as liveness, silently
dropping the TUI's original contract (fail on an unanswered gateway.ping):
a backend whose request loop is wedged but still streams deltas never
tripped the deadline. `heartbeatLiveness` now selects the contract:
'response' (default, TUI) — only a pong or a response to our own request
resets the deadline; 'any-inbound' — the desktop/web WebSocket client's
original behaviour, which JsonRpcGatewayClient passes explicitly so that
surface is unchanged. The dead 'error' branch comment in connect()'s
onClose is corrected to describe the onSocketClose-intercept case it
actually serves.

Tests: it.each over 'null'/'42'/'"str"'/'true' asserts no throw and null;
'response' mode: pongs and request responses keep it alive, streaming
deltas with unanswered pings fire onHeartbeatFailure. Sabotage (remove the
object check + count any inbound): 5 tests fail with the original TypeError.
2026-09-13 05:42:31 -07:00
teknium1
bab5cece78 refactor(ts): one reconnect backoff in apps/shared; web events feed rides the shared client and survives reconnects
Four backoff formulas (ui-tui 1000/30s, desktop 300/15s jittered, web events
1000/30s, web PTY inline 250/3s cap 5 — untested) collapse into
apps/shared/src/reconnect-backoff.ts::reconnectBackoffDelayMs(attempt,
{baseDelayMs, capMs, jitter}). Every caller keeps its own parameters
(table in the PR body); the PTY ladder gains a test.

web/src/components/ChatSidebar.tsx hand-rolled a third WebSocket frame
dispatcher (`new WebSocket` + JSON.parse + `frame.method === "event"` switch
+ a private RpcEnvelope re-declaring shared JsonRpcFrame) for /api/events.
That socket now goes through EventsFeedClient, a notification-only subclass
of the shared JsonRpcGatewayClient (replay off, heartbeat off, connect
timeout covering ticket minting); the effect keeps only the retry ladder and
the banner. Both sidebar clients are now created once per component instead
of per `version` bump, so the shared client's seq watermarks survive a drop
and its `session.events.since` gap replay can actually fire for web
(previously the client was rebuilt on every reconnect and replay never ran).

Behavior change: web sidecar reconnects reuse the same JsonRpcGatewayClient
(gap replay now runs); the events feed's handshake `error`+`close` pair is one
`closed` transition (one retry timer, as before); no parameter of any
backoff ladder changed.
2026-09-13 05:42:31 -07:00
teknium1
6b406f1c89 refactor(ts): ui-tui rides apps/shared's JSON-RPC request channel; one pending map, one heartbeat, typed RPC errors
Two independent JSON-RPC client cores existed for one backend: apps/shared's
JsonRpcGatewayClient (desktop, web) and ui-tui/src/gatewayClient.ts, which
re-implemented request ids, the pending map with timeouts, response->error
mapping, event decoding and the gateway.ping heartbeat (~200 LOC, drifted).

Split the transport-agnostic half out of the shared client into
JsonRpcRequestChannel (apps/shared/src/json-rpc-channel.ts): the owner binds a
JsonRpcTransport { send(text) } per connection generation and feeds inbound
text through handleFrame(). JsonRpcGatewayClient keeps only the WebSocket
lifecycle, seq replay and the typed event hub on top of it; the Ink TUI keeps
only its two transports (spawned child stdio, attached socket) and its
mount-order event buffering, and delegates everything else.

Behavior change:
- TUI RPC errors now carry the JSON-RPC `code` / `data` (JsonRpcGatewayError)
  instead of a bare Error(message); the TUI's timeout text is now the shared
  "request timed out after Ns: <method>" (was "timeout: <method>", matched by
  no caller) and callers may pass a per-call timeout.
- TUI heartbeat liveness counts any inbound frame (shared semantics) rather
  than tracking one in-flight ping id; the interval/deadline are unchanged
  and pings no longer carry the unread `last_activity_ms` param.
- Desktop isMissingRpcMethod reads the -32601 code first and only regexes the
  message for code-less (IPC-flattened) errors, so a tool result that merely
  mentions "unknown method" no longer reads as a capability verdict.
- Shared connect() now settles on a `close` during the handshake (auth-gate
  4401/4403) instead of waiting out the 15s connect timeout, and
  invalidate()/close() drop the socket generation before calling close() so a
  synchronous close event cannot run the closed-path twice.
2026-09-13 05:42:31 -07:00
teknium1
36773e0d78 refactor(ts): one GatewayEventMap in apps/shared typed from tui_gateway emitters; drop never-emitted tool.progress
Three TypeScript clients each declared their own copy of the tui_gateway wire
types and had drifted apart: apps/shared had a partial GatewayEventName union
with a `(string & {})` escape hatch, ui-tui/gatewayTypes.ts a 150-line
discriminated union, and apps/desktop an `RpcEvent<T>` that was field-for-field
the shared GatewayEvent with `type: string`. None matched the emitter:
message.complete lacked warning/status/error/recoverable/error_surface,
tool.start/tool.complete lacked args/result, SessionResumeResponse lacked
session_key/messages_omitted/hydrating/auto_continue/todo_state, three
different ModelOptionProvider shapes disagreed on fields, and all three unions
handled a `tool.progress` event that no Python emitter has ever produced.

Now:

* `apps/shared/src/gateway-events.ts` is the single home: payload interfaces
  typed from the Python emitters (file::symbol cited per interface),
  `BackendGatewayEventMap` (89 backend names) + `ClientLocalGatewayEventMap`
  (5 TUI-synthetic transport events, clearly marked, excluded from the
  contract) merged into `GatewayEventMap`; `GatewayEvent<K>` is discriminated
  on `type` with `seq` typed. RPC shapes shared by 2+ surfaces live beside it
  (ModelOptionProvider = union of every field hermes_cli/inventory.py sets,
  incl. pricing_pending/free_tier_pending; SessionResumeResponse<Info>;
  SessionListItem with resolved_id; Usage).
* `JsonRpcGatewayClient.on<K>` is keyed by event name; the gateway.ready
  heartbeat/replay_epoch and per-frame `seq` reads are typed instead of cast.
* ui-tui and apps/desktop import the shared names; their local duplicates are
  deleted (no re-export shims — importers are repointed; the desktop plugin
  SDK barrel keeps its public `RpcEvent` name as an alias of GatewayEvent).
  web/src repoints ModelOptionProvider/ModelOptionsResponse.
* `tool.progress` handling is removed from the TUI handler/turnController,
  desktop event sets/tools handler, shared union, tests, and two docs
  (`grep '"tool.progress"' tui_gateway/` = 0 hits; the `display.tool_progress`
  config mode is unrelated and untouched).
* `message.complete.warning` (history-commit note from
  prompt_turn.py::_complete_turn_payload) is typed and surfaced on both
  surfaces through their existing notice paths (TUI pushActivity 'warn',
  desktop notify kind 'warning').

Contract: `apps/shared/src/gateway-events.json` is the sorted list of
backend-emitted names. `tests/tui_gateway/test_gateway_event_contract.py`
collects names from the Python emitter side (emit-helper literals, the
`.request → .expire` table, change-watcher table, child delta mirror,
subagent relay, desktop_ui tool emitters, gateway.ready/setup.ready/
browser-controller frames) and asserts emitted == JSON in both directions.
`apps/shared/src/gateway-events.test.ts` asserts BACKEND_EVENT_NAMES (which
the map type is `satisfies`-checked against) == JSON. Sabotage-verified: a
fake JSON name fails both tests; a fake TS name fails tsc + vitest; a fake
Python `_emit("...")` fails pytest.
2026-09-13 05:42:31 -07:00
Teknium
0dcadf6f41 revert: remove Collective Wisdom V1 (#94266)
Reverts the in-tree org skill-marketplace: hermes_wisdom package, three
model tools, CLI/gateway/desktop/dashboard/Telegram/Slack surfaces.

Later non-Wisdom work on shared files (guest onboarding i18n, dashboard
startup schema, Slack adapter, tui_gateway) is kept; Wisdom-only call
sites and config were stripped from those files.
2026-09-11 11:54:49 -07:00
shannonsands
a6ee31f55a feat(wisdom): add Hermes Collective Wisdom Agent V1 (#94266)
* feat(wisdom): add trusted publish and install foundation

* feat(wisdom): add private contribution loop

* feat(wisdom): add managed consumption workflows

* fix(wisdom): close cross-repository safety gaps

* fix(wisdom): align local package and lifecycle policy

* fix(wisdom): require explicit profile setup

* docs(wisdom): repin reconciled gateway head

* fix(wisdom): fence content downloads and approval receipts

* docs(wisdom): record generation-fenced downloads

* docs(wisdom): record unified delivery PR

* fix(ci): stop passing invalid classifier inputs

* docs(wisdom): remove internal requirements ledger

* feat(wisdom): localize dashboard and desktop copy

* feat(wisdom): complete local contribution and consumption UX

* style(wisdom): satisfy desktop lint

* chore(wisdom): refresh requirements pin

* test(dashboard): allow formatted profile copy

* test(wisdom): stabilize desktop interaction coverage

* fix(wisdom): surface dashboard action failures

* fix(wisdom): add repeatable Portal demo login

* feat(wisdom): add actionable skill notifications

* feat(wisdom): add notification install and update actions

* fix(wisdom): make Telegram skill alerts actionable

* fix(wisdom): always refresh demo Agent login

* feat(wisdom): embed Telegram notification actions

* fix(wisdom): preserve Telegram notifications after actions

* fix(wisdom): keep Telegram notification cards readable

* feat(wisdom): add Telegram candidate approval flow

* feat(wisdom): explain Telegram qualification reasons

* fix(wisdom): reconcile cross-surface candidate actions

* feat(telegram): add Collective Wisdom management command

* chore(wisdom): refresh Gateway contract pin

* chore(wisdom): advance Gateway contract pin

* feat(wisdom): align command UX across clients

* feat(slack): add Collective Wisdom management parity

* feat(wisdom): add security and professionalism reviews

* feat(wisdom): add first-time qualification guidance

* feat(wisdom): simplify qualification sharing choices

* feat(skills): add optional editorial metadata

* feat(wisdom): enrich legacy skill presentation

* fix(wisdom): harden review and update boundaries

* fix(wisdom): emit canonical review timestamps

* fix(wisdom): align with merged gateway and main

* wisdom: add agent-led sharing core (policy, evidence, schemas, templates, delivery, weekly job, share/install flows)

- hermes_wisdom/agent_led/: policy resolution (server > local > defaults),
  7-day evidence builder that excludes bundled/hub/managed skills and
  dismissed/handled/recently-suggested content hashes, strict pydantic
  schemas for agent output with repair-or-reject, fixed copy templates
  (Share / Teammate / Published / Update / Mute), idempotent retried
  delivery ledger with stale-action resolution, weekly review job,
  resumable Share and Install flows.
- prompts/: candidate review, recipient recommendation, share packaging.
- tests/wisdom/test_agent_led.py: 30 tests.

* wisdom: agent-led renderers and button action dispatcher

- render.py: Telegram HTML, Slack blocks, Desktop payload; editorial name
  is the emphasized line, product label stays separate.
- actions.py: resolve opaque wa:<action>:<dedup> targets via the delivery
  ledger; Not now -> dismissal, Mute -> fixed options, Share -> resumable
  packaging flow, Install/Update -> plan command. Never publishes/installs.

* wisdom: CLI verbs, agent_led config default, conversational catalog skill

- hermes wisdom browse/review-week/act/share/dismiss/mute (all --json).
- wisdom.agent_led config block, default enabled.
- SKILL.md rewritten so natural-language catalog questions map to the CLI
  verbs, share/install flows and fixed notification templates.

* wisdom: wire agent-led weekly review into gateway tick and Telegram buttons

- gateway housekeeping tick calls maybe_run_weekly_review with a home
  channel sender when a Telegram adapter is available.
- Telegram: wa: callbacks resolved through the ledger (stale-safe), mute
  duration keyboard, send_wisdom_agent_recommendation rich card + fallback.

* fix(wisdom): integrate local mediation and harden model and setup boundaries

* fix(wisdom): honor authoritative recommendation policy and defer on failure

* fix(wisdom): synchronize opaque suppression and recheck delivery preferences

* feat(wisdom): route weekly selection through the session-owned assessment queue

* fix(wisdom): prepare and submit the reviewed generated share package

* feat(wisdom): separate native Share preparation from publication consent

* feat(wisdom): sync native mute choices through a leased preference outbox

* feat(wisdom): bind native mute controls to durable preference choices

* feat(wisdom): add scoped desktop and dashboard notification settings

* fix(wisdom): revalidate feed recommendations before assessment and delivery

* fix(wisdom): persist validated delivery receipts before completing notices

* feat(wisdom): add private notification claim and receipt client

* Persist Wisdom send reservations and recover delivery acknowledgements

* Route legacy Wisdom controls through current native review

* Add typed private Wisdom operation outcome client

* fix(wisdom): make agent-led advice usable in the local demo

* fix(wisdom): keep requested consent outside proactive limits

* fix(wisdom): distinguish unavailable assessments and preserve digest text

* fix(wisdom): assess ongoing usefulness beyond the current task

* fix(wisdom): restore immediate qualification sharing controls

* fix(wisdom): separate qualification review from installation advice

* fix(wisdom): collapse review checklists and simplify sharing copy

* fix(wisdom): show compact sharing progress and publication receipts

* fix(wisdom): require credential prefixes rather than matching skill names

* fix(wisdom): finish package checks before presenting sharing consent

* fix(wisdom): scan local skills before qualification cards

* fix(wisdom): update moderation results on existing sharing cards

* fix(wisdom): keep sharing review accessible from receipt cards

* fix(wisdom): align mediated review cards and collapsible checks

* fix(wisdom): clarify clean security summary wording

* fix(wisdom): normalize consent plans and add explicit recheck

* fix(wisdom): keep install and update receipts concise

* fix(wisdom): collapse assessments and deduplicate operation cards

* fix(wisdom): restore private Portal review from native cards

* fix(wisdom): sync Portal publication to original consent card

* fix(wisdom): show local skill version on sharing cards

* fix(wisdom): skip agent recommendations for self-published versions

* fix(wisdom): simplify candidate notices and local-edit recovery copy

* feat(wisdom): submit locally reviewed packages with one confirmation

* feat(wisdom): expose safe receipt and outcome sync recovery

* wisdom: onboarding notice says detect and share, names the user's own skill

Copy review from the product owner on the first and returning
qualification notices (fixed delivery mode):
- the feature blurb now says the org enabled detection *and sharing*
- both notices say the detected skill is one the user created
- both close with an exclamation mark

Applied identically to hermes_wisdom.notice, the desktop and web i18n
strings, and the tests that assert the sentences.

* wisdom: one opener, no approval line, ask to share after the skill is shown

Product owner review of the candidate card.

- The Hermes written card now opens with the same sentence as the fixed card
  ("Your organisation has enabled Collective Wisdom, a feature designed to
  automatically detect and share useful skills across all team members.")
  instead of its own blurb, so there is one first time message.
- "Nothing is shared without your approval." removed from Telegram, Slack
  and Desktop. The buttons already make the permission explicit.
- "Would you like to share?" no longer appears before the skill is named.
  It is now the last line, after the skill name, description, why suggested
  and the checks, and reads "Would you like to share it?" (matching the
  agent led template wording).

Tests updated for the new order; proposalNotice removed from all desktop locales.

* wisdom: American spelling, organization

Product owner decision: user facing copy uses American spelling.
Changes "Your organisation" to "Your organization" in the chat notice,
the Hermes written card opener, the desktop and web strings, and the
tests that assert them. Identifiers such as nas_organisation:* and the
German and French locales are untouched.

* wisdom: candidate card copy round 4 (owner review)

Apply the product owner's round 4 copy decisions to the Hermes Collective
Wisdom candidate card on Telegram, Slack, Desktop and the shared views:

1. Hermes-written cards are titled "Hermes Collective Wisdom" instead of
   the bare "Collective Wisdom".
2. The "Reusable skill ready to review" line is gone from the candidate
   card (Telegram rich card and plain fallback, legacy agent-led share
   template).
3. The skill name and description are labelled: "Skill name: <name>" and
   "What it does: <description>" (Telegram, Slack, Desktop).
4. "Why suggested:" is now "Why others might benefit:".
5. A passing professionalism review reads "Safe to share at work ✓ (no
   inappropriate content found)" with no per-check bullets and no "Pass";
   a failed review reads "Needs a look before sharing at work (possible
   inappropriate content)" and lists only the checks that flagged
   something. Pending/unavailable wording is unchanged.
6. Telegram button toasts: "Will ask later...", "Preparing more
   details...", "Sharing...".
7. Qualification reasons: "You used this skill consistently across many
   days." and "You've really refined this skill."
8. prompts/wisdom_candidate_review.md asks for a compelling
   editorial_name, a simple one_line_description and a compelling
   why_coworkers_benefit under 300 characters; "Be concise and
   convincing." becomes "Be concise and compelling: the goal is that the
   user wants to share it."

Tests updated for the new strings; review_text() gains direct coverage.

* wisdom: re-apply owner copy after rebase

- Native share cards (advice_view/interaction_view): drop the approval line, ask "Would you like to share it?" as the last line after the checks
- Hermes-written completion card titled "Hermes Collective Wisdom"
- Qualification reasons use the owner wording (consistently across many days / really refined)
- American spelling (organization) in remaining English copy
- Desktop test asserts the current Share button; web test matches the returning notice

* fix(wisdom): pin reconciled Gateway and verify Unicode hash vectors

Pin Gateway 60cd2d6b613ae3cd4a6e65155d1142006d907e78 and byte-identical producer artifacts. Verify every content-order case and package-manifest binding. Validation: 186 focused Python tests, Ruff and contract verifier.

* fix(wisdom): reconcile optional SDK tests and frontend lint

* fix(wisdom): default to agent-written notification summaries

* fix(wisdom): restore deferred install review and browse controls

* feat(wisdom): inspect installed setup with exact package provenance

* feat(wisdom): run native-approved installed setup steps with durable evidence

* fix(wisdom): recover interrupted setup with explicit native consent

* feat(wisdom): hand native installs into guided setup review

* fix(wisdom): continue requested setup with fixed notification copy

* fix(wisdom): preserve setup while waiting for a session model

* fix(wisdom): expose canonical setup review controls on desktop

* fix(wisdom): resume setup after recorded automatic updates

* fix(wisdom): make missing setup prerequisites recheckable

* chore(wisdom): align Agent with verified Gateway contract

* fix(wisdom): stop guessing team slugs in portal links

* fix(wisdom): retire pending advice on account sign-out

* fix(wisdom): cancel advice after terminal account revocation

* fix(wisdom): fence feed responses across account sign-out

* fix(wisdom): checkpoint signed-out feed before reactivation

* fix(wisdom): link proactive advice to scoped notification settings

* fix(wisdom): coalesce queued publication recommendations by version

* fix(wisdom): keep package review navigation local and deferable

* fix(wisdom): reflect installed state in discovery controls

* fix(wisdom): show exact checks before command confirmation

* chore(wisdom): pin bounded analytics privacy contract

* chore(wisdom): pin retired legacy notification contract

* feat(wisdom): review publisher usage with exact sharing copy

* fix(wisdom): align discovery and review check summaries

* fix(wisdom): show expired consent before confirmation

* fix(wisdom): require fresh review for legacy install controls

* fix(wisdom): preserve review expiry across check toggles

* fix(wisdom): retain update policy in native install reviews

* fix(wisdom): surface failed native card edits

* fix(wisdom): persist local command approval reviews

* fix(wisdom): use saved approvals for messaging commands

* test(wisdom): provide scan result in setup handoff fixture

* test(wisdom): exercise Telegram approvals with saved review state

* fix(wisdom): retain suppression policy for offline deferral

* fix(wisdom): reconsider candidates after deferred suppression expires

* fix(wisdom): bind review checks and report verified readiness separately

* fix(wisdom): persist accepted publication intent and recover exact outcomes

* fix(sync): pin UTF-8 tree ordering across writers

* chore(wisdom): pin organisation-scoped Gateway authorization

* fix(wisdom): restrict consent delivery to user-facing sessions

* chore(wisdom): refresh reviewed Gateway contract pin

* fix(wisdom): preserve kept tools in Blank Slate exclusions

* test(auth): reset anonymous fixture with a profile-scoped cache

* fix(wisdom): gate local surfaces and work on current profile entitlement

* fix(wisdom): invalidate quiet tool cache on entitlement changes

* test(wisdom): authorize local consent gateway fixtures

* fix(wisdom): keep entitlement decoding free of native crypto imports

* test(wisdom): provide local entitlement to demo CLI subprocess

* ci: leave upstream workflow unchanged in Wisdom PR

* fix(wisdom): ship package and contracts in Nix wheels

---------

Co-authored-by: hbizi <36184542+hbizi@users.noreply.github.com>
2026-09-11 19:04:06 +10:00
kshitijk4poor
809654d488 refactor(desktop): keep main's boot classification and share the ws-URL guard
Follow-up to the renderer-dial retry:

- Drop the `!connectionDescriptorResolved` gate so `bootFailureIsRetryable()`
  still decides every failure main can see (#82679 contract preserved for
  post-descriptor mint failures); the renderer-owned dial is OR'd in as the
  one failure main cannot classify. The budget check now short-circuits
  before the IPC, as on main.
- Four booleans → one `stage` variable; the `isGatewayReauthRequired` term
  is dropped (reauth is raised before the dial stage, so it was unreachable).
- `isGatewayWebSocketUrl` moves to `apps/shared/src/json-rpc-gateway.ts`
  and `JsonRpcGatewayClient.connect()` uses it, so the hook's "valid dial"
  predicate cannot drift from what `connect()` accepts.
- Tests: keep the three that bind behaviour (first dial retries under a
  stale ready snapshot; invalid URL terminal; post-connect failure
  terminal), drop the four that pinned the removed gate or duplicated the
  existing #82679 bound test. 46/46 green; reverting the hook to main makes
  the retry test fail.
2026-09-09 17:19:36 +05:30
brooklyn!
1175ac4bb9 feat(desktop): default glass to 29% tint on the sidebar 2026-09-08 18:45:42 -05:00
itsflownium
393af4a310 fix(todo): live task state via revisioned snapshots and a dedicated todo.updated event
Salvaged from PR #97815 by @itsflownium, slimmed to the schema-free core:
- TodoStore gains a monotonic in-memory revision; the todo tool result
  returns it so clients can reject stale updates
- tui_gateway emits a dedicated todo.updated full-snapshot event that
  bypasses optional tool-progress display settings
- session resume/activate responses attach the authoritative todo
  snapshot; renderer restores it with revision arbitration
- desktop store tracks per-session revisions and rejects regressions

The session_todo_state DB table from the original PR is intentionally
dropped: canonical todo tool results already persist in conversation
history, so resume paths derive the snapshot from the stored transcript
instead of a parallel store.
2026-08-29 18:40:51 -07:00
kshitijk4poor
9f05b06589 Revert "Merge pull request #94245 from kshitijk4poor/feat/gw-event-replay"
This reverts commit df7d7f6e8d, reversing
changes made to 1a66134404.
2026-08-27 11:26:57 +05:30
kshitij
df7d7f6e8d Merge pull request #94245 from kshitijk4poor/feat/gw-event-replay
feat(gateway): slim WS-only server — remove FastAPI/uvicorn from desktop boot path
2026-08-27 11:22:41 +05:30
Zeus-Deus
693dd5d042 fix(desktop): guard stale refresh ownership 2026-08-25 22:51:31 -07:00
kshitijk4poor
874fab0ce0 feat(chat-plane): trace_id + turn telemetry, transient-delta split, seq-namespace epoch
Chat/event-plane quality work for the amended Phase 1 scope of #94484
(maintainer restructure: lean chat/event plane, no control-plane
changes). Three fixes came out of a source-level comparison against
OpenHands, Chainlit, VS Code, Zed, LangGraph, and Goose.

1. Per-turn trace_id + active-turn telemetry: _start_inflight_turn mints
   a 12-hex trace_id; _event_frame stamps it on every event frame in the
   turn, so a client can correlate the full lifecycle (dispatch -> first
   token -> tool calls -> complete) from one identifier — none of the six
   surveyed projects has frame-level turn correlation.
   session.events.stats now reports active_turns (session_id, trace_id,
   elapsed_s, streaming).

2. Transient vs durable events (OpenHands StreamingDeltaEvent pattern):
   message.delta / thinking.delta are stamped with seqs (live ordering
   holds) but never buffered — one streaming turn emitted hundreds of
   delta frames and evicted every durable control event from the
   512-slot ring, defeating replay for the exact reconnect window it
   exists to cover. The ring now evicts manually and records the highest
   DURABLE seq dropped, so truncated means real data loss and delta-only
   gaps no longer false-positive.

3. Seq-namespace epoch (Goose stale-cursor recovery): event_replay.EPOCH
   (8-hex per boot) is announced in gateway.ready and echoed by
   session.events.since; the client drops its seq watermarks when the
   epoch changes, so a stale HIGH watermark from a previous gateway
   process can no longer suppress replay/gap-detection forever. Legacy
   backends without an epoch are unaffected (client keeps watermarks).

Validation: Python 85/85 across replay/entry_ws/keepalive/protocol
(12 replay tests, 3 new); vitest 9/9 shared (2 new epoch tests), 66/66
desktop; full tui_gateway sweep 614/615 (1 known ordering flake, passes
in isolation). Live e2e on this tree: 12-event turn -> seqs contiguous
1..12, 8 durable frames buffered + 4 deltas live-only, single trace_id
on all frames, active_turns elapsed_s matches the real turn duration.

Research provenance: NousResearch/hermes-agent#94484 (comparative-scan
comment); techniques credited to OpenHands (transient split), Goose
(epoch/stale-cursor), per maintainer-restructured plan.
2026-08-26 02:55:04 +05:30
hermes-seaeye[bot]
7c97343950 fmt(js): npm run fix on merge (#94410)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-25 03:17:26 +00:00
Teknium
beb7941236 fix(tui-gateway): make WS reconnect replay actually deliver events (follow-up to #94219)
The #94219 replay was a production no-op: the server returned full
JSON-RPC envelopes from session.events.since while the client's replay
loop dispatches only elements with a top-level 'type' — every replayed
event was silently skipped. Each side's tests validated its own
assumption, so both suites stayed green.

- server: events_since() now returns bare event objects (the frame's
  params), the exact shape the live dispatch path consumes; ring stores
  params directly; cross-language contract test added on both sides.
- client: live frames racing an in-flight replay are parked and flushed
  seq-gated afterward — no double dispatch of deltas, no gap-skip from
  a watermark advanced past the replay window.
- restart poisoning: seq counters are in-process, so a backend restart
  reset them while clients kept high watermarks (replay forever empty,
  truncated=false). New replay_epoch advertised in gateway.ready and
  echoed by session.events.since; the client clears watermarks on epoch
  change.
- methods_session no longer reaches into event_replay privates
  (is_truncated() accessor).

Live repro: pre-fix, 3 stamped frames -> 0 dispatchable by the client
gate; post-fix 3/3. Tests: 16 py (replay+ws), 8 vitest, tsc clean, ruff
clean.
2026-08-24 20:11:30 -07:00
hermes-seaeye[bot]
03b87d666d fmt(js): npm run fix on merge (#94230)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-24 21:18:54 +00:00
kshitijk4poor
c7577403f8 test: drop unused afterEach import (CI eslint) 2026-08-25 02:38:56 +05:30
kshitijk4poor
87631bd8ae feat(tui-gateway): seq-stamped event replay for lossless desktop reconnect
Server: per-session monotonic seq on every routed event frame, bounded
512-frame replay ring (64 sessions, FIFO eviction), plus two new RPCs —
session.events.since (replay newer-than-watermark, reports latest_seq +
truncated so clients detect gaps) and session.events.stats (telemetry).

Client: per-session seq watermarks recorded from live frames; after any
successful reconnect a fire-and-forget fetchReplay() drains missed events
through the normal dispatch path (recordSeq ignores non-increasing seqs,
so stale replay can never regress a watermark); focus-triggered reconnect
nudge in use-gateway-boot for the Electron unfocused case where macOS wake
skips visibilitychange.

Replay failures are swallowed by design: lossless resume is an upgrade
over the previous lossy reconnect, never a new failure mode.
2026-08-25 02:31:19 +05:30
Eva
9153be2a51 feat(shared): heartbeat and socket-generation invalidation in JsonRpcGatewayClient
The shared-client half of the gateway.ping heartbeat contract (#89958);
tracks lastInboundAt, sends pings, invalidates a silently-dead socket.
Part of #83166.
2026-08-24 04:01:53 -07:00
Brooklyn Nicholson
b8c6547ca4 fix(desktop): off means off when glass is turned down
The light default carries a single point of fade so the window edge reads as
glass rather than as paint. That point followed anyone who dragged the tint
to zero, leaving a window that asked to be opaque sitting at 0.9999.

Fade now applies only while glass is actually active, not merely selected.
2026-08-20 02:28:24 -05:00
Brooklyn Nicholson
be3166607e feat(desktop): glass ships on, tuned per appearance and platform
Translucency was one number serving both appearances and both platforms,
resting at zero. A lever that starts at zero is a feature nobody finds, and
one number cannot serve four situations: a tint that reads as a whisper over
a dark palette is a milky sheet over a light one, and the same numbers that
read as frost on macOS vibrancy read as a washed sheet over Windows acrylic,
which composites its own tint in DWM before the page is drawn.

So the state splits. `mode` stays global — clear versus glass is a choice
about the window, not the palette — while the values resolve through a
ladder, per key: the appearance you are looking at, then a shared base, then
the platform default. Tuning light mode stays in light mode; an untouched
dark keeps inheriting. A v1 state lands in base, so a window someone already
tuned crosses the upgrade with exactly what was on screen.

Main reads the same defaults at window creation, because a window born
opaque cannot reliably be swapped to glass afterwards.

The chat backdrop goes off by default in the same pass: it was competing
with the glass field for the same surface.
2026-08-20 02:28:24 -05:00
Brooklyn Nicholson
7f9e79b2e1 feat(desktop): back the HUD band with the same window material the app uses
The HUD asked for vibrancy directly and always with the 'hud' material —
one of the two rungs the macOS census rejected, because it collapses into
under-window on blur and so changed the frost the moment another app took
focus. It also ignored the translucency setting entirely: Glass off still
frosted, and Windows got nothing at all.

hudFrostFor is the mapping for a transparent window, beside vibrancyFor in
the shared module both processes read. Two gates give it its answer: the
renderer's report that the band actually covers the window, and the user's
Glass setting. Off resolves to no material rather than a resting one, since
a transparent window has no opaque page to hide an unwanted frost behind.

Windows 11 rides setBackgroundMaterial through the same call, so the HUD
follows the frost ladder on both platforms. Main self-diffs and keys the
latch to the window, so a Settings change re-frosts a live HUD, a tint drag
touches nothing native, and a HUD respawned on another profile is not
mistaken for the window that already carried the material.
2026-08-19 19:12:09 -05:00
brooklyn!
00e5a361b6 Merge pull request #89611 from NousResearch/bb/desktop-godfiles
refactor(desktop): decompose god files into atomic modules
2026-08-19 11:05:01 -05:00
Brooklyn Nicholson
9ab5d92fac fix(desktop): sort translucency named exports for eslint
Perfectionist wants values before types in the glass/Windows barrel.
2026-08-19 10:50:52 -05:00
Brooklyn Nicholson
5a027a0081 refactor(desktop): give duplicated helpers one owner
Splitting the god files made a pile of copy-paste helpers visible and,
for the first time, fixable — sharing them previously meant importing a
god file. Hashing function bodies through the TypeScript AST found
twelve groups desktop-wide; production code is now at zero duplicates.

Each helper went to the module that already owns its concern:
firstStringField to lib/text, the two REST 404 predicates to
lib/gateway-rpc beside isMissingRpcMethod, useDebounced and
prefersReducedMotion to their hooks, the superseded-bootstrap guard to
electron/ssh-connection, the composer keyup handler to the trigger hook
that owns the rest of that state machine, and clampDataUrlReadMaxMb to
apps/shared, replacing a "keep these in sync" comment between two
copies.

Only helpers with no existing owner got a new file: lib/mcp-servers,
lib/audio-context, lib/keyed-timeouts, lib/pointer-drag, and the command
palette's status row. Error-shape predicates are the worst thing to
copy — when the backend changes how it reports a missing route, every
copy has to be found.
2026-08-19 10:50:52 -05:00
Brooklyn Nicholson
eb52328857 feat(desktop): back window glass with Windows 11 system materials
Glass was macOS-only because it rode setVibrancy. Windows 11 22H2 has a
first-party equivalent in setBackgroundMaterial, so the mode now resolves
its backing per platform instead of per-OS-check: macOS keeps vibrancy,
Windows 11 gets DWM acrylic / tabbed / mica, and everything older stays on
Clear. No third-party native addon.

Two Windows-specific details the mapping has to respect. DWM only paints
the client area of a transparent window (electron#49443), so glass-capable
Windows chat windows are born transparent with the opaque themed
backgroundColor covering them while glass is off — a live Clear/Glass
toggle then needs no window recreate. And Windows exposes three backdrops
for four frost rungs, so the two heaviest both resolve to mica; the mapping
stays total so a frost saved on a Mac still renders.

Glass support is computed once from os.release() and shared: main uses it
for the persisted default and every window, preload publishes it to the
renderer so the UI can't offer a mode the window can't back.
2026-08-19 10:50:51 -05:00
Brooklyn Nicholson
e9cefa4d75 feat(desktop): pre-select glass on macOS
Window Translucency shipped defaulting to Clear, which means the mode worth
finding is the one nobody sees — Glass is the better-looking half and the
reason the feature exists. A fresh macOS profile now starts with Glass
selected.

Nothing turns on. The intensity still defaults to 0, so the window is
byte-for-byte what it is today until the user moves the lever; the default
only decides which mode that lever will drive. windowOpacityFor stays 1 and
the window is still born with its opaque backing.

The one profile that must NOT flip is one already carrying a non-zero
intensity with no mode recorded: it predates the setting, has been rendering
as clear the whole time, and defaulting it to glass would change a window
someone deliberately tuned. normalizeMode takes the saved intensity and keeps
those on clear.

The renderer store was hand-rolling its own copy of this rule, so it now
routes through the shared normalizer and the two can't disagree.

The store's default test only passed because a beforeEach reset the atom
before it looked — it asserted the post-reset value, not the default, so it
would have stayed green through this change. It now snapshots the atom at
import time. All three mutations (default back to clear, escape hatch
removed, glass leaking onto non-mac) fail the suite.
2026-08-18 01:16:18 -05:00
Brooklyn Nicholson
efa15b5eb4 feat(desktop): frost picker, sidebar glass, full-range tint and peek
Restores the four features SHL0MS built on #84329 that an earlier pass on
this branch had carved out, reconciled onto the shared translucency state
rather than the four-atom store they were written against.

- Frost picker. macOS exposes no blur-radius knob, so the vibrancy material
  IS the frost control. The four in the ladder come from a pixel census on
  macOS 26: the 14 Electron materials collapse to 9 distinct looks, and
  these four are the widest separations that stay distinct in BOTH
  appearances. sidebar/hud collapse into under-window when unfocused, which
  is why they're deliberately absent -- normalizeMaterial rejects them, with
  a test saying why.
- Sidebar-only glass, the Finder shape. <body> stays the single painter and
  splits at the rail's live-measured edge with a hard gradient stop, so
  there's no smear across the seam and no per-layer tint stacking. RTL
  mirrors.
- Full-range tint. glassSurfaceKeep runs linear to zero, so the top of the
  lever is bare untinted blur instead of stopping at a 30% wash. Text, cards
  and the composer keep their own opaque tokens, which is what makes 100%
  usable rather than unreadable.
- Peek. The settings overlay covers the very effect its slider controls, so
  holding the slider ghosts the whole overlay layer and the live window
  becomes the preview. A counter, not a boolean: a held drag and a timed
  pulse from a picker click overlap, and the drag must not be cancelled by a
  pulse expiring underneath it.

One change from the original: the peek's transition is scoped with :has() to
the overlay that arms it. The version on #84329 shipped a bare
`[data-overlay-surface] { transition: opacity 420ms }`, which gave every
overlay in the app -- command center, cron, agents, model picker -- a 420ms
opacity transition for the life of the process to serve one slider. Verified
by running the candidate rules through lightningcss: the scoped selectors
survive minification and no un-gated overlay transition remains.

visualEffectState is pinned to 'active' at each chat window, because several
materials collapse to a shared inactive look on blur -- without it the frost
choice silently erases itself whenever the user clicks another app.

Co-authored-by: SHL0MS <SHL0MS@users.noreply.github.com>
2026-08-18 00:57:01 -05:00
Brooklyn Nicholson
d6adef6991 refactor(desktop): one owner for the translucency mapping
The mapping had grown three copies: the clear-mode ramp in
electron/window-opacity.ts, a second clamp + mode normalizer in
electron/translucency.ts, and a third clamp in the renderer store, with a
"keep in sync" comment standing in for a shared type. Anything the two
processes must agree on -- what a mode is, where the lever clamps, what
intensity means as an opacity -- now lives in apps/shared/src/translucency.ts
and both ends import it.

electron/translucency.ts keeps only the piece that needs a BrowserWindow to
mean anything (the constructor backing), and re-exports the rest so main.ts
has a single import. Its relative specifier is deliberate: the electron
bundle is built by esbuild with no tsconfig path resolution, so a bare
@hermes/shared/translucency would typecheck and then fail to bundle -- the
same constraint connection-registry.test.ts documents for backendScopeKey.

The renderer's tsconfig drops its reference to the electron project. With
both projects claiming the shared file, that edge made the renderer resolve
it through the electron project's build output and demand a prior
`tsc --build`. Nothing in src/ consumes electron's emitted types, so the
reference bought nothing; `npm run typecheck` still checks both projects.
2026-08-18 00:57:01 -05:00
addel
decd6a73fa fix(desktop): preserve registry route identity 2026-08-16 16:26:50 -07:00
Teknium
9caff74408 fix(desktop): key fan-out event consumption by (connectionId, profile)
Secondary-gateway events were tagged with connectionId (store/gateway
fan-out) but no consumer read it: working/attention tracking, the
pruneSecondaryGateways keep-set, and the profile-scoped event gates
(skin.changed / change-watcher broadcasts / approval-mode reconcile)
all keyed by session id + bare profile name. Every registered source
exposes a 'default' profile (the roster force-unshifts it), so two
connected gateways collided — gateway B's 'default' activity was
attributed to gateway A's 'default', keeping the wrong socket alive
and applying the wrong source's config/skin/cron changes.

Thread connectionId through consumption using the existing composite
backendScopeKey helper:

- session-states records each registry-tagged event's (connectionId,
  profile) scope per runtime session; liveSessionScopes() projects the
  busy/needs-input ones as composite keys for the gateway keep-set.
- recomputeKeptGateways (use-gateway-boot) seeds the keep-set with
  those scopes; pruneSecondaryGateways matches registry-scoped entries
  ONLY on their composite key, while local entries keep matching bare
  profile names (single-source path unchanged).
- gateway-event's 'from the active profile' gates now compare the
  event's composite scope against the active gateway's connection via
  the new activeGatewayConnectionId(); untagged local/primary events
  behave byte-identically.

Display-only surfaces that already use roster handles are untouched.
2026-08-16 10:11:47 -07:00
rainbowgits
e14b095f20 fix(tui): map lineage edit ordinals past compression prefix
Desktop/TUI count full displayed lineage after compression, but
prompt.submit validated truncate ordinals against tip-only history.
Translate via display_history_prefix and recover stale 4018s on Desktop.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 02:24:40 -07:00
Teknium
aff19d0251 feat(desktop): multi-source agents end-to-end — sockets, roster, SDK, fan-out updates
Phases 3-5 of the multi-connection campaign in one PR (per Teknium), on top
of the registry (#86679) and composite-key backend routing (#86839). Agents
from every registered connection are now usable side by side.

Renderer socket registry (phase 3):
- backendScopeKey moves to apps/shared (@hermes/shared) so main-process pool
  keys and renderer socket keys derive from ONE rule; the electron module
  keeps a byte-identical twin (tsconfig project boundaries) pinned by a
  cross-copy contract test.
- store/gateway secondaries are scope-keyed: entries carry (connectionId,
  profile); registry-scoped entries dial through getConnectionFor +
  getGatewayWsUrlFor (fresh per-connect OAuth tickets against the right
  host); events keep the bare profile plus a connectionId tag; touch/idle
  keepalive uses the scope key; pruning keeps entries whose PROFILE has live
  work. New ensureGatewayForAgent/openGatewayForAgent fall through to the
  profile path for local/null sources — single-source behavior byte-identical.

Union roster + plugin SDK (phases 3+4, the Bot Mode door):
- hermes:agents:roster enumerates every connection's /api/profiles
  concurrently (eager REST, lazy sockets; unreachable sources report per-row;
  undialed ssh boxes stay connect-on-demand) and flattens through
  buildAgentRoster — the @name-device duplicate-handle rule applied once
  across all sources, pure + tested.
- SDK: host.connections(), host.agents(), host.warmAgent(),
  host.ensureAgent() — feature-detected so plugins degrade cleanly on older
  Desktop builds.

Fan-out updates (phase 5):
- hermes:connections:update-all dispatches hermes update to every eligible
  source in parallel: local via the app's own applyUpdates pipeline,
  remote/ssh via the backend's own POST /api/hermes/update; cloud skipped as
  platform-managed (updateEligibility, pure + tested); per-connection result
  rows so one dead box can't wedge the batch. Settings → Connections gains
  the "Update all instances" button (shown with 2+ connections).

Also: getJsonForBackend/postJsonForBackend helpers with the token/OAuth-cookie
auth split; docs section updated from "staged rollout" to live behavior.

Tests: +4 pure cases (cross-copy contract, roster handles, unreachable
sources, update eligibility); FULL desktop suite 5115 passed; tsc renderer +
electron + shared clean; eslint clean.
2026-08-15 03:11:32 -07:00
Evgenii
f9d64b9a9d fix(cron): add reliable trigger feedback in Web and Desktop clients
- Shared per-job trigger controller (apps/shared) coalesces duplicate
  clicks for the same profile+job inside a mounted client while letting
  unrelated jobs run independently; the backend durable claim remains
  authoritative across windows/processes.
- Two-phase feedback everywhere: the action stays disabled/spinning
  while the request is in flight and the terminal success/error is
  reported once, after the HTTP response — no premature success toast
  (Web), matching the Desktop info notification.
- Desktop keeps the 24h trigger timeout for the synchronous long
  operation and fences stale profile/list responses and unmounted
  surfaces; the sidebar trigger button shows a spinner while busy.
2026-08-14 20:46:50 -07:00
Michael Huang
2cabeba563 fix(tui,desktop): refresh context usage live during active turns 2026-08-14 20:24:42 -07:00
izumi0uu
cab8673ea6 fix(dashboard): reload loopback tabs after stale session-token closes
Loopback dashboard tabs now share one one-shot stale-token recovery path across REST 401s, the PTY socket, the structured event socket, and the shared JSON-RPC gateway wrapper. The shared client exposes only an optional close-event interception hook; the dashboard remains responsible for deciding that loopback 4401 means reload.

Constraint: Current main delegates the web gateway to apps/shared JsonRpcGatewayClient, and #54022 review requires a shared-client-compatible close-code hook plus direct ChatSidebar event-socket coverage.
Rejected: Restore the dashboard's old direct WebSocket implementation | stale against the shared JSON-RPC client and would duplicate transport behavior.
Confidence: high
Scope-risk: moderate
Directive: Keep stale-token policy dashboard-specific; the shared JSON-RPC client should expose close events without learning dashboard auth semantics.
Tested: npm --workspace web test (21 files, 106 tests); focused stale-token tests (5 files, 14 tests); npm --workspace web run typecheck; npm --workspace @hermes/shared run lint; npm --workspace @hermes/shared run typecheck; focused web eslint; git diff --check.
Not-tested: Manual browser smoke test across a real dashboard restart.
2026-08-04 15:26:37 +05:30
ethernet
fabc2d7d33 fix(js): hoist eslint shared devDeps to workspace root 2026-07-31 13:42:03 -04:00
ethernet
515e88a80f fix(sec): pin exact npm package versions everywhere 2026-07-31 13:42:03 -04:00
ethernet
390a1771b2 fix(sec): update npm deps to resolve npm audit warnings
pin brace-expansion to 5.0.8
update concurrently to 10.0.4
update electron-builder to 26.15.3
update eslint to 10.8.0
update eslint-plugin-perfectionist to 5.10.0
update @assistant-ui/react to 0.15.0
update @assistant-ui/react-streamdown to 0.3.8
update radix-ui to 1.6.7
update react-router-dom to react-router@8.3.0 - react-router-dom is no longer a standalone package, it just reexports react-router
remove @radix-ui/react-slot: we import this from `radix-ui`
remove eslint-plugin-react: we imported it, but never actually used it!
2026-07-31 13:42:03 -04:00
Brooklyn Nicholson
e682a9c0a7 refactor: drop the shared subpath export, keeping package.json untouched
The subpath entry existed only so the TUI could import the client-side
projection fallback. The TUI spawns its gateway from this same checkout and
cannot version-skew with it, so that fallback was dead weight — it reads
`display` directly now. With its last caller gone the dispatch helper folds
back into the desktop, where an older backend is genuinely reachable.

apps/shared/package.json is byte-identical to main again.
2026-07-28 03:54:49 -05:00
Brooklyn Nicholson
b700f9f253 feat(shared): read a skill invocation out of its expanded scaffolding
The client-side twin of the gateway's projection, shared by the desktop and
the TUI so a surface talking to an older gateway still renders the
invocation rather than the whole skill body.
2026-07-28 03:44:23 -05:00
Siddharth Balyan
59529afee0 feat(billing): carry the payment-method union through to clients (#71542)
* feat(billing): add payment_method union to the billing-state wire type

* feat(billing): carry the payment-method union through the gateway

NAS now sends a typed `paymentMethod` union on /api/billing/state alongside
the legacy `card` field. The gateway parses payloads field-by-field, so the
new field was dropped on the floor before reaching TUI/Desktop.

Parse it into PaymentMethodInfo and re-emit it as snake_case `payment_method`,
matching the translation the rest of this payload already does. The payment
method id is deliberately not carried through — clients have no use for it.

No client rendering changes: `card` stays populated for cards, so every
existing consumer behaves exactly as before and the new field is inert until
a surface opts into reading it.

* fix(billing): send only the fields each payment-method kind declares

The serializer emitted every key for every kind, so a Link method went out
carrying brand, last4 and wallet set to null. That contradicts the shared
type, where each kind declares its own fields: a client testing `'brand' in
pm` would read every Link method as a card, and one trusting the declared
non-null `brand` could crash on it.

Send each kind's own fields, and forward an unrecognized kind by name alone
so a client that predates it can still say something honest. The shared type
gains the matching fallback arm its own comment already promised.

Tests now follow a payload from the server response through to the client
wire for each kind, rather than checking parsing and serializing separately —
which is why the old expectation locked in the wrong shape without noticing.

* fix(billing): keep the payment-method kind narrowable

Typing the fallback arm's kind as `string & {}` borrowed a trick that only
works on unions of plain strings. On a union of objects it makes the
discriminant non-literal, so TypeScript stops narrowing on every arm — even
`if (pm.kind === 'card')` no longer gives you `brand`. The first client to
use this would have hit a compile error and reached for a cast.

An unrecognised kind now arrives as `unknown`, carrying the real name
alongside it, so every arm has a literal discriminant. A type-level test
pins this: it fails to compile if the discriminant stops narrowing.

The parser settles which kind it is, the way the card parser already does,
so the record cannot hold fields that do not belong to its kind and the
serializer no longer re-checks. The type comment also stops claiming `card`
is a safe signal — it is null for Link, so `!card` does not mean "nothing on
file".
2026-07-27 01:49:43 +05:30
ethernet
5be99b6fce ci(js-tests): split check into parallel matrix shards per workspace (#70252)
Every npm workspace package now defines check:* scripts (check:unit,
check:lint, check:bundle, check:typecheck, etc.) that fan out to
separate matrix runners in CI. The check umbrella script chains all
shards for local dev.

The matrix discovery in the workspaces job queries npm workspaces,
finds check:* scripts (in package.json insertion order), falls back to
check when none exist, and emits an include matrix. No hardcoded
package names — the workflow is fully auto-derived from workspace
metadata.

Previously every package ran a single check script on one worker, and
the fix step (lint:fix + prettier) ran as a separate CI step with
special-cased run_fix gating to avoid running on every shard. Now that
lint is just another check:lint shard, the run_fix field and the fix
step are gone entirely — lint runs in its own runner like everything
else.
2026-07-23 18:45:16 -04:00
Brooklyn Nicholson
960d339f86 feat(billing): shared cross-surface out-of-credits signal
Detect a billing wall once (agent/error_classifier → FailoverReason.billing) and
map it to a recovery link + label in one place, then carry that structured
BillingBlock to every surface instead of re-parsing free-form error text per
surface.

- agent/billing_links.py: provider-agnostic slug/host → (label, billing URL)
  table (single source of truth), Nous-aware (is_nous routes to the in-app flow);
  unknown providers degrade to a readable label with no invented URL.
- conversation_loop: both billing exit paths return a billing_block through one
  helper; the guidance message carries the derived URL for every provider.
- gateway forwards billing_block on message.complete (it was dropped).
- @hermes/shared: BillingBlock type shared by desktop + TUI.
2026-07-22 18:08:59 -05:00
ethernet
41e00dcf87 fix(desktop): salvage /compress cluster — session.compress RPC + dedicated RPC routing (#68229)
* fix(desktop): route /compress through session.compress RPC with transcript replacement

Salvages #44462, #53755, and #68218 into a single canonical fix for the
desktop /compress cluster.

The desktop routed /compress through slash.exec, which sends it to the
_SlashWorker subprocess. Compressing a large session outlives both the
desktop's 30s WS timeout and the worker's 45s pipe timeout — the client
gives up, runExec's blanket catch swallows the error, and command.dispatch
surfaces a misleading "not a quick/plugin/skill command: compress" (#44456).
Even when compression succeeded via the _mirror_slash_side_effects path,
the desktop never received the post-compress message list, so summarized
bubbles stayed on screen forever — /compress looked like a no-op.

This change routes /compress to the dedicated session.compress RPC (the TUI's
path), combining the best of all three PRs:

- 120s client timeout matching the TUI's HERMES_TUI_RPC_TIMEOUT_MS (#44462)
- Transcript replacement from the response `messages` via toChatMessages,
  the same converter session.resume uses (#68218, teknium1 review on #44462)
- Session-isolation guard: updateSessionState only publishes for the active
  runtime, so a late result after a session switch can't clobber the
  foreground transcript (#53755, teknium1 review on #53755)
- Coalescing: dedup concurrent compress requests per session (#53755)
- Progress toast ("compressing context...") outside the transcript (#53755)
- Error unmasking in runExec: when slash.exec fails and command.dispatch only
  adds "not a quick/plugin/skill command" routing noise, surface the original
  worker error instead (#44462)
- /compact alias + focus_topic forwarding

Co-authored-by: AlliDev <AIalliAI@users.noreply.github.com>
Co-authored-by: PinkEVO <PINKIIILQWQ@users.noreply.github.com>

* feat(desktop): route slash commands with dedicated RPCs to those RPCs

Salvages #63513 — introduces a new `rpc` kind on DesktopCommandSurface so
commands with a first-class gateway @method handler bypass slash.exec /
command.dispatch entirely, and a `renderRpcResult` utility that shapes
each RPC's structured reply into readable transcript text.

Migrates 6 commands from exec() to rpc(...):
  /agents → agents.list
  /save   → session.save
  /status → session.status
  /steer  → session.steer
  /stop   → process.stop
  /usage  → session.usage

/compress stays as action('compress') — it needs transcript replacement
from the response `messages`, which the generic rpc path can't do (per
teknium1 review on #44462/#63513).

Also includes the json-rpc-gateway timeout message improvement: the error
now includes the configured timeout duration ("request timed out after 120s:
session.compress") so a user can tell whether the default 30s fired or a
per-call override.

Co-authored-by: Jelvin <SmallNew2003@users.noreply.github.com>

* fix(desktop): preserve provider choice during config initialization

* fix(desktop): preserve slash command and host compression semantics

Keep commands whose CLI behavior exceeds their current RPC contracts on slash.exec.
Propagate the full compression timeout through compute-host control, return structured
host compression outcomes with metadata, and retain successful compression feedback
in the desktop transcript.

Add regressions for timeout forwarding, host aborts and metadata sync, structured host
control responses, command routing parity, and numeric stop counts.

* fix(desktop): harden compression state handling

Preserve the invoking stored-session binding for delayed compression results,
normalize replacement histories, and serialize provider selection. Stabilize
gateway platform tests and guard the desktop Git facade during renderer teardown.

---------

Co-authored-by: AlliDev <AIalliAI@users.noreply.github.com>
Co-authored-by: PinkEVO <PINKIIILQWQ@users.noreply.github.com>
Co-authored-by: Jelvin <SmallNew2003@users.noreply.github.com>
2026-07-22 16:40:06 -04:00
hermes-seaeye[bot]
0ec4a873c1 fmt(js): npm run fix on merge (#69562)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-22 19:01:09 +00:00
ethernet
02fa447f8e fix(desktop): validate gateway WebSocket URLs 2026-07-22 14:31:53 -04:00
Brooklyn Nicholson
9160f10fc9 fix(desktop): tsc clean before dev / build
A stray tsc run can emit foo.js next to foo.ts under apps/shared/src or
apps/desktop/src. .gitignore hides the artifact from git status, but Vite
resolves extensionless imports .js-before-.ts, so the renderer silently runs
the stale compiled copy.

tsc -b . --clean already knows the emit graph and deletes
matching outputs. Run it before vite in all dev scripts.

This bit for real: a Jul 16 artifact of websocket-url.js predated the #68250
getGatewayWsUrl contract change ({ ok, wsUrl } IPC result), so its old
'if (fresh) return fresh' handed the whole result object to new WebSocket(),
dialing ws://127.0.0.1:5174/[object%20Object] on every boot. The desktop app
could never connect, and the failure survived reboots and cache wipes because
the poison lived in src/.

JsonRpcGatewayClient.connect() now rejects non-ws:// URLs with a readable
error instead of letting new WebSocket() coerce an object into
[object%20Object], so any future contract skew fails diagnosably.
2026-07-22 13:19:47 -04:00
Siddharth Balyan
7c68b4eae1 feat(desktop): Billing page revamp — current-plan card, in-app plans view, tier art (#68722)
* feat(desktop): revamp Billing page — plan card, in-app plans view, tier art

Reshape the desktop Billing settings per wayfinder ticket 09. New page order:
Plan → Payment → One-time top-up → Automatic refill → Usage, with the at-a-glance
summary strip unchanged at the top.

- CurrentPlanCard replaces the old Subscription row: tier name + price + renewal
  and at most one button — "View plans" (free/no-sub + can_change_plan),
  "Change plan" (subscriber + can_change_plan), or none for teams / non-changers.
  Teams keep the portal "Adjust plan ↗" link so they are not stranded. The button
  navigates in-app to the plans sub-view.
- bview=plans sub-view mirrors the settings pview/kview pattern (useRouteEnumParam,
  default overview). BillingPlansView renders a grid of PlanCard from live tiers[]
  (is_enabled, sorted by tier_order, free tier included).
- PlanCard: tier art + name + $/mo + monthly credits as dollars ("$110 credits/mo").
  Current tier is highlighted + inert; higher/no-current tiers get "Choose ↗"
  (opens portal with plan=<tierId>); lower tiers are a DISABLED "Downgrade" with a
  caption — downgrades move in-app in ticket 11 (gateway pending-change flow), so
  this PR intentionally links them out/disabled rather than wiring the money path.
- buildManageSubscriptionUrl gains an optional third arg (tierId) → appends
  plan=<tierId>. Signature kept identical to draft PR #68666 for a trivial rebase;
  NAS #748 validates the param server-side.
- Tier art: four NAS hero webps rendered as ~40px thumbnails over a Nous-blue well
  with per-tier blend modes (the only place Nous blue appears). Keyed by lowercase
  tier NAME (free/starter→connect, plus→memory, super→automation, ultra→sandbox);
  unknown name → text-only card. Imported via vite static imports for packaged
  file:// + webSecurity.
- Top-up vs auto-refill disambiguated by section label + first sentence: "One-time
  top-up" / "Buy credits now" vs "Automatic refill" / "Refill when low" (configured
  copy reads "Charges $X automatically when your balance falls below $Y.").
- Variant-A auto-refill editing: Manage swaps the row's left side (caption → the two
  $ fields with a pre-allocated error line) and the action column (Manage → Save/
  Cancel) in place, with the row height reserved for the tallest state so the Usage
  section never shifts. Fixes the spurious on-open validation error (errors now show
  only after an edit or a save attempt). Save/disable API calls + confirm-disable
  flow unchanged.
- Remove subscriptionTierChips and the subscription-row chips; reshape (not delete)
  deriveBillingView to expose plan + tiers. Buy-credits row keeps the chips seam.
- Dev fixtures: add free-personal and subscriber-personal (personal orgs, full
  4-tier Free/Plus/Super/Ultra catalog) so the plans view is exercisable.

Tests: update/extend index.test.tsx + use-billing-state.test.ts, add tier-art.test.ts;
delete the old chips tests. Desktop billing suite 70/70 green, typecheck clean.

* fix(desktop): mark the free/lowest tier current (not an upgrade) when there is no subscription

Visual verification caught a spec-fidelity bug: in the plans grid, an account with
no active subscription rendered the Free tier ($0/mo, tier_order 0) as a "Choose ↗"
upgrade — clicking would deep-link the portal to "subscribe to Free".

Ruling: current-card = tier.is_current OR (subscription.current == null AND the tier
is the lowest-order / $0 tier). derivePlanTiers now falls back to the lowest-order
tier as the stand-in current plan when there is no subscription, so the free card
renders exactly like is_current (inert, "Current plan") and — being the lowest order
— no tier can be a downgrade; every paid tier is a "Choose ↗" upgrade.

CurrentPlanCard is unaffected (still "Free" + "View plans"); subscriber-personal is
unchanged (Free stays a disabled Downgrade below the current Plus tier).

Tests: free-personal grid now asserts Free = current/inert, no downgrade state, three
Choose buttons; text-only unknown-tier test gains a free tier so the unknown paid tier
is unambiguously an upgrade. Billing suite 70/70 green, typecheck + lint clean.

* chore(desktop): shrink bundled tier art to 128px thumbnails

The plan-card wells render the art at ~40px; shipping the full landing
images added 2.7 MB to the repo for no visible difference. 128px covers
2x displays; total is now 26 KB.

* fix(desktop): address 6 adversarial-review findings on the Billing revamp

1. Grandfathered current tier (BLOCKER). NAS marks a grandfathered current tier
   is_enabled:false; the enabled-only filter dropped it, leaving currentOrder
   undefined so every lower tier rendered as an actionable "Choose ↗". derivePlanTiers
   now resolves current identity/ordering against the UNFILTERED tiers and keeps the
   grandfathered current tier in the grid as the inert "Current plan" card; downgrades
   classify against its tier_order. (Non-current disabled tiers are still dropped.)

2. Dead plan-card button. derivePlanCard offered "View plans"/"Change plan" purely on
   can_change_plan, but the grid could be empty / current-only and showPlans refused,
   so the button no-oped. It now offers the in-app action ONLY when the grid has ≥1
   actionable (non-current) tier; otherwise it falls back to the portal link.

3. Deep-link bypass. showPlans now gates on the same capability that renders the button
   (view.plan?.action), so a team / non-changer deep-linking bview=plans always falls
   back to overview instead of a grid of live Choose buttons.

4. Lost portal escape hatch. Whenever the card has no in-app action (teams, non-changers,
   refused subscription, empty catalog) it now ALWAYS carries the "Adjust plan ↗" portal
   link built from subscription?.portal_url ?? billing.portal_url — the refusal caption
   no longer promises a portal the UI didn't render.

5. Choose URLs dropping org_id/plan. (a) derivePlanTiers now threads billing.portal_url
   as the fallback base for the Choose URL. (b) buildManageSubscriptionUrl treats the
   hard-coded FALLBACK_PORTAL_BILLING_URL as a last-resort ORIGIN (applying org_id/plan)
   instead of a bare return, so a null portal_url never strips the routing params.

6. Zero-shift on narrow panes. Replaced the magic min-h-28 (under-reserved once the two
   inputs stack below @2xl) with exact reservation: the edit form is always rendered and
   both states share one grid cell ([grid-template-areas:'stack']), invisible+aria-hidden
   when not editing — the row equals the tallest state at every width, no breakpoint math.
   The refusal stays inside the reserved layer.

Tests: +12 (grandfathered current, no-dead-button + empty-catalog portal link, team &
personal deep-link fallback to overview, billing.portal_url-backed Choose URL, fallback
org_id/plan, reserved-form-mounted); updated the two portal-link expectations for §4.
Billing suite 78/78 green; typecheck (app/electron/e2e) + lint clean.

* refactor(desktop): reuse the shared openExternalLink helper in the plans view

* fix(desktop): honor the auto_reload wire contract — null card + disable amounts

A full-stack contract sweep (desktop ↔ shared types ↔ gateway ↔ NAS) surfaced two
real desktop bugs in the auto-refill row:

A. auto_reload.card can be null. The gateway's _parse_auto_reload_card returns None
   for a missing/unknown-kind card and _serialize_billing_state emits `card: null`,
   but the shared BillingAutoReload.card union had no null arm and use-billing-state
   dereferenced `autoReload.card.kind` bare — a crash on the enabled path. Add `| null`
   to the shared union (contract honesty) and guard the read (`card?.kind`); null now
   falls through to the default enabled path, same as a canonical card.

B. Disable was rejected by the gateway. billing.auto_reload unconditionally requires
   threshold + top_up_amount, so `updateAutoReload({ enabled: false })` came back
   invalid_request. (The TUI always sends both; desktop fixture mode stubbed it.)
   disable() now sends the current threshold_usd/reload_to_usd from the autoReload
   prop alongside enabled: false, matching the TUI.

Tests: enabled auto_reload with card:null renders the normal enabled row (derivation
+ render, no crash); disable call carries both current amounts. Billing suite 80/80
green; typecheck (app/electron/e2e) + lint clean.

* fix(tui): guard the nullable auto_reload card in the auto-reload screen

The shared BillingAutoReload.card union gained its honest null arm (the
gateway emits card: null for a missing/unknown card); the TUI's only bare
dereference follows the same default path as a canonical card.

* fix(desktop): align billing inputs to the sm control height

The three billing inputs used an ad-hoc h-8 (32px) next to size=sm
buttons (24px). They now use the control system's size=sm with a
py-[3px] compensation for the input's real 1px border — buttons draw
theirs as an inset shadow, so sm alone still sits 2px taller. All five
controls in the buy row now measure 24px.

* fix(desktop): plan-card actionability + billing view-model hardening

Code-quality review of the Billing revamp (PR #68722).

BLOCKING — a top-tier subscriber (only downgrades/current below them) opened a
plans grid with zero enabled actions AND no portal link. The plan card gated its
in-app button on `tiers.some(state !== 'current')`, which counts the (disabled)
downgrade tiles. It now gates on an actual UPGRADE being present
(`capable && tiers.some(state === 'upgrade')`); with no upgrade the card falls back
to its "Adjust plan ↗" portal link, and the bview=plans deep link (gated on the same
plan.action) falls back to overview.

Reviewer structural items:
- One "plans capability" verdict (personal + can_change_plan + subscription ok) is
  derived once in deriveBillingView and threaded to BOTH derivePlanCard and
  derivePlanTiers; the grid only mints upgrade actions when capable, so the invariant
  lives in one place.
- BillingPlanTierView is now a discriminated union (`current` | `downgrade` w/
  disabledCaption | `upgrade` w/ required action), and BillingPlanCardView is an
  action-XOR-link union — deleting the `tier.action?.url ?? ''` and `plan.link?.url`
  defensive branches in the consumers.
- `findCurrentTier(subscription)` replaces the repeated is_current||id predicate at
  its three sites (plan card price, grid ordering, summary plan line).
- BillingView exposes named `paymentRow` / `topupRow` / `refillRow` instead of an
  `accountRows[]` + three `.find(id)` lookups.
- The auto-refill row that edits in place carries an explicit `manageInApp: true`;
  AutoReloadRow keys off it instead of sniffing the action label/url.
- tier-art header comment no longer cites an internal repo path; dead `?.` removed
  from RowValue (via a destructured const) and the plan-card link handler.

Behavior is identical except the blocking fix. Billing suite 82/82 green; typecheck
(app/electron/e2e) + lint clean.

* refactor(desktop): adopt inline-review nits on the billing plan card

Resolves the inline suggestion threads:
- plan-card gate reads a named `hasActionableTier` = "a tile carries an action"
  (union-safe `'action' in tier`, equivalent to the old upgrade-only check).
- re-narrow link/action inside the click callbacks (`plan.link && …`,
  `tier.action && …`) rather than relying on outer narrowing.

Behavior unchanged; billing suite 82/82 green, typecheck + lint clean.
2026-07-22 08:07:52 +05:30