Compare commits

..
Author SHA1 Message Date
Brooklyn Nicholson 60325fc138 docs(desktop): document the desktop plugin SDK (@hermes/plugin-sdk)
Add an end-to-end developer guide for extending the native Hermes Desktop
app introduced in #60638: the HermesPlugin contract, PluginContext, every
contribution area (panes, routes, sidebar nav, status/title bar, palette,
keybinds, themes, composer, mount-scoped Contribute), the host API, the
React Query + nanostores data layer, the UI kit + theme variables, the
scoped ctx.rest/ctx.socket backend (plugin_api.py under /api/plugins/<id>)
and its separate enable gate, Settings/defaultEnabled/storage, bundled
plugins, the security model, pitfalls, and a full reference.

- Register the page in the sidebar under Extending -> Plugins.
- Disambiguate from the unrelated web-dashboard plugin SDK from both
  directions, and add a map-table row + a desktop user-guide pointer.
- Fill the gaps in the agent-facing hermes-desktop-plugins skill
  (ctx.rest/socket + backend, React Query, defaultEnabled, Contribute)
  and point it at the new reference so agents know the SDK when writing
  addons.
2026-07-18 23:56:02 -04:00
brooklyn! 667b98b5cf Merge pull request #67296 from NousResearch/bb/composer-model-removed-fallback
fix(desktop): reseed composer when a sticky manual pick was removed from the catalog
2026-07-18 23:50:01 -04:00
David MetcalfeandDavid Metcalfe 4cdfcf568d fix(desktop): don't auto-expand user-collapsed side on reactive unhide (#65375)
* fix(desktop): don't auto-expand user-collapsed side on reactive unhide

When `setTreePaneHidden(paneId, false)` was called for a workspace-gated
pane like `files` (bound to $hasWorkspace), it auto-called
`revealTreePane(paneId)` — which expanded the parent column even when
the user had explicitly collapsed it via Cmd+J.

That meant every session create / resume — anything that flipped
$currentCwd from empty to non-empty — silently re-opened the right
sidebar and persisted that open state to localStorage, so the original
session also showed the sidebar as open on return.

`setTreePaneHidden` is a state primitive; user-intent semantics (open
the side, front the tab) belong to `revealTreePane`. Drop the auto-call
and replace it with a narrower `frontPaneInGroup` helper that only
makes the pane the active tab in its group — visible the next time the
column is opened, without forcing it open now.

* fix(desktop): preserve explicit review reveal

---------

Co-authored-by: David Metcalfe <80915+DavidMetcalfe@users.noreply.github.com>
2026-07-18 23:49:37 -04:00
brooklyn! 4f4a4f0d43 Merge pull request #67236 from NousResearch/bb/tui-incremental-markdown
perf(tui): render streamed markdown incrementally per block
2026-07-18 23:47:15 -04:00
brooklyn! e99a0f6a97 Merge pull request #67283 from NousResearch/bb/salvage-60980-apiserver-offload
fix(api_server): offload synchronous SessionDB calls off the event loop (supersedes #60980)
2026-07-18 23:47:03 -04:00
Brooklyn Nicholson d5decee5ac fix(desktop): reseed composer when a sticky manual pick was removed
A manual composer pick stays sticky across new chats (intended), but if that
model later disappears from the provider/config, every new chat kept trying the
dead model and 404'd. Fall back to the profile default in that one case only.

refreshCurrentModel now consults the model-options cache the composer already
populates (no extra fetch): a manual pick is preserved unless manualPickRemoved()
proves it's gone — provider present, non-empty catalog, model absent. An
unknown/absent provider, an empty (re-auth/unconfigured) list, or a not-yet-loaded
catalog all preserve the pick, so a still-valid selection is never clobbered.
2026-07-18 23:44:40 -04:00
Brooklyn Nicholson 24b44d56a3 refactor(tui): tighten StreamingMd comments and fence folding
Condense the header rationale to the load-bearing invariants and inline
the single-line math-fence guards. No behavior change; scanner logic and
public surface (createScanState / advanceScan / findStableBoundary)
unchanged.
2026-07-18 23:42:10 -04:00
kshitijk4poor 9ef66ea8c1 test: expect restored human anchor after user-role summary compaction
_is_real_user_message no longer accepts a user-role compaction summary as
the human anchor, so _compress_context restores the original user turn
after the summary; update the lifecycle-status test's expectation.
2026-07-19 08:55:08 +05:30
kshitijk4poor c03c247e7c fix(compression): keep anchor restoration alternation-safe and grounding scaffolding-proof
Follow-up hardening on top of the salvaged #66637 commits:

- _insert_real_user_anchor could place the restored human turn directly
  next to user-role scaffolding (index-0 insert before a leading synthetic
  user turn, or a scaffolding-only transcript), breaking the strict
  alternation contract (#55677). Restoration now merges into trailing
  scaffolding (anchor text leads, synthetic flags cleared) and appends
  after a user-role compaction summary instead of inserting adjacent.
- _is_real_user_message now also rejects user-role compaction summaries
  (the compressor pins the summary to role=user when the tail opens with
  an assistant turn), so a summary can no longer satisfy the human-anchor
  check and skip restoration.
- _latest_user_task_snapshot reuses the same real-user predicate, so the
  deterministic task snapshot can no longer anchor on todo snapshots,
  truncation notices, or background-process reports.
- The Historical Task Snapshot rewrite keeps the section terminated with
  a blank line; the previous replacement consumed the boundary newlines,
  gluing the next '## ' heading mid-line and deleting all later sections
  on the next iterative compaction.
- Drop _length_continuation_synthetic (no producer anywhere).
- AUTHOR_MAP entry for enzo-adami.
2026-07-19 08:55:08 +05:30
Enzo Adami d511ce0602 test: assert durable compression rotation 2026-07-19 08:55:08 +05:30
Enzo Adami 2f824ec5d2 test: align string summaries with grounded task snapshot 2026-07-19 08:55:08 +05:30
Enzo Adami 761a0b124e fix: ground compression task snapshot 2026-07-19 08:55:08 +05:30
Enzo Adami 960abf73a0 fix(compression): preserve human intent and durable handoffs 2026-07-19 08:55:08 +05:30
brooklyn! d015500d45 Merge pull request #67287 from NousResearch/bb/salvage-38614-resume-cwd
fix(cli): restore session cwd on mid-chat /resume and /sessions (supersedes #38614)
2026-07-18 23:25:02 -04:00
nousbot-engandgithub-actions[bot] 73fea7b2b0 fmt(js): npm run fix on merge (#67284)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-19 03:21:18 +00:00
Brooklyn NicholsonandDusk1e 3700ca4a54 fix(cli): restore session cwd on mid-chat /resume (transplant to mixin)
Dusk1e's fix wired _restore_session_cwd into _handle_resume_command, but that
handler was extracted from cli.py into hermes_cli/cli_commands_mixin.py
(094aa85c3) after the PR's base, so the original cli.py hunk no longer applied
(a naive cherry-pick fuzzily misplaced it inside new_session(), where
session_meta is undefined). Transplanted the call to the end of the handler in
its current home; /sessions <id> delegates here so both command forms are
covered. Dusk1e's regression tests carry over unchanged.

Co-authored-by: Dusk1e <yusufalweshdemir@gmail.com>
2026-07-18 23:20:32 -04:00
Dusk1e 228d8de19c fix(cli): restore session cwd on mid-chat /resume and /sessions 2026-07-18 23:20:19 -04:00
Brooklyn Nicholsonandnecoweb3 d2cb318509 fix(api_server): reconcile SessionDB offload with per-profile DB routing
Rebased onto current main, where _ensure_session_db grew a per-profile
cache (get_hermes_home()-keyed) for /p/<profile>/ multiplex — after this
PR's base. The PR's async rewrite assumed the old single-self._session_db
model, so on current main `session_db=self._session_db` in _create_agent
would pass None in production (the real DB lives in the per-home cache).

Split the concern: keep a SYNC _ensure_session_db (per-profile, used by
_create_agent + the many sync-patching create_agent tests) and add an
async _ensure_session_db_async that captures the profile home on the loop
thread then offloads only the SQLite open via to_thread (single-flight).
Both share _open_and_cache_session_db. Request handlers use the async
variant; _create_agent reverts to the sync call. Updated the first-request
test's FakeDB to accept db_path to match main's SessionDB(db_path=...).

Co-authored-by: necoweb3 <sswdarius@gmail.com>
2026-07-18 23:14:57 -04:00
brooklyn! f099b469de Merge pull request #67282 from NousResearch/bb/unify-project-status
feat: unify active-project identity in chat status (supersedes #64721)
2026-07-18 23:13:52 -04:00
Brooklyn Nicholson 66a7825ebb feat: unify active-project identity in chat status (supersedes #64721)
Surface the session's first-class Project in both chat surfaces: the
Desktop status bar (project name as the workspace label, full cwd in the
tooltip) and the TUI status label + /status output.

One source of truth. The per-profile projects.db is the authority, read
in tui_gateway via _project_info_for_cwd (backed by
projects_db.project_for_path) and threaded through every session.info
emission path the TUI consumes. The Desktop already caches that truth in
$projectTree, so it DERIVES the label from it (projectNameForCwd) instead
of carrying a second per-session $currentProject atom fed from
session.info.

That drops the parallel state #64721 introduced and the entire
reset/reconciliation surface it required (resume, agentless cwd.set,
gateway-switch, fresh-draft): the label is purely derived, so it stays
correct whenever the cwd or the project tree changes. Only explicit,
named projects resolve on both surfaces, so an auto-discovered repo root
keeps the cwd-leaf label everywhere.

Excludes the unrelated markdown shell-fence change bundled in #64721.
2026-07-18 23:08:13 -04:00
dsad 5529175084 fix: TOCTOU race in session create + offload SessionDB init
- Make create sequence (check + insert + title) atomic via single
  _execute_write call with BEGIN IMMEDIATE, closing the TOCTOU window
  where two concurrent same-ID creates could both return 201.

- Offload _ensure_session_db() to asyncio.to_thread with single-flight
  lock so first-request SQLite init doesn't block the event loop.

- Add concurrent same-ID create test (one 201, one 409) and
  first-request path test covering the initialization.
2026-07-18 23:07:29 -04:00
dsad 7ba944d054 fix(api_server): offload synchronous SessionDB calls off the event loop 2026-07-18 23:06:17 -04:00
kshitijk4poorandSoju06 c7035ef252 fix: add api_content to _CONVERSATION_ROW_COLUMNS for get_resume_conversations
The PR added api_content to get_messages_as_conversation's inline SELECT
but missed the shared _CONVERSATION_ROW_COLUMNS constant used by
get_resume_conversations — _rows_to_conversation references
row["api_content"] but the column wasn't in the SELECT, causing
IndexError on resume-session tests.

Co-authored-by: Soju06 <qlskssk@gmail.com>
2026-07-19 08:25:35 +05:30
kshitijk4poorandSoju06 39efad89a8 refactor: shared helpers for api_content sidecar pop/drop/extract
Deduplicates the sidecar handling across 9 sites:
- substitute_api_content(): 2 API-bound pop+substitute sites
  (chat_completion_helpers, transport — conversation_loop keeps its
  inline pop because the current-turn compose fallback needs the value)
- drop_stale_api_content(): 4 rewrite-drop sites
  (context_compressor x2, replay_cleanup, agent_runtime_helpers)
- extract_api_content_sidecar(): 3 gateway forwarding sites
  (gateway/session, gateway/slash_commands, cli_commands_mixin)

Also: restore the eager _pending_cli_user_message clear on the early
row-creation try (was lost when the crash-persist moved after prefetch —
a crash in compression between the two tries would leave a stale staged
input), and fix a comment indentation nit in run_agent.py.

Co-authored-by: Soju06 <qlskssk@gmail.com>
2026-07-19 08:25:35 +05:30
Soju06 7b3dcee928 feat(cache): persist the exact bytes sent to the API in an api_content sidecar
The first LLM call of every gateway turn gets ~0% provider prompt-cache
hit rate (in-turn calls: 97-99%) because the bytes sent for a turn's
user message are not the bytes replayed next turn: memory-prefetch and
pre_llm_call context are injected into the API copy only, the #48677
persist override writes cleaned content to the DB row, and
get_messages_as_conversation sanitize/strips user and assistant content
on load. Any of these diverges the request prefix at that message and
re-prefills everything after it — measured 27.9s for the first call vs
2.4-5.8s cached at a median ~156k-token context.

Persist what you send: a nullable messages.api_content column stores the
exact content string sent to the API when it differs from the clean
stored content, and replay substitutes it verbatim (no sanitize, no
strip). The injection composition lives in one helper
(turn_context.compose_user_api_content); the turn prologue stamps its
output onto the live user message, the api_messages build sends the
stamped bytes, and every outgoing copy pops the field so it never
reaches a provider. The crash-resilience user-turn persist moves after
prefetch/pre_llm_call so the user row is written once with its final
sidecar; _ensure_db_session stays before preflight compression (session
rotation needs the parent row under PRAGMA foreign_keys=ON). The
current-turn index trackers are re-anchored after compaction rebuilds
the message list, in-place preflight compaction backfills the stamp onto
the already-inserted row, and gateway replay forwards the sidecar only
when the replay pipeline did not rewrite the content. Rewrite paths that
would leave stale bytes (historical image strip, merge-summary-into-
tail, consecutive-user repair merge, stale-confirmation redaction) drop
the sidecar; the chat-completions transport and the max-iterations
summary path strip it defensively. codex_app_server and MoA turns are
excluded from stamping because their wire bytes differ from the
composition. A missing or dropped sidecar degrades to today's behavior
(one cache-boundary miss), never to wrong content.
2026-07-19 08:25:35 +05:30
brooklyn! e53f87fe69 Merge pull request #67238 from NousResearch/bb/anthropic-owner-thread-abort
fix(agent): request-local Anthropic clients so the stale/interrupt watchdog never corrupts SQLite (#67142, supersedes #51688)
2026-07-18 22:37:40 -04:00
nousbot-engandgithub-actions[bot] 19bf16c4da fmt(js): npm run fix on merge (#67258)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-19 02:27:32 +00:00
brooklyn! 8e9c176117 Merge pull request #67245 from NousResearch/bb/desktop-sidebar-batch
perf(desktop): batch sidebar session slices into one profile-DB pass
2026-07-18 22:21:12 -04:00
brooklyn! ea1cc1dd4b Merge pull request #67247 from NousResearch/bb/desktop-resume-single-read
perf(gateway): serve session.resume model + display history from one SELECT
2026-07-18 22:18:39 -04:00
Brooklyn Nicholson fbabdfbe15 bench(tui): add per-append timing series mode to streaming-md bench 2026-07-18 22:16:23 -04:00
kshitijk4poor 66ed9d63fe refactor: extract 6 copy-paste voice interrupt blocks into one helper
The busy/priority/monitor/backup×2/drain paths each had near-identical
try/except blocks for transcribe+echo with only log_context, adapter,
and metadata varying. Extract into _transcribe_and_echo_pending_voice
which handles the cache lookup, echo dedup, and exception logging in
one place.

Uses a _UNSET sentinel to distinguish 'caller did not pass metadata'
(use the rich _thread_metadata_for_source fallback) from 'caller
explicitly passed None' (monitor/backup/drain paths that use the
simpler thread_id-only dict).

~120 lines of copy-paste collapsed into one 30-line helper.
2026-07-19 07:27:29 +05:30
kshitijk4poor bca886c844 fix(gateway): invalidate pending STT cache when media merges into event
merge_pending_message_event extends the existing event's media_urls in
place when two media-bearing messages arrive in quick succession (photo
bursts, consecutive voice messages).  The gateway runner caches STT
transcripts on the event via _gateway_pending_stt_text; if the cached
event gains new media after the cache was populated, the stale transcript
was returned instead of transcribing the merged attachments.

Add _invalidate_pending_stt_cache() and call it from both media-merge
branches in merge_pending_message_event so the next transcription call
re-runs against the full merged media list.

Closes the merge-race edge case identified during review of PR #61519.
2026-07-19 07:27:29 +05:30
kshitijk4poor 5920b305f4 refactor: delete dead _dequeue_pending_with_transcription
The function was introduced in d55304c39 but never had a single caller —
verified via git log -S and search_files across the whole repo. The
drain path at _stream_confirmed_final_delivery inlines its logic directly.
Keeping a dead function around that duplicates live logic is a maintenance
hazard: future changes to the live path won't propagate to the dead one.
2026-07-19 07:27:29 +05:30
yu-xin-c 6a135142cc test(gateway): use compression helper in voice regression 2026-07-19 07:27:29 +05:30
yu-xin-c 7b330b1d22 fix(gateway): preserve pending voice media semantics 2026-07-19 07:27:29 +05:30
yu-xin-c f5d493aebf fix(gateway): dedupe pending voice transcript echoes 2026-07-19 07:27:29 +05:30
kshitijk4poor 71157cbf66 refactor(turn_finalizer): extract _is_pure_tool_call_tail, fix SQLite durability
Extract the inline pure-tool-call tail check to a named helper using
flatten_message_text (canonical content extraction). Fix a SQLite
durability regression: the incremental tool-call persist
(conversation_loop.py:4990) stamps _DB_PERSISTED_MARKER on the assistant
row, so the next _persist_session flush skips it — the filled content
reaches the in-memory transcript but NOT the durable store, and /resume
reloads content="". Pop the marker so the next flush re-writes the row.

Tests pass, ruff clean.
2026-07-19 07:27:10 +05:30
Frowtek 56ac96976b fix(agent): persist the delivered response when the turn tail is a tool-call row
`finalize_turn` guarantees the invariant "delivered final_response =>
assistant row in transcript" (#43849 / #44100) for recovery paths that
return a response without appending a closing assistant message. It
enforces it by checking only the tail's ROLE:

    if _tail_role != "assistant":
        messages.append({"role": "assistant", "content": final_response})

A tail that is a *pure tool-call turn* — `assistant(tool_calls=[...])`
with no text of its own — satisfies that check while carrying none of the
delivered answer. The append is skipped and the response is never
persisted, so the durable transcript ends at an assistant row the user
never saw as the reply. On the next turn the model replays the user
backlog and re-answers it: exactly the symptom the block was added to
prevent, just reached through a different tail shape.

Observed with the real finalizer: with messages ending
`user -> assistant(content="", tool_calls=[t1])` and
`final_response="Here is your answer."`, the persisted transcript keeps
`content=""` and the answer is absent.

Fill that row's empty content instead of appending. This keeps the
invariant without disturbing the tool-call structure and without creating
an assistant->assistant pair. A tail tool-call row that already carries
model text is left untouched, so no model output is ever overwritten.

Adds regression tests for both: the empty tool-call tail is filled, and a
tool-call tail with existing text is not clobbered.
2026-07-19 07:27:10 +05:30
Brooklyn Nicholson 206953fb66 perf(gateway): serve session.resume's model + display history from one SELECT
session.resume built two projections of the same lineage with two separate
get_messages_as_conversation calls — the model-fed copy (tip rows, alternation-
repaired) and the display copy (full lineage, verbatim). The display fetch
already reads a superset of the model fetch (the tip rows are part of the
lineage), so the second call re-scanned the same messages.

Add SessionDB.get_resume_conversations(): one lineage SELECT, split into the tip
(model) and full (display) projections in memory via the extracted
_rows_to_conversation helper. Byte-identical to the two separate reads
(test_get_resume_conversations_matches_separate_reads covers a lineage with a
dangling tool-call tail so repair diverges the two lengths, a single session,
and replayed-user dedup). Both session.resume build paths (deferred + eager) now
use it; the subagent single-read path is unchanged.

From the Desktop performance audit (P1: "Remove repeated resume transcript
work") — the gateway half. The renderer's concurrent REST prefetch is left as-is
(the audit flags dropping it as measure-first).
2026-07-18 21:50:26 -04:00
brooklyn! 7235592ad2 Merge pull request #67241 from NousResearch/bb/telegram-reconnect-watchdog
fix(telegram): cause-agnostic wedged-recovery watchdog + bounded drain so the reconnect ladder can't freeze silently (#66377, supersedes #66492)
2026-07-18 21:48:19 -04:00
Brooklyn Nicholson 40160e2a04 perf(desktop): batch sidebar session slices into one profile-DB pass
The sidebar refresh fired three /api/profiles/sessions calls (recents, cron,
messaging), and each one reopened every selected profile's state.db and re-ran
list_sessions_rich + session_count — ~3N DB opens/counts per refresh, on every
turn/broadcast/reconnect.

Add GET /api/profiles/sessions/sidebar: one pass that opens each profile DB
once and runs the three source-scoped queries together (recents scoped to the
active profile; cron + messaging cross-profile), returning the three windows in
one payload. Same read-only projection, 300s active heuristic, and caller-
supplied source taxonomy (recents_exclude / messaging_exclude / source=cron) as
the per-slice endpoint.

Renderer refreshSessions now makes one listSidebarSessions call and distributes
recents/cron/messaging to their stores (cron *jobs* stay a separate getCronJobs
API). Electron splices remote profiles per slice via fetchProfilesSessionSlice
(reusing the proven per-slice merge) so remote correctness is preserved; the
no-remote common case gets the single-open fast path.

From the Desktop performance audit (P1: "Batch sidebar session slices").
2026-07-18 21:45:18 -04:00
Brooklyn Nicholson 23d2fd5d78 chore(contributors): map mkoduri73@gmail.com -> MaheshBhushan
Attribution mapping for the salvaged #66492 commit (#66377).
2026-07-18 21:38:20 -04:00
Brooklyn NicholsonandKoduri Mahesh Bhushan Chowdary c2cb37532c fix(telegram): add cause-agnostic wedged-recovery watchdog so the reconnect ladder can't freeze silently (#66377)
The Telegram gateway could go silently deaf for hours: the reconnect ladder
stalled mid-way (e.g. "attempt 4/10, reconnecting in 40s" then nothing) while
the process stayed active(running), so Restart=always never fired.

Root class: every recovery path — the ladder's re-entry
(_schedule_polling_recovery), the pending-update probe (_probe_pending_updates),
and PTB's error callback — gates new recovery on _polling_error_task.done(). If
that single task wedges on any hung await, all recovery returns early forever
and nothing retries.

The heartbeat loop is a separate task, so make it an independent, cause-agnostic
watchdog: if the same recovery task stays in-flight past
_POLLING_ERROR_TASK_STUCK_TIMEOUT (300s — well beyond a healthy ladder attempt's
bounded stop+drain+start+backoff), force a retryable-fatal so the background
reconnector rebuilds the adapter instead of relying on the frozen ladder. This
guarantees progress regardless of *where* the stall is (issue direction #1),
tracked locally so no task-assignment site needs to change.

Also salvages @koduri-mahesh-bhushan-chowdary's #66492 (drain-await timeout),
which closes the one concrete wedge vector documented in the incident
(_drain_polling_connections' unbounded shutdown()/initialize() on a wedged
CLOSE-WAIT pool). The watchdog covers the rest of the class.

Co-authored-by: Koduri Mahesh Bhushan Chowdary <mkoduri73@gmail.com>
2026-07-18 21:31:15 -04:00
Koduri Mahesh Bhushan ChowdaryandClaude Fable 5 3391e639f6 fix(telegram): bound polling drain so wedged pool close can't stall reconnect ladder (#66377)
_drain_polling_connections() awaited polling_req.shutdown() and
.initialize() without a timeout. When the getUpdates httpx connection is
wedged on a stale CLOSE-WAIT socket, that close can block forever, hanging
_handle_polling_network_error (the tracked _polling_error_task). The task
never completes, so every escalation path — _schedule_polling_recovery,
_probe_pending_updates, the heartbeat verifier — stays gated behind its
in-flight guard, the ladder freezes mid-way, _set_fatal_error is never
reached, and Restart=always never fires: the gateway is alive but silently
dead.

Wrap both drain awaits in asyncio.wait_for with a new module-level
_DRAIN_TIMEOUT (15.0s, matching _UPDATER_STOP_TIMEOUT), mirroring the
existing bounded stop()/start_polling() sites. On timeout the drain logs and
continues, so the handler task completes and the ladder always advances
toward the fatal-restart escalation.

Adds test_reconnect_continues_if_drain_hangs, which wedges the drain and
asserts the handler still reaches start_polling within a hard bound.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:23:11 -04:00
Brooklyn Nicholsonandraymondyan-zhijie 42c240f580 fix(agent): request-local Anthropic clients so the stale/interrupt watchdog never corrupts SQLite (#67142)
Direct-Anthropic requests used a single shared _anthropic_client, and the
stale/interrupt watchdog closed + rebuilt it from the poll (stranger) thread
at four sites (non-streaming stale/interrupt, streaming stale/interrupt).
Closing a client whose TLS socket a worker thread was still reading released
the FD from a stranger thread; the kernel recycled it under a live SSL BIO,
which then wrote a 24-byte TLS record into an unrelated SQLite header
(cron/executions.db), bricking every cron on the profile. Same shape as the
OpenAI-only #29507 fix, but the Anthropic path never got the owner-thread
contract.

Extend the #29507 ownership contract to Anthropic: build a per-request client
(_create_request_anthropic_client), register it with the request-client
holder tagged by kind, and route _close_request_client_once by kind — a
stranger thread only shuts the request client's sockets down
(_abort_request_anthropic_client), while the owning worker performs the SDK
close (_close_request_anthropic_client). The shared _anthropic_client is now
never closed from inside a request (streaming or non-streaming), including the
worker retry-cleanup sites, since each attempt builds a fresh request client.
The #28161 no-hang guarantee is preserved: the poll-thread socket abort
unblocks the worker immediately.

Salvages the approach from #51688 (@raymondyan-zhijie), reimplemented onto
current main (non-streaming dispatch was refactored into
_dispatch_nonstreaming_api_request; streaming grew _cancel_current_stream_attempt
and worker retry-cleanup sites). Tests updated to the request-local mechanism
(incl. replacing a banned source-reading test with a behavior test) plus new
regression coverage proving the watchdog aborts the request client and never
touches the shared client.

Co-authored-by: raymondyan-zhijie <32435458+raymondyan-zhijie@users.noreply.github.com>
2026-07-18 21:20:48 -04:00
Brooklyn Nicholson a4890569a3 perf(tui): render streamed markdown incrementally per block
StreamingMd previously split in-flight text into one memoized
stable-prefix <Md> plus a re-parsed tail. Every time the stable boundary
advanced, the prefix string changed, its memo key missed, and the entire
prefix re-tokenized — O(blocks^2) parse work across a long reply — and
finding the boundary rescanned fence state from position 0 on every
delta.

Replace the monolithic prefix with an append-only array of settled
top-level blocks, each rendered as its own <Md> memoized on text that
never changes once committed (every block parses exactly once for the
life of the stream), plus a persistent scanner that keeps fence/math
open-state and scan position across deltas so each delta only scans the
newly arrived complete lines. Boundaries stay at "\n\n" outside
code/math fences; partial trailing lines stay in the tail until their
newline arrives so a growing "```" can't be misjudged.

Replaying a newline-terminated block-heavy stream at width 80 through a
real Ink render (one process per strategy so the parse LRU and GC
pressure can't cross-contaminate):

| Blocks | Appends | naive full Md | monolithic prefix | per-block |
|--------|---------|---------------|-------------------|-----------|
| 32     | 135     | 279.7 ms      | 221.1 ms          | 104.7 ms  |
| 128    | 543     | 3.03 s        | 2.39 s            | 317.8 ms  |
| 512    | 2175    | 56.28 s       | 35.14 s           | 3.05 s    |

Remaining per-block cost is Ink layout of the growing tree, not parsing.
Bench: ui-tui/scripts/bench-streaming-md.tsx.
2026-07-18 21:14:05 -04:00
brooklyn! 614dc194ea Merge pull request #67195 from NousResearch/bb/desktop-perf-p2
perf(desktop): scope tool-diff subscriptions + narrow profile query invalidation
2026-07-18 19:21:27 -04:00
Brooklyn Nicholson e30174fa17 perf(desktop): scope tool-diff subscriptions + narrow profile query invalidation
Two structural fixes from the Desktop performance audit (P2 tier):

1. Scope live tool-diff subscriptions. `ToolEntry` subscribed to the whole
   `$toolDiffs` map via `useStore`, so one `recordToolDiff` re-rendered every
   mounted tool row. Add a cached per-toolCallId derived atom
   (`$toolInlineDiff(id)`, mirroring the existing `$toolDisclosureOpen` pattern);
   computed() only notifies when that id's diff string changes, so a live patch
   re-renders one row.

2. Narrow profile / gateway-switch query invalidation. Both the active-profile
   subscription and `wipeSessionListsForGatewaySwitch` called keyless
   `queryClient.invalidateQueries()`, refetching account/marketplace/onboarding
   caches on every switch. Add `invalidateProfileScopedQueries()` with a
   denylist of profile-independent roots (billing, marketplace-themes,
   onboarding-model-options, contrib-logs-tail). A denylist is correctness-safe:
   a root we forget just refetches (cheap), whereas an allowlist that misses a
   profile-scoped key would paint the previous profile's data.

Tests: per-tool notify isolation, and real-QueryClient invalidation partition
(profile-scoped invalidated, global left intact, unknown keys invalidated).
2026-07-18 19:15:57 -04:00
brooklyn! 34e66a0d52 Merge pull request #67192 from NousResearch/bb/p2-config-salvage
fix(config): P2 batch — .env quoting/UTF-16, aux key_env, profile-aware system prompt
2026-07-18 19:10:05 -04:00
webtecnica 2bae4df8bb fix: replace hardcoded ~/.hermes with get_hermes_home() in system prompt (#66450)
The system prompt building code hardcoded '~/.hermes' paths instead of
using get_hermes_home(). When HERMES_HOME is set to a custom location,
the prompt text still referenced ~/.hermes, confusing the AI about
where files actually live.

Changed:
- Import get_hermes_home from hermes_constants
- Default profile hint: ~/.hermes/profiles/<name>/ → <home>/profiles/<name>/
- Non-default profile hint: ~/.hermes/... → <home>/... for all paths

Closes #66450
2026-07-18 19:03:51 -04:00
webtecnica 65bf42b669 fix(auxiliary): resolve key_env in _resolve_task_provider_model (#66641)
_resolve_task_provider_model() read api_key from the auxiliary task
config but never consulted key_env (or api_key_env). When a user
configured an auxiliary task with key_env instead of a plaintext
api_key, the resolved API key was None, causing 401 on every call.

Add the same key_env → os.getenv() resolution pattern already used in
_fallback_entry_api_key() and named custom provider resolution.

Closes #66641
2026-07-18 19:03:51 -04:00
Paulo Nascimento 90d3ba5be9 fix(cli): warn once per path for UTF-32 .env refuse-to-mangle
Hot-reload and multi-entry load_hermes_dotenv can hit the same UTF-32
file repeatedly; gate the refuse-to-mangle warning on a module-level
seen-set (house style: _WARNED_KEYS sibling) so logs are not spammed.
2026-07-18 19:01:10 -04:00
Paulo Nascimento 7d597cc5d4 fix(cli): sanitize UTF-16 .env without corrupting the first key
Notepad "Unicode" saves write UTF-16 with a BOM. The sanitizer decoded
those bytes as utf-8-sig with errors=replace, glued U+FFFD onto the first
key name, stripped NULs, and rewrote the mangled content permanently.

Sniff leading BOMs before any text decode (UTF-32 before UTF-16, because
UTF-32-LE's BOM starts with UTF-16-LE's FF FE). Decode UTF-16 correctly
and rewrite as clean UTF-8. Refuse UTF-32 (leave untouched + warning).
After errors=replace, do not persist a first line that starts with U+FFFD.

Does not touch _load_dotenv_with_fallback (#65124's surface).
2026-07-18 19:01:10 -04:00
Paulo Nascimento 4441e11c77 fix(cli): quote .env values with internal whitespace in save_env_value
_quote_env_value previously left internal spaces unquoted (only #/"/'
and leading/trailing whitespace triggered). Spaced macOS paths written
via hermes setup SSH / Google Chat SA path / hermes config set produced
lines that python-dotenv still parsed but shell `set -a; . file` word-split.

Extend needs_quoting with any(c.isspace()); escaping dialect unchanged.
2026-07-18 19:01:10 -04:00
brooklyn! bf411238d8 Merge pull request #67176 from NousResearch/bb/incremental-markdown-lex-fix
fix(desktop): correct incremental markdown split boundary (setext underline merge)
2026-07-18 18:58:49 -04:00
brooklyn! ed957aeb26 Merge pull request #67182 from NousResearch/bb/p0-salvage
fix(install): keep install.ps1 pure ASCII so Windows PowerShell 5.1 doesn't misparse it (#66994/#67000)
2026-07-18 18:58:31 -04:00
brooklyn! 4f10b4f15d Merge pull request #67183 from NousResearch/bb/mcp-poll-loop-oom
fix(mcp): stop gateway OOM from poll loop swallowing a completed future's real TimeoutError (supersedes #63918, #64072, #63903, #66039)
2026-07-18 18:55:26 -04:00
Brooklyn Nicholson d8b59bd60e test(desktop): raise timeout on markdown-blocks property fuzz
The char-level streaming fuzz runs 12 seeds × 500 growing prefixes
(~6000 full+cached lexes) and first trips the pre-fix boundary at
seed 11 / step 257, so the workload can't shrink without gutting the
guard. The work is bounded but exceeds Vitest's 5s per-test default on
CI workers under parallelism, so give this one test an explicit 30s
timeout instead of weakening coverage.
2026-07-18 18:53:41 -04:00
Brooklyn Nicholson 1cec5c69d3 test(mcp): e2e integration coverage for #63892 poll-loop OOM spin
The salvaged unit tests hand-construct completed futures to lock the
_run_on_mcp_loop contract. Add a live-loop test that reproduces the actual
field trigger: an inner asyncio.wait_for expiry stores a real TimeoutError
on a real future scheduled on the MCP loop. Asserts the fixed loop surfaces
it once, promptly -- not spinning to the outer deadline (which both leaked
memory and masked the real error behind the generic wrapper message).
2026-07-18 18:49:56 -04:00
PRATHAMESH75 97249cfc8a fix(install): keep install.ps1 pure ASCII so Windows PowerShell 5.1 doesn't misparse it
A commit added a bullet and an em-dash inside two Write-Host/Write-Info string
literals in scripts/install.ps1. The file has no UTF-8 BOM, so Windows
PowerShell 5.1 (which the bootstrap runs the cached script under) reads it in
the system ANSI code page (CP1252), not UTF-8. The em-dash's UTF-8 tail byte
decodes to a smart close-quote (U+201D) that the tokenizer treats as a string
delimiter, prematurely closing the string and desyncing the parser -- surfacing
as the reported cascade of syntax errors at lines 1619/1770 and aborting the
Windows GUI installer before it does anything.

Non-ASCII bytes in '#' comments are harmless (skipped to end-of-line), so the
file carried em-dashes in comments for months; only the two chars in code
broke it. Convert all non-ASCII to ASCII equivalents (em-dash -> '--', already
this file's own comment convention; bullet -> '-') and add a source-level test
locking the pure-ASCII invariant, since Linux CI cannot run the PS installer.

Fixes #66994
Fixes #67000
2026-07-18 18:40:59 -04:00
Jupiter363 3df8bd3478 fix(mcp): propagate stored timeouts from completed futures 2026-07-18 18:40:34 -04:00
Brooklyn Nicholson e934ee440e fix(desktop): correct incremental markdown split boundary (setext merge)
The streaming block splitter added in #67154 dropped only the previous
parse's trailing whitespace blocks plus its LAST content block before
re-lexing the appended suffix. That boundary is unsound: a trailing
Setext underline (`-`/`=`) underlines the paragraph ABOVE it, so
appending to it can retroactively merge the previous parse's last TWO
blocks into one.

Minimal repro: cached "…#e\n5\n-" lexes to [ …, "#e\n", "5\n-" ], but
grown to "…#e\n5\n-p2=kj:c" collapses "#e"/"5\n-" into a single block.
The reused settled prefix still contained a stale "#e\n" block. The
`blocks.join('') === text` guard can't detect this because the wrong
split reconstructs the same source string, so the divergence rendered
as mis-split blocks with no fallback.

Fix: drop the last TWO content blocks (skipping whitespace-only blocks
around them) before re-lexing the suffix. The block before the last is
the deepest an append can reach — a Setext underline consumes exactly
one preceding block — and earlier blocks stay fenced off by settled
blank lines, so re-lexing two is sufficient and safe.

Tests: a deterministic regression for the exact prev→grown pair, and a
character-level streaming property fuzz (12 seeds × 500 growing prefixes
over the markdown control alphabet). Both fail on the pre-fix boundary
and pass after. tsc/eslint/prettier clean; markdown-text suite green.
2026-07-18 18:21:58 -04:00
brooklyn! 1310ceb07b Merge pull request #67154 from NousResearch/bb/incremental-markdown-lex
perf(desktop): incremental block lexing for streaming markdown — 14× less splitter CPU on long replies
2026-07-18 18:18:37 -04:00
Teknium 7a43ab042f fix(computer_use): reconnect a dead cua-driver session instead of hanging (#67138)
Bug 1 of #55048: when the MCP connection dropped (driver crash / restart),
_lifecycle_coro exited but left _started=True, so the next list_apps/capture
passed _require_started() and then operated on a None session — hanging
forever instead of reconnecting.

- _lifecycle_coro's finally now resets _started=False on ANY exit, so a dead
  session is re-enterable (idempotent no-op on the normal stop() path; atomic
  bool write, safe from the bridge-loop thread without the lock stop() holds).
- call_tool() re-enters start() when the session isn't active, rebuilding it
  before the call. The start_session/end_session handshake (driven by start()/
  stop() themselves) is exempted so bootstrap doesn't recurse.

Tests: two cases in test_computer_use_delivery_ladder.py — finally resets
_started, and call_tool restarts a dead session exactly once. Full
computer_use suite green (233).

Refs #55048 (Bug 1). Bug 2 (expose foreground dispatch) is covered by the
delivery_mode work in #67123.
2026-07-18 15:07:04 -07:00
nousbot-engandgithub-actions[bot] c34b29d11a fmt(js): npm run fix on merge (#67152)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-18 21:49:35 +00:00
Brooklyn Nicholson bd4953b30d perf(desktop): incremental block lexing for streaming markdown — 14x less splitter CPU on long replies
Fourth profiling round (#66033/#66347/#66470). Streamdown's
parseMarkdownIntoBlocks is a full `marked` lex of the entire message,
and during streaming every flush is a new string — so the splitter paid
O(full-text) ~30x/s: benchmarked 3.4-9.6ms per call at 64-192KB, i.e.
15-30% of a core burned re-lexing settled text on long agent replies.
(The rest of the May-2026 "re-parse elephant" was already eaten by
tailBoundedRemend, block-memo, the KaTeX memo, and deferred shiki —
the splitter was the last O(full-text) pass besides preprocess, which
benches at only 1.5-4.6ms and is left alone.)

New src/lib/markdown-blocks.ts (extracted from markdown-text.tsx) wraps
the splitter with two caches:

- the existing exact-string LRU (remounts: virtualizer scroll, session
  switch) — moved, unchanged;
- a streaming-append cache: when the new text startsWith a recently
  parsed text, reuse that parse's blocks up to a settled boundary and
  lex only the suffix. The boundary drops trailing whitespace-only
  blocks plus the last content block — the only block appended text can
  reinterpret (open fence, list/table continuation, setext underline,
  lazy blockquote). Earlier blocks are separated by settled blank lines
  and can't change. Cross-block reference links can't regress:
  Streamdown already renders each block as an independent document.

Safety: the splitter's blocks.join('') === text property makes offsets
exact and is defensively re-checked; any mismatch or non-append rewrite
(edit, branch swap) falls back to the full lex — byte-for-byte the old
behavior.

Property tests: at every random streaming cut over a corpus covering
fences, loose lists, tables, setext headings, lazy blockquotes, HTML
blocks, and display math — and token-by-token through a fence boundary
— the cached splitter's output is asserted deep-equal to a fresh full
lex. Bench (128KB reply, 200 flushes): 990ms -> 72ms total splitter CPU
(4.95 -> 0.36ms/flush).

Verification: tsc clean; eslint/prettier clean; lib + assistant-ui
suites green (473 tests).
2026-07-18 17:45:55 -04:00
brooklyn! 73e32f37e7 Merge pull request #66747 from NousResearch/perf/desktop-hot-paths
perf(desktop): cut startup serialization and per-turn REST amplification
2026-07-18 17:42:41 -04:00
Austin Pickettandlucaskvasirr ca0703feae fix(cli): try bundled TUI before requiring ui-tui workspace (#67116)
_make_tui_argv() called _ensure_tui_workspace(tui_dir) unconditionally
before checking for a prebuilt bundle. That function sys.exit(1)s when
ui-tui/ doesn't exist, which it never does on a pip/pipx install — the
wheel ships hermes_cli/tui_dist/entry.js but never ships ui-tui/ at all
(that directory only exists in a git checkout).

Every dashboard Chat tab connection on a pip/pipx install therefore
hard-exited before ever reaching _find_bundled_tui(), surfacing as the
unhelpful "Chat unavailable: 1" banner despite having a fully valid
bundled entry.js on disk.

Move the bundled-wheel/HERMES_TUI_DIR shortcut ahead of the workspace
check. --dev is unaffected (it never uses the bundled path and still
requires the workspace), and the checkout-without-bundle path is
unaffected (bundled lookup returns None, falls through to the existing
git-restore/npm-install/build flow).

Adds a contributors/emails mapping for the original author.

Fixes #56665

Co-authored-by: lucaskvasirr <lucaskvasir@duck.com>
2026-07-18 17:17:25 -04:00
teknium1 11cb9e571f fix: harden /model --once against persistence and config-sync leaks
Fixes the two review defects that kept PR #29923 open, plus docs:

- gateway: exclude --once from the session-store write-through. The
  once-override lived only in memory before, but the write-through
  persisted it, so a gateway restart before the finally-restore
  rehydrated a supposedly one-turn model permanently.
- TUI: skip _sync_agent_model_with_config while a one-turn restore is
  pending. The once-model is deliberately not pinned as a session
  model_override, so the config sync saw a model mismatch and clobbered
  the once-override back to the config model before the turn ran.
- tests: real _handle_model_command drive asserting --once never
  touches set_model_override while --session still does; restore-pop
  idempotency.
- docs: /model --once in configuring-models.md with an honest
  prompt-cache cost note (one-shot switch breaks the cached prefix
  twice; wins for short sessions and cheap-to-expensive escalation).
2026-07-18 14:01:56 -07:00
deusyu 3f84b7a163 feat: add /model --once one-turn model override (#29914)
Adds --once to /model across CLI, TUI, and gateway: switch model for the
next turn only, restoring the previous model in a finally block so
success, exception, and interrupt all revert. Parsing extends
parse_model_flags_detailed(); resolve_persist_behavior() treats --once
as a persistence opt-out; --global + --once is rejected.

Salvaged from PR #29923 (image-generation lane split to #59815 per
review; conflict resolution against current main by the maintainers).
2026-07-18 14:01:56 -07:00
teknium1 7ab95b4c9c chore: map emo-eth contributor emails for #66149 salvage
Replaces the frozen LEGACY_AUTHOR_MAP additions from the pre-migration
branch with per-email contributor files (conflict-free path).
2026-07-18 14:01:33 -07:00
Teknium 38b39b87ef fix(discord): keep recovery ledger I/O off event loop
Offload scan bookkeeping and final-delivery writes so SQLite contention cannot stall Discord heartbeats or message delivery.
2026-07-18 14:01:33 -07:00
Teknium 2b2203e3a7 fix(discord): advance cursors only after final delivery
Round-robin configured Discord histories under the global scan cap, but move each channel/thread cursor only when its source message reaches successful final delivery.
2026-07-18 14:01:33 -07:00
Teknium bc0e5adb1d fix(discord): persist per-channel recovery cursors
Resume each configured Discord channel/thread after its last scanned message so busy sources cannot permanently starve later history windows.
2026-07-18 14:01:33 -07:00
Teknium 92a7145297 test(streaming): include original reply anchor metadata 2026-07-18 14:01:33 -07:00
Teknium 9412f2dd84 fix(discord): persist streamed final delivery
Carry the original reply anchor through stream metadata so a successful final Discord edit marks the recovered source message complete.
2026-07-18 14:01:33 -07:00
Teknium d5b9c1ee37 fix(discord): guard recovery claims and ledger failures
Honor configured bot senders at shared ingress, fail closed when durable state is unavailable, and suppress duplicate reconnect work while a fresh queued/processing claim is active.
2026-07-18 14:01:33 -07:00
Teknium da955a643e fix(discord): preserve recovery message identity
Admit recovered events without consuming live dedup first, bypass split-message debounce, report actual dispatch admission, and require explicit reply correlation before suppressing a missed request.
2026-07-18 14:01:33 -07:00
Teknium 26eafd6a00 refactor(discord): remove unused recovery reaction probe 2026-07-18 14:01:33 -07:00
Teknium fad6cbaed3 fix(discord): make reconnect recovery lifecycle-safe
Preserve monotonic final-delivery completion, isolate recovery config and storage per adapter/profile, coalesce reconnect scans, release cancelled claims, bypass split-message debounce for historical events, and move bounded ledger setup into a short-timeout state module.
2026-07-18 14:01:33 -07:00
Teknium ec24fcc682 docs(discord): clarify default recovery scope 2026-07-18 14:01:33 -07:00
Teknium 95ce3344c4 test(discord): assert global recovery scan cap
Keep the scan-cap regression focused on the invariant instead of depending on per-channel ordering.
2026-07-18 14:01:33 -07:00
Teknium 80744bc2bc fix(discord): close reconnect recovery edge cases
Include allowed mention-gated channels in default recovery scope, keep the newest bounded history window, narrow outage-message detection, avoid disabled-path ledger I/O, and persist forum replies.
2026-07-18 14:01:33 -07:00
Teknium 2278f2cb7e fix(discord): harden reconnect message recovery
Route recovered messages through the live Discord ingress policy, preserve dedup and completion invariants, bound and retain the recovery ledger, and expose the opt-in config with docs and backup coverage.
2026-07-18 14:01:33 -07:00
emo-eth 867037bced test: update Discord backfill import for plugin adapter 2026-07-18 14:01:33 -07:00
James a52041b2e0 fix: avoid masking missed Discord parent messages
Preserve startup missed-message backfill behavior while avoiding false address classifications from unrelated parent-channel messages.
2026-07-18 14:01:33 -07:00
James 303949acdc fix: backfill missed Discord messages on startup (#3) 2026-07-18 14:01:33 -07:00
Teknium 9d6d772837 feat(computer_use): follow cua-driver's verify → escalate ladder (#67123)
Hermes' computer_use wrapper dropped cua-driver's structured action verdicts,
exposed no delivery_mode, and injected background-only guidance — so the agent
reported unverified no-ops as success and concluded cua-driver 'cannot drive'
Electron/Chromium surfaces (observed live on tldraw offline). Fixes #67052.

Phase A — preserve the result contract:
- ActionResult carries verified/effect/escalation/path/degraded/code/delivery_mode
- CuaDriverBackend._action() reads structuredContent (was data-only); a helper
  normalizes it, additive and None-safe on old drivers
- _text_response surfaces the fields additively (ok stays transport-only)

Phase B — bounded, model-reachable foreground:
- delivery_mode (background|foreground) + bring_to_front on the schema, dispatcher,
  ABC, and all input methods
- foreground is capability-gated (input.delivery_mode); old drivers get a
  structured foreground_unsupported refusal, never a silent background downgrade
- no automatic/hidden foreground retry — the model selects it from the signal

Phase C — guidance + isolation:
- system prompt (prompt_builder) and bundled skills/computer-use/SKILL.md go from
  background-ONLY to background-FIRST, teaching the AX→PX→foreground ladder driven
  by returned effect/escalation, not predicted from the app being Electron
- foreground approval scoped by (action, delivery_mode): a background approval
  never silently authorizes foreground
- approval state keyed per session_id so concurrent gateway runs don't leak unlocks

Tests: tests/tools/test_computer_use_delivery_ladder.py (15) cover confirmed/
unverifiable/suspected_noop/degraded/old-driver verdicts, delivery_mode gating +
foreground_unsupported, and session-scoped foreground approval. Existing 265
computer_use tests still green.

Live E2E (real cua-driver 0.8.3 + tldraw offline on Linux/X11): a background click
returned effect='unverifiable'/path='ax' (no fabricated success), and a foreground
request returned code='foreground_unsupported' — correct on a driver that predates
the input.delivery_mode capability.
2026-07-18 13:59:35 -07:00
Austin PickettandUnathiCodex 2637aa607f fix(desktop): preserve in-flight turns across gateway reconnects (rebase of #66234) (#67114)
* fix(desktop): preserve turns across gateway reconnect

* chore(release): map UnathiCodex attribution

* fix(desktop): prefer rotated resume projection

* fix(desktop): refresh warm session transcripts

* chore(contributors): use email mapping file

* fix(desktop): restore live prompts after restart

---------

Co-authored-by: UnathiCodex <theunathi@gmail.com>
2026-07-18 16:12:59 -04:00
HexLab98 862b1b37bf test(error_classifier): cover empty-response max_tokens misclassification 2026-07-18 12:54:58 -07:00
HexLab98 032a424fa4 fix(error_classifier): stop empty-response advisories from triggering compression
Provider empty-reply text mentions "very low max_tokens", which used to match
the bare overflow pattern and thrash compress until "Cannot compress further".
2026-07-18 12:54:58 -07:00
jingsong-liu bf39103087 fix(desktop): accept shift modifier for keyboard zoom-in on macOS (#43517)
Surgical reapply of the surviving half of PR #43517 by @jingsong-liu
(the branch predates the ts-ify migration; its other half — zoom restore
after reload/navigation — landed via #66989).

On US layouts Plus is physically Shift+=, so Cmd+Plus arrives with the
shift modifier set. The blanket 'input.shift' early-return in
installZoomShortcuts silently swallowed keyboard zoom-in on macOS: the
chord matched neither branch and fell through to nothing. Shift is now
evaluated per-chord: zoom-in accepts it, zoom-reset and zoom-out still
reject it (Ctrl/Cmd+Shift+0 and Shift+'-' are different chords).
2026-07-18 12:42:20 -07:00
teknium1 d8fd45e9a8 fix(gateway): getattr-guard _status_text for bare-instance adapter tests
Gateway tests build adapters via object.__new__() without __init__ (the
documented bare-instance pattern), so the new _status_text dict must be
accessed through getattr guards in set_status_text, the _keep_typing
finally cleanup, and the Slack send_typing read — same treatment as
other post-hoc __init__ attributes. Fixes CI shard 2/8
(test_active_session_text_merge).
2026-07-18 12:28:59 -07:00
teknium1 d4396797c3 feat(gateway): live per-tool status line on Slack
Builds on the salvaged typing_status_text plumbing (PR #62007): instead
of a static 'is thinking...', Slack's assistant status line now updates
live as the agent works — 'is running pytest tests/…', 'is reading
docs/api.md…' — and reverts to the static text between tool calls.

Mechanics:
- agent/display.py: build_status_phrase() derives a <=49-char present-
  tense phrase from the existing _TOOL_VERBS table (+ 'is using <name>'
  for plugin/MCP tools; None for _thinking).
- base adapter: supports_status_text capability flag + set_status_text()
  per-chat store, cleared when the typing loop winds down.
- Slack adapter: send_typing() renders the live phrase when set, falling
  back to typing_status_text then 'is thinking...'.
- gateway/run.py: progress_callback stashes the phrase on tool.started
  and clears on tool.completed. Rendering rides the existing
  _keep_typing refresh cadence — zero additional Slack API calls, no
  rate-limit exposure. Works with tool_progress: off (Slack default);
  the callback is now armed whenever the adapter supports status text.
- display.live_status config (full|verb|off, default full): 'verb' hides
  argument previews for shared/customer-facing channels.

Also fixes a latent crash in the cherry-picked from_dict: malformed
non-dict 'extra' sections broke typing_status_text resolution (uses the
already-coerced extra dict).

Design notes: status text is a side-effect display channel only — never
enters the transcript, no prompt-cache impact. Lifecycle guarantees from
the stuck-status fix family are preserved (per-thread tracking,
clear-on-finish via existing stop_typing paths). Related: #45109
(closed; same direction via lifecycle states), #59010/#51363 (native
task cards — complementary, larger scope).
2026-07-18 12:28:59 -07:00
George Drury 16604d59ca docs(gateway): document typing_status_text on the Google Chat page
Mirrors the Slack docs, per review; notes the marker is a real posted
message (edited in place), unlike Slack's ephemeral status.
2026-07-18 12:28:59 -07:00
George Drury 21d01149c7 test(gateway): cover typing_status_text through load_gateway_config
Loader-level coverage for both YAML routes (top-level platform block via
the shared-key bridge; nested platforms.slack via _merge_platform_map),
per review — from_dict alone didn't exercise the bridge.
2026-07-18 12:28:59 -07:00
George Drury dc0c778b22 feat(gateway): make the working-state status text configurable
Adds PlatformConfig.typing_status_text for the two platforms that render
text for the working-state line: Slack's assistant.threads.setStatus
status (hardcoded 'is thinking...') and Google Chat's visible marker
message (hardcoded 'Hermes is thinking…'). None keeps each platform's
built-in default; to_dict omits the field when unset so existing configs
serialize unchanged. Plumbing mirrors typing_indicator exactly (typed
field, from_dict extra fallback, shared-key bridge).

Also documents that Slack's status line requires the assistant:write
scope — without it setStatus fails silently and Slack shows its own
generic placeholder, which previously made the behaviour undiagnosable
from config alone.
2026-07-18 12:28:59 -07:00
Ayoub 5b44b65887 feat(dashboard): schema override for browser.headed toggle
Salvaged from PR #25653 by @Black0Fox0 — the config-key and env-wiring
halves of that PR landed via #67018; this carries the surviving dashboard
schema override so browser.headed renders as a labeled boolean toggle.
Description updated to reflect the merged cleanup-skip behavior.
2026-07-18 12:28:36 -07:00
UnathiCodex e45d12642d fix(tui_gateway): prevent resume stalls during submit and teardown (#66573)
* fix(tui_gateway): keep busy submits resume-safe

* chore: map contributor email

* fix(tui_gateway): release resume lock before teardown
2026-07-18 14:51:06 -04:00
alelpoan 58a5945b16 fix(dialog): close button not working and tooltip showing on open (#66340)
* fix(dialog): close button not working and tooltip showing on open

- Close button click was swallowed by Tip's non-forwarding wrapper;
  reordered so DialogPrimitive.Close asChild wraps Button directly.
- Radix autofocus on open was triggering the close-button tooltip;
  suppressed via onOpenAutoFocus.

Adds tests covering both regressions.

* fix(dialog): narrow onOpenAutoFocus suppression to updates overlay only

Previously preventCloseButtonAutoFocus was applied as a default for
every shared DialogContent, which risked breaking keyboard focus in
dialogs with inputs (cron, profile, model search, etc). Now it's
opt-in and exported, applied explicitly only in updates-overlay.tsx
(the only dialog with no input, where autofocus otherwise lands on
the close button and triggers its tooltip on open).

Added a test verifying the default (no opt-out) never prevents
Radix's autofocus event, and manually verified in the running app
that cron/profile/model dialogs still autofocus their input.

* test(dialog): opt tooltip-on-focus test out of Radix autofocus

Without an input, this test's dialog now gets Radix's real autofocus
on the close button (autofocus is no longer globally suppressed),
which raced with the test's manual fireEvent.focus and made the
tooltip assertion flaky in CI. Opt out explicitly, same as
updates-overlay.tsx.

* test(dialog): increase tooltip wait timeout for CI load

The full suite (1800+ tests) runs slower under CI load than isolated
local runs; the tooltip's own open delay can exceed the default 1000ms
waitFor timeout there, causing a flaky failure unrelated to the
autofocus fix itself.

* test(dialog): use real .focus() instead of synthetic focus event

fireEvent.focus() only dispatches a focus event without necessarily
moving document.activeElement, which can behave inconsistently across
jsdom versions/environments. Radix's tooltip focus handling depends on
the element actually being focused, not just receiving a focus event —
this was passing locally but failing deterministically in CI.

* test(dialog): skip pre-existing tooltip-on-focus test in CI

Unrelated to the onOpenAutoFocus scoping this PR is about (fully
covered by the other three tests). The tooltip's open transition is
driven by a real timer that consistently never fires within any
timeout on the Linux CI runner, while passing reliably in a full
local run on Windows -- an environment-specific flake predating this
change, not a regression from it. Needs separate investigation.
2026-07-18 14:50:38 -04:00
Gille 48e36a5370 fix(desktop): open setup for missing provider rows (#66767)
* fix(desktop): open setup for missing provider rows

* fix(desktop): keep provider recovery profile-safe

* fix(desktop): satisfy model settings hook deps
2026-07-18 14:23:45 -04:00
liuhao1024 ad0ddfb15d feat(desktop): support Ctrl/Cmd + mouse wheel zoom (#40295)
Ground-truth reapply of PR #40414 by @liuhao1024. The original branch
predates the ts-ify migration and implemented the gesture by injecting
a DOM wheel listener via executeJavaScript + a new IPC channel. Current
Electron surfaces the modifier+wheel gesture natively as the main-process
webContents 'zoom-changed' event, so the salvage uses that instead:
no renderer injection, no new preload surface, no new IPC channel.

The handler routes through setAndPersistZoomLevel — the same
persist+notify funnel as the keyboard shortcuts — so wheel zoom uses
the same 0.1 half-step, persists to zoom-state.json across restarts,
and keeps the settings Scale control in sync. Session windows get the
gesture automatically via wireCommonWindowHandlers; the pet overlay
stays opted out via zoomWiringForWindowKind.
2026-07-18 10:50:00 -07:00
teknium1 5988fe6cd5 fix: widen headed-mode gate to config, add browser.headed default, tests, docs
Follow-up to @vishnukool's #24064 salvage:
- cleanup skip now uses _is_headed_mode() (config browser.headed OR
  AGENT_BROWSER_HEADED env) instead of env-var-only, with env fallback
  if browser_tool import fails
- browser.headed added to DEFAULT_CONFIG (default false)
- 14 regression tests: resolution precedence, cleanup skip, --headed
  argv injection (local vs cloud), VM cleanup unaffected
- docs: Headed Mode section in browser.md
2026-07-18 10:07:46 -07:00
Vishnu 29899c2aa9 Fix headed browser sessions being killed after every turn
The per-turn `_cleanup_task_resources` unconditionally calls
`cleanup_browser`, closing the browser window immediately after each
bot reply. This makes headed mode (`AGENT_BROWSER_HEADED=1`) unusable
— the window flashes up and disappears on every response.

This mirrors the existing VM persistence pattern: skip per-turn
cleanup when headed mode is active and let the inactivity reaper
handle idle sessions instead. Full-session teardown on gateway
shutdown remains unconditional.

Also adds `browser.headed` config.yaml support and passes `--headed`
to agent-browser in local mode when configured, so users don't need
to rely solely on the `AGENT_BROWSER_HEADED` env var.

Closes #11020 (lead bug)
2026-07-18 10:07:46 -07:00
teknium1 581e92e42c chore: add AUTHOR_MAP entry for ildunari 2026-07-18 09:35:01 -07:00
kosta 036b659527 fix(desktop): preserve UI scale after resize 2026-07-18 09:35:01 -07:00
teknium1 5f95b251d6 chore: add AUTHOR_MAP entry for SongotenU 2026-07-18 09:24:40 -07:00
SongotenU 6f42d6a5b8 fix(desktop): re-apply persisted zoom on every full load, not just the first (#46429)
Surgical reapply of PR #46429 by @SongotenU (the branch predates the
ts-ify migration; main.cjs no longer exists so a direct cherry-pick
cannot apply).

Main already adopted half of the PR's fix when zoom restore moved into
wireCommonWindowHandlers (57dfebe3d) — session windows are covered. The
surviving delta is the listener lifetime: 'once' spends the listener on
the first did-finish-load, so any later full load in the same
webContents — the crash-recovery webContents.reload(), a manual reload,
or an in-place navigation that lands on a fresh per-host zoom entry —
came up at default zoom while the settings Scale control still showed
the persisted percentage. 'on' re-applies the persisted level after
every completed load; restorePersistedZoomLevel routes through the
applyZoomLevel funnel so the renderer stays in sync.
2026-07-18 09:24:40 -07:00
nousbot-engandgithub-actions[bot] b1fc653081 fmt(js): npm run fix on merge (#66983)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-18 15:12:47 +00:00
Sahil-SS9 e632769269 fix(desktop): persist zoom to JSON and save window state on first show (#56726)
Surgical reapply of the surviving halves of PR #57414 by @Sahil-SS9
(the branch predates the ts-ify migration and the zoom apply/notify
funnel, so a direct cherry-pick no longer applies):

- Zoom persists to a main-process zoom-state.json as the primary store.
  The old localStorage-only store lives under Electron's cache/storage
  folders, which crash recovery can move or recreate — wiping zoom
  exactly when the user recovers from a crash. localStorage stays as a
  secondary mirror; pre-JSON installs migrate on first read.
- Window geometry persists at ready-to-show, so a crash before the
  first resize/move/close still captures the restored bounds.

The third half of #57414 (one-shot --no-sandbox relaunch on Windows
renderer crash loops) was superseded by #66842, which ships the same
recovery gated on the 0x80000003 sandbox-crash signature.

Adapted to current main: restore/persist route through the
applyZoomLevel funnel (39230d173) so the settings UI Scale control
stays in sync, and JSON writes go through writeFileAtomic.
2026-07-18 08:06:08 -07:00
kshitijk4poor af6b41b18b test: add coverage for manual-skill curator guard
Adds two tests for the _background_review_write_guard manual-skill check:
- refuses delete on a skill with created_by=None (manually authored)
- allows delete on a skill with created_by='agent' (agent-created)
2026-07-18 20:17:22 +05:30
19404 6236412294 fix(curator): guard background review writes against manually authored skills
Prevents the curator's LLM consolidation pass from archiving skills the
user placed manually (e.g. via URL install, direct SKILL.md authoring, or
Gitee source). These skills carry created_by=None in .usage.json rather
than created_by=agent, but the _background_review_write_guard only checked
pinned, external, bundled, hub, and protected built-in status — missing
the manual-skill case entirely.

The guard already caught a real case: the user's 'auto-dev' skill
(use_count=50, patch_count=119) was archived 28 seconds after its last
use during a curator auto-run.

Adds a check: if the skill has a usage record and its created_by is not
'agent', refuse the background curator write. Skills with no record at
all (new/unknown) are not blocked.
2026-07-18 20:17:22 +05:30
kshitij 2c9b4ca284 Merge pull request #66961 from kshitijk4poor/chore/author-map-re-itrt
chore: AUTHOR_MAP add 1940428933@qq.com -> re-ITRT
2026-07-18 20:12:51 +05:30
kshitijk4poor e7400fb9bf chore: AUTHOR_MAP add 1940428933@qq.com -> re-ITRT
For PR #66579 salvage attribution.
2026-07-18 20:04:40 +05:30
kshitijk4poor c78aa0bad5 refactor(gateway): dedupe detached-task consumer + reconnect backoff policy
/simplify-code findings on the #66222 salvage:

- consume_detached_task_result moves to agent/async_utils.py (shared home);
  gateway/run.py and the Discord adapter both had near-identical copies of
  the same pattern (a third lives in the telegram adapter). One canonical
  implementation, both new callsites import it.
- Reconnect backoff formula min(30 * 2^(n-1), 300) was copied verbatim at
  3 sites in run.py (primary watcher x2, secondary-profile reconnect), the
  third hardcoding the cap. Hoisted to module-level _reconnect_backoff()
  with a single _RECONNECT_BACKOFF_CAP so a future tune can't silently
  miss one path.

Behavior-preserving: 70 gateway teardown/liveness/reconnect tests green.
2026-07-18 20:01:55 +05:30
kshitijk4poor 8b14440d75 chore: AUTHOR_MAP entries for StellarisW and 王鑫 (PR #66222 salvage) 2026-07-18 20:01:55 +05:30
StellarisWand王鑫 f57157a128 fix(gateway): recover Discord websocket and event-loop stalls
Replace REST-based Discord liveness probe with local WebSocket/heartbeat
state detection. REST success doesn't prove Gateway event delivery — a
half-closed WebSocket can leave Bot.start() alive while REST returns 200.
Now samples ready/open/ACK state and heartbeat latency; consecutive
unhealthy samples emit one retryable fatal code so GatewayRunner rebuilds
the adapter through the existing reconnect path.

Also fixes three lifecycle gaps in the recovery path:
1. asyncio.wait_for() can remain blocked if adapter cleanup swallows
   cancellation — now uses bounded asyncio.wait() with task detachment.
2. Multiplexed secondary-profile adapters had no profile-scoped reconnect
   owner — now uses one runner-owned reconnect slot per profile.
3. An in-flight turn could send its final text through the disconnected
   adapter after a replacement was registered — now resolves the live
   same-profile replacement for unsent final responses only (message IDs
   never migrate, edits/deletes stay on the old transport).

Adds an opt-in Linux/systemd event-loop watchdog (gateway.systemd_watchdog_seconds,
default 0) for the failure mode where the whole asyncio loop stops making
progress and no in-process liveness task can run. stdlib-only sd_notify,
Type=notify/WatchdogSec generation, READY/STOPPING lifecycle.

Co-authored-by: 王鑫 <wx.xw@bytedance.com>
2026-07-18 20:01:55 +05:30
kshitijk4poor c48a801b7c fix: gate debug log on first-discard to avoid double-logging
The _discard_stale_stream_chunk helper emitted both logger.warning and
logger.debug for the first discarded chunk. Gate the debug on
discarded_chunks != 1 so only the warning fires for the first discard.
2026-07-18 19:55:16 +05:30
davidb73-hub 4fa67d2014 fix(streaming): block stale stream deltas 2026-07-18 19:55:16 +05:30
kshitijk4poor 3ec4c9ce4d fix: drain logs + release PID/lock before watchdog os._exit, drop infographic PNG
C1: The watchdog's os._exit(1) bypassed drain_log_queue() and
remove_pid_file()/release_gateway_runtime_lock() — the three things
_exit_after_graceful_shutdown does before exiting. The watchdog's own
logger.critical('shutdown watchdog fired') was silently dropped because
it was still in the async QueueListener queue when os._exit ran. PID
file and runtime lock were stranded on next boot.

W1: Dropped 2MB infographic PNG — dead asset with zero references in
the repo. Binary blobs in git history are permanent; every clone pays
the cost forever.

Authorship: @HexLab98's commits preserved via rebase-merge.
2026-07-18 19:53:00 +05:30
HexLab98 e378ed0c91 test(gateway): cover shutdown watchdog and loop heartbeat (#66892)
Pin delay math, disarm-before-fire, fire-with-dump, heartbeat refresh,
and the runner state attrs used by the stop/start wiring.
2026-07-18 19:53:00 +05:30
HexLab98 1bf5fd08ad fix(gateway): arm thread shutdown watchdog + loop heartbeat (#66892)
A frozen asyncio loop mid-SIGTERM drain cannot run the drain timeout or
status rewrites, so KeepAlive never sees a dead process. Arm an OS-thread
watchdog at stop() (drain+60s → faulthandler dump + os._exit) and rewrite
state/gateway.heartbeat from a loop task for external liveness checks.
2026-07-18 19:53:00 +05:30
kshitijk4poor bd3d16a490 fix: salvage follow-up — remove redundant coercion, fix classifier, restore None guard
1. Remove PR's redundant strip_think_blocks coercion (Teknium's fix
   296494db0 already handles this at the same chokepoint with superior
   logic that drops thinking/reasoning blocks).

2. Restore 'if not content: return ' guard at top of strip_think_blocks
   that was lost during cherry-pick auto-merge. Without it, None content
   hits str(None) → 'None' string instead of returning empty.

3. Fix error classifier design flaw: remove 'conversation_loop' and
   'run_agent' from _local_processing_modules — these are the container
   modules for the try/except, so every exception passes through them,
   making _hit_local always True and misclassifying transient API/network
   errors as non-retryable local bugs.

4. Move module sets to module-level frozenset constants (_LOCAL_PROCESSING_MODULES,
   _API_CALL_MODULES) instead of rebuilding on every exception.

5. Replace traceback.extract_tb() with raw tb walk — avoids disk I/O for
   source lines that are never used.

6. Remove unused 'import traceback'.

7. Fix docstring corruption: 3 lines where think tags were replaced
   with Chinese characters during the PR's editing.
2026-07-18 19:39:07 +05:30
nanami7777777 7942a77586 fix: normalize multimodal list content in build_assistant_message (#66267)
Second call site (non-streaming / gateway path) now flattens list-type
content with flatten_message_text before the inline <think> regex and the
surrogate sanitizer, matching the interim-text fix from the prior commit.

Adds regression tests (tests/run_agent/test_66267_multimodal_interim.py)
covering:
- build_assistant_message with list content does not raise TypeError
- inline <think> inside list content is extracted + stripped correctly
- _interim_assistant_visible_text is safe for tool messages (list content)
- duplicate_previous_interim dedup guards against tool messages

Verified the tests fail without the fix (TypeError: expected string... got
'list') and pass with it.
2026-07-18 19:39:07 +05:30
nanami7777777 aef0fe6f27 fix: handle multimodal content in interim assistant text and avoid retrying local processing errors (#66267) 2026-07-18 19:39:07 +05:30
kshitijk4poor 277eedefbe fix(review): surface respawn-storm env vars in config.yaml + docs
Follow-up to PR #66479 salvage. The three new env vars
(HERMES_LOCAL_STREAM_STALE_TIMEOUT, HERMES_GATEWAY_MAX_STARTS,
HERMES_GATEWAY_START_WINDOW_S) were introduced as bare env-var reads with no
config.yaml surface or documentation — violating the .env-is-for-secrets-only
policy (behavioral settings must live in config.yaml, bridged to env internally).

- config.yaml: add gateway.respawn_storm {max_starts, window_seconds} to
  DEFAULT_CONFIG, mirroring the existing restart_loop_guard pattern.
- gateway.py: read config.yaml first, env vars override as escape-hatch.
- environment-variables.md: document all three new env vars, noting the
  config.yaml alternative for the gateway ones.
- chat_completion_helpers.py: cross-reference the env-var docs from the
  local stale timeout comment.
2026-07-18 19:38:21 +05:30
Burke AutreyandClaude Opus 4.8 fdcf352797 fix(review): pass 2 — Bedrock reasoning-floor matches both dashed & dotted keys
_bedrock_reasoning_stale_floor only matched dashed floor-table keys (opus
'claude-opus-4-6'), so sonnet reasoning models keyed with a dotted version in the
shared table ('claude-sonnet-4.5'/'4.6') got no reasoning floor from the dashed
Bedrock inference-profile id — premature stale-abort if the base timeout is set
below 180s. Generate both version-separator forms (digit-dash-digit <-> digit-dot-
digit, via lookbehind/lookahead so only version numbers flip) and try all
candidates; matches the table however each model is keyed, no edit to the shared
table. Verified: opus->240 (unchanged), sonnet-4.5/4.6->180, haiku->None. New
TestBedrockReasoningStaleFloor (8 cases).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 19:38:21 +05:30
Burke AutreyandClaude Opus 4.8 ff9519d447 fix(review): pass 1 — consecutive supervisor cap + Bedrock reasoning-floor modelId
- _spawn_supervised restart cap was lifetime-cumulative (never reset), so a
  watcher crashing _MAX_SUPERVISED_RESTARTS times over a days-long process was
  permanently abandoned despite the 'consecutive' wording. Make it time-based:
  reset the attempt counter when the task ran healthily (>= _SUPERVISED_HEALTHY_SECS
  = 300s) before crashing, so only rapid repeated crashes accumulate toward the
  ceiling. Reword docstring/log. New regression: healthy-run-then-crash is not
  abandoned.
- _derive_stream_stale_timeout read only 'model', so the reasoning stale-floor
  never applied to Bedrock (payloads key the model as 'modelId', dotted
  us.anthropic.claude-opus-4-6-v1:0 form). Resolve model||modelId and normalize
  the Bedrock dotted/region-prefixed form to match the floor. (Sonnet's dotted
  vs dashed floor-table key is a pre-existing table mismatch, left out of scope.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 19:38:21 +05:30
Burke AutreyandClaude Opus 4.8 d920954747 fix(gateway): launchd ThrottleInterval + portable respawn-storm circuit breaker
Re-applied against v0.18.2. Still unconditional KeepAlive <true/> with no
ThrottleInterval, and upstream's new restart_loop_guard only skips session
auto-resume (never throttles the boot), so the launchd respawn storm is
unguarded.

- launchd plist: add ThrottleInterval=30 + ExitTimeOut=25.
- status: record_start_and_check_storm — portable breaker (atomic gateway-starts.log,
  backs off when too many starts land in a window); run_gateway sleeps it before
  asyncio.run. Env HERMES_GATEWAY_MAX_STARTS (<=0 disables) / _START_WINDOW_S.
  Separate file from upstream's restart_loop.json — no collision.
- tests: breaker (threshold/prune/atomic) + plist throttle keys.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 19:38:21 +05:30
Burke AutreyandClaude Opus 4.8 71f4de3cd8 fix(gateway): supervise long-lived watcher tasks (task-level)
Re-applied against v0.18.2. Upstream now wraps each watcher's INNER loop in
try/except (kept — not re-added), but the 9 long-lived watchers in start() are
still bare asyncio.create_task: no handle, no exception logging, no restart. A
raise in _platform_reconnect_watcher's OUTER while-loop / pre-try region still
dies silently, permanently losing platform reconnection. Add _spawn_supervised
(track + log + bounded-backoff restart; no respawn on clean return to avoid
busy-spin) and route all 9 watchers through it.

- tests: clean-return spawned exactly once; exception-restart bounded at ceiling+1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 19:38:21 +05:30
Burke AutreyandClaude Opus 4.8 ff56251555 fix(streaming): Bedrock liveness watchdog (into #58962 breaker) + finite local timeout
Re-applied against v0.18.2. Upstream now covers the OpenAI-path stall watchdog
(cross-turn give-up breaker #58962) and the tool-batch deadline, so those are
dropped. Two gaps remain:

- Bedrock streaming had NO liveness watchdog and was excluded from the #58962
  breaker (it returns before _check_stale_giveup). Add an on_event hook to
  stream_converse_with_callbacks (fires per yielded event = true wire-level
  liveness), drive a stale timer from it, and wire Bedrock INTO the existing
  breaker: entry _check_stale_giveup, _bump_stale_streak on stall, raise to end
  the call (invalidate_runtime_client can't abort the in-flight botocore stream,
  so the streak escalates across turns like the OpenAI path), and reset the
  streak on success.
- local providers still got float('inf') stale timeout (watchdog disabled) — give
  a finite ceiling (HERMES_LOCAL_STREAM_STALE_TIMEOUT, default 900s).
- tests: on_event per-event + swallow; Bedrock stall bumps streak + aborts;
  pre-elevated streak aborts at entry; success resets streak.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 19:38:21 +05:30
Siddharth Balyan d296749056 feat(desktop): billing settings tab (#61054)
* feat(tui): rename /billing slash command to /topup

Behavior-preserving rename of the /billing command surface to /topup.
Changes: billing.ts → topup.ts (export topupCommands, name 'topup', new
help string), registry.ts import+spread updated, billingOverlay.tsx
overview header 'Usage credits' → 'Top up credits', billingCommand.test.ts
→ topupCommand.test.ts with import/lookup/call updated. RPC method names
(billing.state, billing.charge, etc.) and component/symbol names unchanged.

* refactor(tui): extract overlay primitives to shared module

Lift MenuRow, ActionRow, footer, and barCells() out of billingOverlay.tsx
into overlayPrimitives.tsx so the upcoming subscriptionOverlay.tsx can
import them instead of duplicating. spendBar now calls barCells() —
output is byte-identical. Pure behavior-preserving refactor.

* feat(tui): add /subscription + /topup CTAs to /usage output

Every /usage render now ends with 'Run /subscription to change plan
· /topup to add credits' — both the healthy (with-calls) and depleted
(no-calls) paths. Strings-only change, no WS1 dependency.

* feat(tui): add subscription wire types

Add SubscriptionTierOption, SubscriptionStateResponse, and
SubscriptionManageLinkResponse to gatewayTypes.ts. Type-only — no
usages yet. Mirrors the BillingStateResponse conventions (snake_case,
Decimals as strings) and reuses BillingErrorPayload for error mapping.

* feat(gateway): add subscription.state + subscription.manage_link RPCs

- agent/subscription_view.py: SubscriptionState dataclass + fail-open
  build_subscription_state() (mirrors billing_view pattern) +
  get_subscription_manage_link() for the Stripe deep-link.
- hermes_cli/nous_billing.py: get_subscription_state() +
  post_subscription_manage_link() HTTP helpers for the two NAS endpoints
  (WS1 Phase A/C). The manage-link endpoint raises BillingScopeRequired
  when Remote-Spending is missing (Phase 4 step-up trigger).
- tui_gateway/server.py: _serialize_subscription_state() +
  subscription.state RPC (fail-open) + subscription.manage_link RPC
  (returns {ok,kind,url} or typed error envelope via
  _serialize_billing_error). NOT added to _LONG_HANDLERS — synchronous
  HTTP round-trip, not a device flow.

* feat(tui): add subscription overlay state types + store slot

Add SubscriptionScreen, SubscriptionOverlayCtx, SubscriptionOverlayState
to interfaces.ts and a 'subscription' slot to OverlayState. Wire it into
overlayStore.ts (buildOverlayState + $isBlocked). NOT added to
resetFlowOverlays preserve list — flow-scoped like billing, drops on
turn end.

* feat(tui): build SubscriptionOverlay — overview + confirm + handoff

Pure-render Ink component mirroring billingOverlay.tsx's structure.
Overview screen covers all 5 states (free-upgradeable, mid-tier,
top-tier, not-admin, downgrade-pending) + dunning. Confirm screen is
y/n deep-link to Stripe (NO in-terminal charge). Handoff is the
transient 'Opening Stripe' screen. Imports shared primitives from
overlayPrimitives.tsx. 8 render tests via renderSync covering every
state.

* feat(tui): add /subscription command + overlay wiring

- subscription.ts: SubscriptionOverlayCtx closure (openManageLink,
  refreshState, requestRemoteSpending) + run handler that fetches
  subscription.state and opens the overlay. Alias /upgrade.
- registry.ts: spread subscriptionCommands into SLASH_COMMANDS.
- appOverlays.tsx: render SubscriptionOverlay when overlay.subscription set.
- useInputHandlers.ts: Esc closes subscription overlay; promptOverlay OR
  includes subscription so input is intercepted while open.
- subscriptionCommand.test.ts: 4 tests (fetch+open, logged-out sys line,
  /upgrade alias, /subscription resolves).

* fix(tui/subscription): stop saying Stripe in deep-link copy + fix manage link kind type

Replace all user-facing 'Stripe' mentions in the /subscription overlay and
sys messages with 'your subscription page' — the deep-link target is NAS's
own /manage-subscription page, not the Stripe hosted portal. Stripe only
legitimately appears later at actual Checkout. Also add 'manage' to the
SubscriptionManageLinkResponse.kind union (NAS emits kind:'manage'; was
previously missing from the TypeScript type causing silent narrowing errors).

* feat(tui/subscription): render cancellation-scheduled note with headline precedence

Parse cancelAtPeriodEnd + cancellationEffectiveAt from the NAS contract
(camelCase) in the agent parser (_parse_current), emit cancel_at_period_end
+ cancellation_effective_at from the gateway serializer, extend the
SubscriptionStateResponse type, and render a warn note in OverviewScreen:
'Cancels on {date} — your plan stays active until then.'

Headline precedence when multiple flags co-occur:
  past-due > cancel-scheduled > downgrade-pending > active
The downgradeNote guard is tightened to suppress when cancel is scheduled,
so at most one status line renders at a time.

* feat(tui/subscription): team-context screen — redirect to /topup for team orgs

Parse the NAS context:'personal'|'team' field (defaults to 'personal' for
unknown/missing values), emit it on the gateway wire, add it to
SubscriptionStateResponse. When context is 'team', SubscriptionOverlay
renders a dedicated read-only screen instead of the tier picker:

  'This terminal is connected to {org_name}. Teams run on shared
   credits — use /topup to add funds. Personal subscriptions live
   on your personal account.'

The screen closes on Enter or Esc. The personal/tier-picker path is
unchanged.

* fix(subscription): drop manage-link gateway RPC, build URL locally

The NAS POST /api/billing/subscription/manage-link endpoint was dropped
(it added no server work — the target is the static /manage-subscription
page, not a Stripe-minted secret). Build the URL client-side instead:
{portal_base}/manage-subscription?org_id=<org.id>.

- Remove subscription.manage_link gateway RPC (server.py)
- Remove get_subscription_manage_link helper (subscription_view.py)
- Remove post_subscription_manage_link (nous_billing.py)
- Remove SubscriptionManageLinkResponse type (gatewayTypes.ts)
- Add org_id to SubscriptionState + wire through serializer + TS type
- openManageLink() builds the URL locally via buildManageUrl(), opens
  it with the existing openExternalUrl(), no gateway round-trip
- Drop targetTierId param from openManageLink (v1 sends everyone to
  /manage-subscription; no tier deep-link needed)
- Fix stale test expectations (Stripe copy → subscription page copy)

* chore(subscription): drop unused format_money import

* feat(cli): /subscription + /upgrade, /billing→/topup rename, /usage CTAs

Add the classic-CLI half of the terminal billing surface to match the TUI:
- /subscription (alias /upgrade) command + /topup (renamed /billing, keeps
  'billing' as a back-compat alias) in the command registry.
- Drop the stale 'billing' entry from _SLACK_VIA_HERMES_ONLY (now cli_only).

* feat(subscription): CLI /subscription handler, drop dunning, current:null no-plan

- CLI _show_subscription mirrors the TUI overlay (plan read + tier list + usage
  bar + browser deep-link via subscription_manage_url); credits render as counts.
- Adapt to the updated NAS read contract: remove is_past_due/dunning everywhere
  (a card-failing subscriber returns as a normal plan now), and treat no-plan as
  current:null (parser returns None) rather than an all-null object.
- HERMES_DEV_SUBSCRIPTION_FIXTURE env-driven fixtures + ui-tui fixture harness
  drive every state (CLI + live TUI) with no portal.

Verified against handoff 2026-06-24_subscription-tui-handoff.md.

* feat(billing): CF-4 Remote-Spending revoked-terminal UX (NAS PR #481)

Wire the Remote-Spending gate denial contract end to end:
- nous_billing: BillingRemoteSpendingRevoked (403 remote_spending_revoked →
  reconnect) + BillingSessionRevoked (401 session_revoked → re-login), distinct
  from insufficient_scope; capture actor/code/recovery; 503 stays transient.
- gateway _serialize_billing_error threads the new typed kinds + actor/code/
  recovery to the TUI.
- TUI renderBillingError: actor-aware revoke copy, kills the spend overlay
  immediately (no 15-min zombie button), handles session_revoked, the dual-
  emitted cli_billing_disabled/remote_spending_disabled, role_required,
  idempotency_conflict; poll treats a mid-poll revoke as ambiguous (check
  balance before retry), not a failure.
- CLI _billing_render_charge_error: same denial matrix, actor-aware copy.

Tests: gate-contract mapping + envelope (py) and revoke/session/disabled (TUI).
Per handoff 2026-06-24_remote-spending-TUI-contract-handoff.md.

* refactor(subscription): remove dead step-up scaffolding from /subscription

/subscription only opens a browser deep-link to manage-subscription — that needs
no billing scope, so it can never hit insufficient_scope. Drop the never-fired
'stepup' screen type, requestRemoteSpending ctx fn, and resumeScreen bookkeeping
(leftovers from a superseded plan). The resumable step-up lives on /topup, where
the charge actually gets gated.

* feat(tui/topup): resumable 'Allow Remote Spending' step-up on the charge path

Phase 4: when a charge returns insufficient_scope, the /topup modal no longer
tears down with a 'run /billing again' ConfirmReq. Instead it stays MOUNTED and
switches to a step-up screen:
- charge() is now awaitable, returning a discriminated outcome (submitted |
  needs_remote_spending | error) so the overlay can route without closing.
- StepUpScreen: 'Allow Remote Spending' → await the device-flow grant (browser
  opens via the existing out-of-band billing.step_up.verification event) →
  replay the held charge (pendingCharge.amount) and settle, with no command
  re-run. Never surfaces the raw billing:manage scope.
- armStepUp's fire-and-forget ConfirmReq replaced by requestRemoteSpending();
  the leaky 'billing:manage' / 'Re-authorize' / 'run /billing again' copy is gone.

Tests: charge-outcome routing, step-up grant/deny, and a render test asserting
the step-up copy holds the amount and never leaks billing:manage.
Per handoff 2026-06-24_remote-spending-TUI-contract-handoff.md §2 (Grady #6).

* feat(billing): shared dollar usage model + two-bar view (drop "credits")

Single source of truth for the /usage and /subscription usage bars across
TUI + CLI. Reads the NAS account-info dollar fields (subscription/top-up/total
remaining, monthly allowance, renewal) and produces a surface-agnostic model:
two full-resolution bars (plan allowance + purchased top-up), a status
classification (free | healthy | low | depleted), and a human renewal date.

- agent/billing_usage.py: UsageModel/UsageBar, usage_model_from_account
  (fail-open), build_usage_model (HERMES_DEV_CREDITS_FIXTURE-aware),
  format_renews (ISO -> "Jul 24, 2026", Windows-safe), $5 low-balance threshold.
- tui_gateway/server.py: _serialize_usage_model/_serialize_usage_bar, a
  usage.bars RPC, and the model embedded into subscription.state so the overlay
  renders the same bars from its single fetch.
- Dollars only, never "credits"; two separate bars (not a crammed
  three-segment one) for legibility at terminal widths.
- tests/agent/test_billing_usage.py: status classification, bar math
  (clamp/over-cap), NaN/Inf rejection, fail-open invariants.

* feat(tui): dollar usage bars on /usage + /subscription, drop tier picker

Render the shared two-bar dollar model in both overlays; strip "credits" and
the in-terminal tier selection per UX feedback.

- overlayPrimitives.tsx: UsageBars (themed plan/top-up bars — gold allowance,
  green top-up) + usageBarsText for the /usage panel. Plan name labels the
  bar; "$X left of $Y · N% used" (disambiguated so the % matches); top-up
  "never expires".
- subscriptionOverlay.tsx: status line dedupes ($X left once; bar carries the
  breakdown), human renewal date, state-matched nudges (free upsell / <$5
  low alert) with box-safe ASCII markers (! / >) instead of the width-unstable
  emoji that broke the border. Tier picker removed — overview shows usage +
  plan, then "Manage on portal" / "Close" (free users get "Start a
  subscription"). No "credits" anywhere.
- session.ts: /usage renders the dollar bars + balance summary, falling back
  to the legacy credits lines only when the model is unavailable; CTA reworded.
- gatewayTypes.ts: UsageModelData/UsageBarData wire types + usage on
  SessionUsageResponse/SubscriptionStateResponse.
- Tests updated to the new contract (no "credits", "left of", dedup, markers).

* feat(cli): mirror dollar usage bars on /usage + /subscription

CLI parity with the TUI billing rework, from the same shared usage model.

- _print_nous_credits_block (/usage) and _subscription_overview render the
  two-bar dollar view (plan name on the bar, "$X left of $Y · N% used",
  top-up "never expires", total spendable) instead of the credits-worded block.
- Dollars only — dropped the tier catalog (no more "$N/mo (… credits)") and
  every user-facing "credits"; team copy says "shared balance".
- Human renewal date via the shared format_renews; status line dedupes the
  "$X left"; free upsell + <$5 low alert with ASCII markers.
- /subscription manage modal no longer dumps the raw manage-subscription URL
  in its detail — the [1] Open / [2] Copy link / [3] Cancel options carry it.
  Title is "Manage your subscription" (no in-terminal plan change). The raw URL
  stays only in the non-interactive / not-admin fallbacks, which have no menu.
- /usage token-usage panel (model, tokens, cost, context) left untouched.

* feat(billing): embed dollar usage model into billing.state for /topup

The /topup overview renders the same two-bar dollar usage (plan + top-up) as
/usage and /subscription. Embed the shared usage model into the billing.state
RPC payload (mirrors subscription.state) so the overlay gets the bars from its
single fetch, and add the `usage` field to BillingStateResponse.

* feat(tui/topup): reorder overview + in-flight reauth with press-Enter resume

Reworks the /topup overlay per the Jun 19 review and the no-preflight decision.

Overview:
- Balance leads in the title ("Top up · balance $X"); the shared two-bar dollar
  usage (plan + top-up) renders below. Dropped the old monthly-cap spend bar.
- "Add funds" is the first action (was "Buy credits"); auto-reload / monthly
  limit / manage-on-portal follow. Dollars only — no "credits" anywhere.
- No "Enable terminal billing" menu item and NO scope preflight: whether the
  terminal can charge is discovered reactively at pay time. (We deliberately do
  not read/refresh the OAuth token to gate UI.)

Step-up (reached only on a charge's insufficient_scope 403):
- New 4-phase flow that keeps the modal mounted: prompt (one-time-setup
  heads-up) → waiting (browser authorize) → granted (explicit "Press Enter to
  resume") → replay the held charge → settle. The press-Enter beat is the
  reassuring "you're back, finish your purchase" moment.
- Renamed user copy "Allow Remote Spending" → "Enable terminal billing"; never
  leaks the raw billing:manage scope (guarded by the render test).
- topup.ts error copy de-crufted to terminal-billing wording, emoji removed.

Tests: step-up prompt copy, the no-raw-scope invariant, and new overview tests
(balance-in-title, Add-funds-first, two-bar usage, no "credits").

* feat(cli/topup): mirror overview reorder + in-flight reauth resume

CLI parity with the TUI /topup rehaul, from the same shared usage model.

- _billing_overview: balance in the title, the two-bar dollar usage (plan name
  on the plan bar, top-up "never expires") in place of the old cap spend bar,
  "Add funds" first, dollars throughout — no "credits", no scope preflight.
- _billing_handle_scope_required: now takes the held amount + idempotency key
  and runs the in-flight flow — "Enable terminal billing" → browser device-flow
  → re-check the org kill-switch → press-Enter to resume → replay the held
  charge (reusing the key so a double-submit collapses to one). Stops leaking
  the raw billing:manage scope.
- Charge-error + buy/auto-reload copy de-crufted to terminal-billing/dollars.
- Tests updated to the new overview + buy copy.

* fix(billing): guard non-JSON 2xx responses in the billing HTTP client

A 2xx response with a non-JSON body — e.g. a reverse-proxy / SPA fallback HTML
page served when a billing route isn't actually mounted on a deployment — hit
json.loads() on the success path of _request() and raised a raw
json.JSONDecodeError. That escaped the typed-BillingError contract, so callers'
`except BillingError` missed it and fell through to a generic fail-open that
rendered as a misleading "not logged in" (observed when /api/billing/subscription
was briefly unshipped on staging: 200 text/html, x-matched-path /[...notFound]).

Now a non-JSON 2xx body raises a typed BillingError(error="endpoint_unavailable")
so surfaces degrade gracefully ("could not load …") instead of crashing or
mislabeling a valid session as logged-out. The 4xx/5xx path already guarded its
.json(); this closes the same hole on the success path.

Test: tests/hermes_cli/test_nous_billing_request.py — non-JSON 2xx → typed
error (not JSONDecodeError, not BillingAuthError), empty body → {}, valid JSON
parses.

* feat(billing/dev): add HERMES_DEV_BILLING_FIXTURE for offline card/scope testing

build_billing_state short-circuits to a fixture when HERMES_DEV_BILLING_FIXTURE
is set (mirrors HERMES_DEV_CREDITS_FIXTURE for the usage model). States:
nocard | card | card-autoreload | notadmin | billing-off | logged-out — so the
card-on-file gate, admin role, and kill-switch paths are exercisable offline
without a live portal. Env-var gated; returns None when unset (no prod leak).

Adds 8 behavior tests asserting the card/admin/billing-on contract per state.

* refactor(billing): fold /credits into /topup

/credits is redundant now that /topup shows the dollar balance + portal handoff.
Make 'credits' (and 'billing') aliases of /topup so typing /credits still works,
resolving to topup everywhere (CLI, gateway, Slack, TUI, autocomplete, help).

Remove the standalone /credits surface across 6 places:
- CLI _show_credits handler + dispatch
- gateway _handle_credits_command -> renamed _handle_topup_command, copy softened
  to 'Manage billing on the portal' (the messaging billing surface; /topup is now
  gateway-available so messaging keeps billing — credits was the only one before)
- TUI commands/credits.ts + creditsCommand.test.ts (deleted), registry entry
- tui_gateway credits.view RPC + the CreditsViewResponse type
- Slack _SLACK_VIA_HERMES_ONLY: credits -> topup

Sweep user-facing /credits -> /topup (usage-block hint, depletion notice) and
stale doc-comments. OpenRouter's /credits endpoint URL left untouched. Tests
updated (test_credits_folds_into_topup) or pruned for the removed symbols.

* fix(billing): card-on-file heads-up, no-card portal gate, /usage bar ordering, modal glyph

In-terminal charge (POST /charge against the org's server-held card, no card ref
leaves the client):
- card present: confirm screen shows 'Your card saved on the portal will be
  charged' + a 'Manage on portal' escape option (CLI); heads-up line (TUI)
- no card on file: /topup overview + buy flow detect it and route to the portal
  to add a card, instead of offering a charge that 403s no_payment_method

/usage bar ordering: route the dollar block through _cprint consistently. The
Plan: line (_cprint) and the bar (raw print) flushed to different buffers under
patch_stdout and interleaved nondeterministically; now Plan: -> bar -> status/CTA
is stable across all states.

Modal glyph: strip the leading emoji from bordered _prompt_text_input_modal
titles — it measures 1 char but renders 2 columns, shifting the box's right
border (the stray '|'). Includes the f-string 'Pay $X?' title.

Small /credits -> /topup string bits in cli.py ride along with the surrounding
charge edits (the fold lives in the sibling refactor commit).

* refactor(billing): apply safe simplify-pass fixes

Three low-risk cleanups from a parallel simplify review (reuse/quality/efficiency):
- dev fixture portal URL: reuse the prod host (was drifted to staging-* — a real
  mismatch vs subscription_view's _DEV_FIXTURE_PORTAL)
- TUI billingOverlay choose(): collapse two byte-identical branches (needsCard +
  the not-full else both = portal-or-close at index 0) into one tail; the only
  divergent path (full && !needsCard → buy/auto/limit) stays explicit
- /topup overview comment: correct the stale 'buy_flow detects no_payment_method'
  note (the overview's no-card gate fires first, so reaching Add funds implies a
  card on file)

Skipped (judgment): the orphaned CreditsView.depleted field (harmless, on a live
dataclass), the defensive card gates in _billing_buy_flow/_confirm_and_charge
(cheap correct defense on the money path), and folding the no-card handoff into a
shared helper (touches 4 money-path sites for tidiness — not worth the risk here).

* fix(billing): reactive charge gating — drop card preflight, react to 403 (scope→reauth, no-card→portal)

* refactor(billing): drop the /credits alias entirely

The /credits fold made it an alias of /topup; now remove that too. Typing
/credits is an unknown command, not a silent redirect — billing lives only on
/topup (with /billing kept as the old command's back-compat name). Dropped the
alias from the registry CommandDef and the TUI topup.ts; updated the test to
assert /credits resolves to nothing (no command, no alias).

* docs(billing): fix stale comment in _billing_overview — describe reactive no-card path

The comment still described the removed overview-level card gate ('no-card case
handled above'). Corrected to: the buy flow reacts to the server's
no_payment_method 403 and hands off to the portal at charge time (no preflight).

* refactor(billing): simplify-pass — share usage-payload helper, drop dead bar wire fields + redundant admin gate

* refactor(billing): drop the /billing alias too — /topup is the only billing command

Following /credits removal, retire the old /billing name as well. /topup now has
NO aliases — both /credits and /billing are unknown commands. Dropped the alias
from the registry CommandDef and TUI topup.ts; fixed the one live user-facing
straggler (the not-logged-in message said 'then /billing' → /topup) and the
_show_billing docstring/default-arg references. Test asserts /topup carries no
aliases and neither old name resolves.

* fix(billing): code-review fixes — money-path + parity bugs

Money path (TUI):
- auto-reload "Turn off" now echoes current threshold/top_up_amount so the
  PATCH succeeds (was sending {enabled:false} → invalid_request → stayed ON)
- charge poll honors the 5-min cap on the 429/503 throttle branch too (was
  rescheduling forever); cap folded into one timedOut() helper
- step-up resume reacts to the replay outcome instead of unconditionally
  closing on a reassuring line with no charge made
- synchronous submit guard on Confirm so two key events can't double-charge

Gateway:
- billing.step_up routes typed errors through _serialize_billing_error (was a
  raw {error:'error'} dict → generic copy for session_revoked)
- billing.state / subscription.state / usage.bars / session.usage moved to
  _LONG_HANDLERS (blocking portal HTTP no longer stalls the main stdin loop)

CLI:
- _billing_render_charge_error handles insufficient_scope without leaking the
  raw billing:manage scope name on a post-grant replay re-raise

Python model:
- subscription_view tier parse None-coalesces tierOrder/dollarsPerMonth so a
  free tier's 0 survives ($0, not "—"; correct sort order)

TUI parity/robustness:
- /usage shows formatted renews_display, not raw ISO renews_at
- subscription overview guards a null pending_downgrade_at (was "on null.")
- subscription overview surfaces a message instead of silently closing when
  portal_url is missing
- buildManageUrl wraps new URL() so a malformed portal_url can't throw out of
  the Ink key handler

* fix(billing): cross-surface bar direction, formatted cancel/downgrade dates, Slack alias gating

- CLI plan bar now fills by REMAINING (fuel-gauge), matching the shared model's
  fill_fraction, the top-up bar, and the TUI — same account renders identically
  on both surfaces (#8)
- subscription serializer emits cancellation_effective_display /
  pending_downgrade_display (format_renews); TUI shows 'Jul 1, 2026' not raw ISO (#14b)
- _SLACK_VIA_HERMES_ONLY now includes the 'billing' alias so it follows its
  canonical /topup via /hermes instead of leaking a native Slack slot (#9)

* fix(billing): thread idempotency key through the TUI step-up replay (#2)

Mint a stable idempotency key when the purchase amount is chosen; it rides
pendingCharge into both the Confirm charge and the post-grant step-up replay,
so a retried charge dedups server-side (the gateway already echoes the key).
A fresh amount selection gets a fresh key. Combined with the sync submit guard,
a double-submit now collapses to one charge.

* refactor(billing): remove dead /subscription tier-picker scaffolding (#18)

The in-terminal plan picker was cut (deep-link only), leaving a whole unreached
state machine. Removed end-to-end:
- TUI: ConfirmScreen, HandoffScreen, the 'confirm'/'handoff' screen types,
  pendingTargetTierId, and the now-dead onPatch threading (collapsed the dispatch
  to a single overview screen + folded the duplicate Box wrapper)
- gateway: the tiers serialization + SubscriptionTierOption wire type
- model: SubscriptionTier, _parse_tier, _coalesce, _dev_tiers and the tiers field
  (never displayed on either surface, so this supersedes the tier-parse fix)
- tests: dropped the confirm/handoff/tier-passthrough tests; slimmed the overview
  render tests

Net: a large dead-code cull (no behavior change — the picker never ran).

* test(billing): parametrize usage-model tests; drop dead is_low/is_free props

Collapse the fail-open + status-classification cases into parametrized tables
(same coverage, ~80 fewer lines) and remove the now-unused UsageModel.is_low /
is_free properties (only a test pinned them).

* fix(billing): revert dead 'billing' Slack-via-hermes entry — the alias was dropped

#9 was based on a stale review diff: /billing is no longer an alias of /topup
(dropped earlier), so routing it via /hermes filtered a name that doesn't exist.

* test(billing): cull redundant TUI billing tests (parametrize, merge dupes)

usageCommand: collapse 3 CTA tests into one + a panel helper.
billingStepUp: merge the two step-up render asserts.
topupCommand: parametrize requestRemoteSpending + the revoked-actor pair, drop
the redundant happy-path-submitted test. Money-path + error-mapping coverage
preserved.

* refactor(billing): extract _usage_bar_lines — one source of truth for the CLI bars

The plan + top-up bar format was copy-pasted across _print_nous_credits_block,
_subscription_overview, and _billing_overview. Extract a helper returning the
ready-to-print lines; each caller keeps its own print fn (the _cprint-ordering
constraint stays) and resolves its plan-name label. Centralizes the format so
the three surfaces can't drift.

* feat(billing): NAS V3 subscription-change HTTP client wrappers

Add the four write-side wrappers for the V3 subscription contract to nous_billing,
each a thin _request() call (reusing auth, JSON, 401-retry, typed errors):
- post_subscription_preview      → POST  /subscription/preview      (chargeless quote)
- put_subscription_pending_change→ PUT   /subscription/pending-change (downgrade/cancel)
- delete_subscription_pending_change → DELETE .../pending-change      (resume/undo)
- post_subscription_upgrade      → POST  /subscription/upgrade        (the money route)

pending-change takes a discriminated body (tier_change | cancellation); upgrade
requires an Idempotency-Key (mandatory, validated client-side before any I/O).
Tests assert the exact method/path/body/header each wrapper puts on the wire.

* feat(billing): subscription tier catalog + change-preview models

Reinstate the catalog the in-terminal picker needs (was culled when /subscription
was deep-link-only): SubscriptionTier + SubscriptionState.tiers + _parse_tier, with
_coalesce so the free tier's 0 tierOrder/price survives a falsy-or. Parse the
catalog from GET /subscription's tiers and seed _dev_tiers into every fixture.

Add SubscriptionChangePreview + subscription_change_preview_from_payload for the
POST /preview quote (effect/amountDueNowCents/effectiveAt/reason + tier delta); a
malformed/missing effect fails safe to 'blocked' so a bad quote never reads as a
charge. Module docstring updated: the overlay is no longer deep-link-only.

* feat(billing): gateway RPCs for the V3 subscription change flow

Add subscription.preview / .change / .resume / .upgrade RPCs, each wrapping its
nous_billing call and reusing _serialize_billing_error for the typed envelope
(so a 403 still drives the device step-up). upgrade mints + echoes the
idempotency key and surfaces status + recovery_url so the TUI can route an
SCA/decline to the portal. Re-add the tier catalog to _serialize_subscription_state
(price pre-formatted) for the picker. All four are pool-routed (_LONG_HANDLERS) —
preview + upgrade hit Stripe and must not stall the main stdin loop.

* feat(billing): in-terminal subscription change flow (TUI)

/subscription is no longer deep-link-only: it drives the change in-terminal
against the V3 contract via the new gateway RPCs. The overlay is a state machine
overview → picker → confirm → result:
- picker lists the tier catalog with upgrade/downgrade hints (current + free
  excluded; free=cancel, on the overview);
- confirm shows the previewed effect — pay $X now (upgrade) / scheduled at date
  (downgrade) / cancel at period end / blocked-with-reason — then applies it;
- an upgrade's SCA/decline routes to the portal via the result screen's recovery
  link; resume/cancel/downgrade are chargeless.

Starting a NEW subscription still deep-links (needs a fresh card). insufficient_scope
points to /topup (the step-up stays there, not duplicated here). Adds the wire
types (tiers + preview/upgrade responses), widens the overlay ctx + screen state,
and threads onPatch. Render tests cover every screen.

* feat(billing): in-terminal step-up + clearer scheduled-change UX (TUI)

Two improvements to the /subscription overlay:

Step-up re-auth in place. When a mutation (preview/change/upgrade/resume) returns
insufficient_scope, route to a new 'stepup' screen that grants terminal billing
via billing.step_up and AUTO-REPLAYS the held action on grant — no bounce to
/topup. Scope routing is centralized in previewAndRoute/applyPendingAndRoute/
resumeAndRoute (shared by the picker, confirm, overview + the step-up replay). The
browser opens via the shared global verification handler; copy never leaks the raw
billing:manage scope.

Make a scheduled change unmissable. A downgrade/cancel was one buried warn line
that read as 'nothing happened'. Now the overview leads with a banner
( Scheduled change · Ultra ──▶ Plus · <date> · you keep Ultra until then), the
status line echoes the transition (Plan: Ultra → Plus), 'Keep <tier> (undo)' is
promoted to the first olive action, the result screen says 'your plan doesn't
change today', and confirm gets a charged-now / scheduled chip.

* feat(billing): full in-terminal subscription change flow in the classic CLI

Bring the CLI to parity with the TUI overlay — /subscription is no longer
deep-link-only. A paid admin/owner gets picker → preview → confirm → apply,
mirroring the /topup buy flow's modal idioms:
- _subscription_change_menu (change / undo-or-cancel / manage-on-portal),
- _subscription_pick_tier (catalog with upgrade/downgrade hints),
- _subscription_preview_and_confirm (POST /preview → effect-aware confirm),
- _subscription_apply (schedule / cancel / resume chargeless; upgrade charges
  the sub's card, SCA/decline → portal),
- _subscription_handle_scope_required (insufficient_scope → step_up_nous_billing_scope
  inline, then replays the held preview/mutation — reusing the upgrade idempotency key).

Also the scheduled-change UX fix: the overview leads with a prominent banner
( Scheduled change · Super ──▶ Plus · <date> · you keep Super until then) and the
status line echoes the transition, matching the TUI. Members / non-interactive /
free still deep-link. Tests drive every branch via a mocked modal + nous_billing.

* fix(billing): close TUI subscription money-path holes (ultracode review)

- Un-consented charge (P1): the step-up now HOLDS at a 'granted' phase requiring
  an explicit Continue, and an abortedRef gates the grant's late .then — a cancel
  during the browser flow can no longer replay the held upgrade + charge.
- Missing idempotency key (P2): mint it when building an upgrade 'pending' so it
  rides into confirm AND the step-up replay (was always undefined → gateway minted
  a fresh key per call, defeating dedup).
- Navigate-away re-charge (P2): confirm 'back' is guarded by submittingRef while an
  apply is in flight.
- Ambiguous charge (P2): a transport-null upgrade is reported as 'may or may not
  have charged — re-check', never a flat failure that invites a blind retry.
- Typed step-up denial (P2): requestRemoteSpending returns {granted,error,message};
  the screen maps session_revoked / remote_spending_revoked / rate_limited to the
  right recovery instead of always 'an admin must allow it'.

* fix(billing): close CLI subscription money-path holes (ultracode review)

- Bounded step-up (P2): bust the 30s token cache after a grant (it held the
  pre-grant unscoped token; _request only busts on 401, not 403) and replay ONCE
  with allow_stepup=False so a still-denied scope can't re-prompt/re-open in a loop.
- Stray-keystroke charge (P3→near-P2): the upgrade confirm defaults to 'Go back',
  not 'Pay ' — a bare Enter can't move money.
- Fail-open on unknown effect (P3→near-P2): an unrecognized preview effect now
  fails SAFE (portal hand-off) instead of scheduling a real PUT.
- 'cancel' word collision (P3): the Close row uses value 'close' so typing 'cancel'
  can't hit it and falsely report 'Cancelled'.
- blocked effect re-offers the portal; undo is promoted to the first row when a
  change is pending (TUI parity).

* fix(billing): guard the step-up resume against double-fire (2nd ultracode pass, BUG A)

The P1 fix split the auto-replay into a user-triggered resume() on the granted
screen, where the default row is the charging action — but resume() had no
re-entrancy guard, so a double-Enter fired two replays (the upgrade dedups on the
shared key, but schedule/cancel/resume replays carry none → duplicate PUT/DELETEs).
Mirror billingOverlay.resume(): flip to a 'resuming' phase + a resumingRef so it
fires at most once, and block 'back' once resuming (no re-mount → no second submit).

* fix(billing): CLI charge-route ambiguous-charge caveat (2nd ultracode pass, BUG B)

The TUI hardened upgradeResult(null) but the CLI charging route did not: a
transport/timeout/500 (or unknown 2xx status) on post_subscription_upgrade — after
NAS may have already prorated + charged — printed a flat failure, and a manual
re-run mints a FRESH idempotency key the server can't dedup → a real second charge.
Now the charge route reports 'your card may or may not have been charged — re-run
/subscription to check before trying again' and steers away from a blind retry
(the CLI can't persist the key across a command re-run). Also thread allow_stepup
through the preview→apply replay (BUG C.1) and route the requires_action/
payment_failed portal lines through _cprint for deterministic ordering.

* fix(billing): cap the TUI step-up replay to avoid a resume-deadlock (final pass, R1)

The round-2 resume guard ('resuming' phase + resumingRef) could deadlock: on a
REPEAT insufficient_scope during the post-grant replay, the route helpers did
onPatch({screen:'stepup'}) — a no-op since we're already mounted on stepup (no key
→ no remount) — leaving phase='resuming'/resumingRef=true frozen on 'Applying your
change…'. Thread allowStepUp through previewAndRoute/applyPendingAndRoute/
resumeAndRoute; the resume() replay passes false, so a repeat scope denial surfaces
a 'still isn't enabled' result instead (mirrors the CLI's allow_stepup=False cap).
Also: applyPendingAndRoute(pending=null) now routes to overview, not a stranded
Promise.resolve().

* fix(billing): narrow the CLI ambiguous-charge catch to indeterminate outcomes (final pass, R2)

The round-2 fix caught EVERY non-scope BillingError as 'may or may not have been
charged' — but typed pre-charge rejections (BillingRateLimited 429, BillingSessionRevoked
401, BillingRemoteSpendingRevoked 403, role_required/no_payment_method 4xx) never
reached Stripe, so the ambiguity copy was wrong and dropped their real recovery hints.
Now route those to _subscription_render_error, and reserve the ambiguous copy for
genuinely indeterminate outcomes (network_error / endpoint_unavailable / status None /
5xx). Tests: rate-limit stays deterministic; a real transport failure stays ambiguous.

* feat(billing): card visibility + guided add-card path in /topup and /subscription

Consume the NAS card-resolver contract (card.resolvedVia + chargeability) across
both surfaces, degrading cleanly on today's NAS (fields absent → prior behavior):

- WHICH card: the payment lines render provenance — 'Visa ····4242 — the card on
  your subscription' (resolvedVia → label; unknown rung/older NAS → masked card +
  the old generic line). Link payment methods render the brand alone (last4 is
  empty — never 'Link ····').
- Presence at a glance: the /topup overview now shows 'Card: …' or 'No saved
  card on file' for the full-menu case, plus a warning when the resolver marks
  the card needs_repair (failing auto-reloads) on overview/buy/confirm.
- Add-card path: with no card on file, 'Add funds' becomes a guided screen —
  open the portal billing page, then 'I've added it — check again' re-fetches
  billing state and continues straight into the purchase (also recovers a
  transient display miss). Cards are never entered in-terminal.
- /subscription upgrade confirm names the exact card ('Visa ····4242 — the card
  on your subscription — will be charged'), best-effort via billing.state and
  only when the resolution rung matches what a subscription charge actually
  uses (subPin/customerDefault, mirroring Stripe's precedence); otherwise the
  generic line stands. Fail-soft: any lookup error keeps the generic line.
- Gateway serializes display/resolved_via/needs_repair; TUI ctx gains
  refreshState (topup) + fetchCard (subscription); new offline fixtures
  card-sub / card-repair.

Tests: TUI ctx mocks extended; CLI suites cover provenance + repair-warning
render, the Link guard, the add-card path (continue-after-recheck + abandon),
the sub-confirm card line, and keep the confirm-time lookup offline in tests.

* feat(desktop): add desktop-local billing wire types

* feat(desktop): billing gateway API client and refusal taxonomy

* feat(desktop): register billing settings tab with skeleton view

* feat(desktop): wire billing tab to live gateway reads with fail-open states

* feat(desktop): buy-credits charge flow with settlement poller

* fix(desktop): keep About last in settings nav, billing above it

* feat(desktop): auto-refill editing and billing step-up verification flow

* fix(desktop): clamp overdrawn subscription credits and pin USD symbol formatting

* fix(desktop): move billing next to notifications in settings nav

* feat(desktop): usage-bar state colors and dev fixture simulator

* feat(desktop): wide usage bars with top-up bar and refresh affordance

* fix(desktop): disable buy controls without a card, neutral tracks for bar-less usage rows

* polish(desktop): usage-grid alignment, tabular numerals, legible tracks and danger states

* polish(desktop): dithered empty and depleted usage-bar tracks per app bar idiom

* fix(billing): consume server canChangePlan, preserve distinct refusal codes, drop dead chargeability

- Parse canChangePlan verbatim from NAS payloads into BillingState and
  SubscriptionState; fall back to the legacy OWNER/ADMIN check only when the
  server omits the field (FINANCE_ADMIN stops being locked out where NAS
  authorizes it). Role model updated to the 5-role enum.
- Add the autoReload.card union (canonical | distinct | none) end-to-end:
  parse + gateway serialization, distinct carries payment_method_id/brand/last4
  with nullable display fields.
- stripe_unavailable (503, transient) and upgrade_cap_exceeded (429, daily cap)
  now survive to the wire as their own codes instead of collapsing into
  rate_limited; new exception types subclass BillingRateLimited so existing
  backoff call sites keep working.
- Remove card.chargeability / needs_repair parsing, serialization, fixtures and
  the cli warning blocks: NAS #670 removed the field, so the repair path was
  permanently dead. The future card-health signal belongs to the NAS W1/W3 work.
- Tests: five-role fixtures, canChangePlan override/fallback, all three
  auto-reload card variants, 429-vs-503 code preservation end-to-end.

* feat(tui): render the full NAS billing refusal surface

- billingOverlay: divergence notice when auto-refill charges a distinct card
  (portal deep-link to reconcile); needs_repair warnings removed with the field.
- topup: explicit copy for consent_required, org_access_denied,
  upgrade_cap_exceeded, auto_top_up_disabled_failures and stripe_unavailable
  (honors retry_after); processing_error is an explicit charge-failure case;
  transport loss during charge polling now reads as an unconfirmed outcome
  (check balance before retrying), matching the revocation path.
- subscriptionOverlay: branch on upgrade reason, not status, so an SCA-needing
  upgrade routes to portal verification even while NAS pre-#711 labels it
  payment_failed; after an upgrade, poll subscription state until the tier
  flips (bounded), rendering applying/still-applying rather than assuming
  immediacy.
- Capability-neutral refusal copy (owner, admin, or finance admin) replaces
  the stale org admin/owner wording.
- gatewayTypes: BillingAutoReload.card union added, needs_repair removed.

* refactor(shared): move terminal-billing wire types to @hermes/shared

The billing/subscription wire shapes (plus UsageBarData/UsageModelData,
which they reference) move verbatim from ui-tui/src/gatewayTypes.ts into
apps/shared/src/billing-types.ts so the desktop app can share the same
gateway contract. gatewayTypes.ts re-exports every moved name from the
new @hermes/shared/billing subpath, so no ui-tui consumer changes.

The subpath export keeps DOM-less ui-tui from pulling the barrel (whose
WebSocket helpers need the DOM lib). ui-tui also now declares its
@hermes/shared dependency explicitly instead of relying on workspace
hoisting.

* test(cli): pin nous_billing wire-layer status-to-exception mapping

The HTTP layer's error handling had zero coverage through _request:
only 2xx parsing and request shaping were tested, and the mapping cases
in test_remote_spending_gate_contract.py hit _raise_for_error directly.

Adds 19 tests driving _request via a monkeypatched urlopen: the
401-refresh-retry path (success, terminal plain/session_revoked,
idempotency-key preservation, base re-resolution), 403 variants through
the wire, 429/503 retry-after, non-JSON error bodies, 404/502
fallbacks, and URLError normalization.

Two behaviors are pinned as findings rather than fixed: a JSON-body
retryAfter hint is ignored unless the Retry-After header is present,
and a bare socket.timeout propagates uncaught (real urllib wraps
timeouts in URLError before this layer).

* fix(tui_gateway): delete dead credits.view RPC

The handler assigns into an undefined `usage` variable, so any call
would raise NameError (the except swallows the first hit, then the
return re-raises it uncaught). Nothing can reach it: the TUI command
registry removed /credits (pinned by test_credits_command_fully_removed)
and no client sends the RPC. The live credit view is
agent/account_usage.py::build_credits_view via the remote gateway's
/topup command, which is untouched.

* fix(cli): normalize read-phase timeouts to the typed billing error

urlopen wraps connect-phase timeouts in URLError (already mapped to
network_error), but a timeout during resp.read() raises a bare
TimeoutError that escaped the typed-BillingError contract and reached
callers as an unhandled exception. Catch it narrowly and normalize.
The boundary test now asserts normalization instead of documenting the
leak.

* fix(shared): stop typing mutation success payloads as error payloads

BillingMutationResponse.payload was declared BillingErrorPayload, but on
ok:true the gateway passes through the raw NAS success body (rail,
changeType, cancelAtPeriodEnd, ...). The TUI never reads it so nothing
broke, but the shared contract now feeds the desktop app too — widen the
field deliberately and document both shapes.

* feat(shared): typed billing refusal and charge-failure unions

- BillingRefusalCode covers every code the gateway serializes today, with a
  (string & {}) arm so unknown future codes (the NAS W3 card-health family)
  stay assignable — consumers keep their unknown-code fallback.
- ChargeFailureReason models the four NAS terminal reasons plus the raw
  subscription_payment_intent_requires_action code NAS leaks pre-#711.
- billing.state now carries the server-derived can_change_plan the gateway
  emits; capability comments updated (canChangePlan is capability-based, not
  an OWNER/ADMIN role gate).

* docs(billing): client-side billing state and refusal lifecycle table

Enumerates, from the code, every billing.state shape and typed refusal the
gateway serves and the exact TUI copy + recovery each renders. Acceptance from
the billing-integration handoff: no NAS billing state or typed refusal falls
through to a generic toast; unknown codes still degrade to the default branch
that surfaces the server message.

* refactor(desktop): consume @hermes/shared billing types, full refusal copy, divergence notice

- billing/types.ts becomes a re-export shim over @hermes/shared/billing (keeps
  the desktop-only bounds field via a local BillingAutoReload extension);
  needs_repair is gone with the shared type.
- resolveRefusal gains specific copy for consent_required, org_access_denied,
  upgrade_cap_exceeded, stripe_unavailable (transient, honors retry_after) and
  processing_error; BillingErrorKind now IS the shared BillingRefusalCode.
  Default fallback unchanged.
- Auto-refill row surfaces the distinct-card divergence: caption naming the
  charging card (or 'a different card' when brand/last4 are null) and a
  Reconcile portal deep-link instead of the inline edit form.
- Fixtures/tests updated for the required auto_reload.card union; new
  auto-refill-divergent dev fixture.

* fix(desktop): auto-refill-divergent fixture must be enabled to exercise the divergence row

* refactor(billing): explicit BillingTransient trait, drop broken credits.view, public token-cache invalidation

- BillingRateLimited / BillingStripeUnavailable / BillingUpgradeCapExceeded
  become siblings under a new BillingTransient trait (deterministic non-charge
  outcome, safe to retry) instead of the false is-a chain that made a Stripe
  outage 'a kind of rate limiting'. Catch sites that meant 'any deterministic
  pre-charge transient' now say so explicitly; the gateway serializer
  dispatches on the trait and emits the preserved raw code.
- Delete the credits.view RPC handler left broken by the /topup rename (its
  body referenced an undefined variable; no caller remains).
- invalidate_cached_token() replaces the CLI's reach into the private
  _token_cache global after a billing step-up.

* refactor(cli): extract CLIBillingMixin; charge gates follow the server capability

- Move the ~1,400-line billing/subscription handler family out of cli.py into
  hermes_cli/cli_billing_mixin.py, following the existing HermesCLI mixin
  pattern (lazy cli imports, verbatim bodies).
- can_charge and the CLI billing-action gates now route through
  can_change_plan (server capability with legacy role fallback) instead of the
  deprecated 3-role is_admin — a FINANCE_ADMIN the server authorizes can now
  add funds, matching the plan-change path.
- Render the spend bar from the UsageBar model's fill_fraction instead of the
  deleted _billing_spend_bar re-derivation; fix a stale docstring.

* refactor(tui): promote useMenu to overlay primitives, type pendingTierId end-to-end

- useMenu (arrow/number/Enter/Esc menu hook) moves to overlayPrimitives with
  an onKey escape hatch; billingOverlay's Overview and Limit screens drop
  their verbatim copies. BuyScreen keeps its bespoke handler (typing mode +
  stale-selection clamp don't fit the shared contract cleanly).
- SubscriptionResult carries pendingTierId directly; the shadow
  SubscriptionResultWithPending interface and the ResultScreen cast are gone,
  so the apply-poll field is type-tracked through finish().

* docs(billing): correct the CLI-parity row — the CLI has the full in-terminal change flow

* refactor(shared): move terminal-billing wire types to @hermes/shared

The billing/subscription wire shapes (plus UsageBarData/UsageModelData,
which they reference) move verbatim from ui-tui/src/gatewayTypes.ts into
apps/shared/src/billing-types.ts so the desktop app can share the same
gateway contract. gatewayTypes.ts re-exports every moved name from the
new @hermes/shared/billing subpath, so no ui-tui consumer changes.

The subpath export keeps DOM-less ui-tui from pulling the barrel (whose
WebSocket helpers need the DOM lib). ui-tui also now declares its
@hermes/shared dependency explicitly instead of relying on workspace
hoisting.

* test(cli): pin nous_billing wire-layer status-to-exception mapping

The HTTP layer's error handling had zero coverage through _request:
only 2xx parsing and request shaping were tested, and the mapping cases
in test_remote_spending_gate_contract.py hit _raise_for_error directly.

Adds 19 tests driving _request via a monkeypatched urlopen: the
401-refresh-retry path (success, terminal plain/session_revoked,
idempotency-key preservation, base re-resolution), 403 variants through
the wire, 429/503 retry-after, non-JSON error bodies, 404/502
fallbacks, and URLError normalization.

Two behaviors are pinned as findings rather than fixed: a JSON-body
retryAfter hint is ignored unless the Retry-After header is present,
and a bare socket.timeout propagates uncaught (real urllib wraps
timeouts in URLError before this layer).

* fix(cli): normalize read-phase timeouts to the typed billing error

urlopen wraps connect-phase timeouts in URLError (already mapped to
network_error), but a timeout during resp.read() raises a bare
TimeoutError that escaped the typed-BillingError contract and reached
callers as an unhandled exception. Catch it narrowly and normalize.
The boundary test now asserts normalization instead of documenting the
leak.

* fix(shared): stop typing mutation success payloads as error payloads

BillingMutationResponse.payload was declared BillingErrorPayload, but on
ok:true the gateway passes through the raw NAS success body (rail,
changeType, cancelAtPeriodEnd, ...). The TUI never reads it so nothing
broke, but the shared contract now feeds the desktop app too — widen the
field deliberately and document both shapes.

* feat(shared): typed billing refusal and charge-failure unions

- BillingRefusalCode covers every code the gateway serializes today, with a
  (string & {}) arm so unknown future codes (the NAS W3 card-health family)
  stay assignable — consumers keep their unknown-code fallback.
- ChargeFailureReason models the four NAS terminal reasons plus the raw
  subscription_payment_intent_requires_action code NAS leaks pre-#711.
- billing.state now carries the server-derived can_change_plan the gateway
  emits; capability comments updated (canChangePlan is capability-based, not
  an OWNER/ADMIN role gate).

* feat(shared): closed Known* halves for the refusal and charge-failure unions

- KnownBillingRefusalCode / KnownChargeFailureReason are closed literal sets,
  so classification tables, copy maps and tests can be Record-exhaustive and
  break at compile time when a code is added but not mapped. The wire types
  keep the (string & {}) open arm for unknown future codes.
- Add network_error (client-originated transport code the gateway already
  serializes) to the known set.
- Export the union types from the root barrel alongside the other billing
  names.

* feat(shared): canonical billing refusal policy and charge-settlement driver

- billing-policy.ts: one exhaustive Record<KnownBillingRefusalCode,
  BillingRefusalPolicy> classifying every known code (recovery kind,
  mid-poll ambiguity, idempotency-key reuse) with a documented unknown-code
  fallback. Surfaces keep their own copy; the behavior classification now
  has a single home that breaks the build when a new code goes unmapped.
- charge-settlement.ts: the settlement poll state machine (2s cadence,
  5-minute cap, bounded retry-after backoff, ambiguous-on-revocation) as a
  pure dependency-injected driver returning a discriminated outcome.
- The TUI's pollCharge becomes a thin renderer over the shared driver —
  byte-identical output, and the desktop poller can now share the same
  machine instead of a drifting copy.

* fix(desktop): real auto-reload bounds, shared refusal policy and settlement driver

- Delete the phantom BillingAutoReload.bounds plumbing: nothing ever populated
  it, so the auto-reload amount validation it fed was silently dead. The
  editor and validators now enforce the gateway's real top-level
  min_usd/max_usd (new test pins the $10 minimum actually rejecting), and
  types.ts collapses to a plain re-export shim over @hermes/shared/billing.
- Delete the test-only BillingRpcResponse envelope family; BillingResult is
  the one response model.
- Refusal copy speaks desktop: reconnect/sign-in route to Settings → Gateway
  instead of the TUI's /portal command; the dead processing_error refusal
  case is gone (it is a charge-failure reason, already rendered by the
  poller).
- Adopt @hermes/shared billing-policy + charge-settlement: the poll loop is
  the shared driver, revocation-ambiguity comes from the policy table
  (insufficient_scope mid-poll now counts, per the ruling), and all
  policy-retry codes back off during polling instead of failing hard.
  errors.test.ts is Record-exhaustive over KnownBillingRefusalCode again.

* refactor(shared): move terminal-billing wire types to @hermes/shared

The billing/subscription wire shapes (plus UsageBarData/UsageModelData,
which they reference) move verbatim from ui-tui/src/gatewayTypes.ts into
apps/shared/src/billing-types.ts so the desktop app can share the same
gateway contract. gatewayTypes.ts re-exports every moved name from the
new @hermes/shared/billing subpath, so no ui-tui consumer changes.

The subpath export keeps DOM-less ui-tui from pulling the barrel (whose
WebSocket helpers need the DOM lib). ui-tui also now declares its
@hermes/shared dependency explicitly instead of relying on workspace
hoisting.

* test(cli): pin nous_billing wire-layer status-to-exception mapping

The HTTP layer's error handling had zero coverage through _request:
only 2xx parsing and request shaping were tested, and the mapping cases
in test_remote_spending_gate_contract.py hit _raise_for_error directly.

Adds 19 tests driving _request via a monkeypatched urlopen: the
401-refresh-retry path (success, terminal plain/session_revoked,
idempotency-key preservation, base re-resolution), 403 variants through
the wire, 429/503 retry-after, non-JSON error bodies, 404/502
fallbacks, and URLError normalization.

Two behaviors are pinned as findings rather than fixed: a JSON-body
retryAfter hint is ignored unless the Retry-After header is present,
and a bare socket.timeout propagates uncaught (real urllib wraps
timeouts in URLError before this layer).

* fix(cli): normalize read-phase timeouts to the typed billing error

urlopen wraps connect-phase timeouts in URLError (already mapped to
network_error), but a timeout during resp.read() raises a bare
TimeoutError that escaped the typed-BillingError contract and reached
callers as an unhandled exception. Catch it narrowly and normalize.
The boundary test now asserts normalization instead of documenting the
leak.

* fix(shared): stop typing mutation success payloads as error payloads

BillingMutationResponse.payload was declared BillingErrorPayload, but on
ok:true the gateway passes through the raw NAS success body (rail,
changeType, cancelAtPeriodEnd, ...). The TUI never reads it so nothing
broke, but the shared contract now feeds the desktop app too — widen the
field deliberately and document both shapes.

* feat(shared): typed billing refusal and charge-failure unions

- BillingRefusalCode covers every code the gateway serializes today, with a
  (string & {}) arm so unknown future codes (the NAS W3 card-health family)
  stay assignable — consumers keep their unknown-code fallback.
- ChargeFailureReason models the four NAS terminal reasons plus the raw
  subscription_payment_intent_requires_action code NAS leaks pre-#711.
- billing.state now carries the server-derived can_change_plan the gateway
  emits; capability comments updated (canChangePlan is capability-based, not
  an OWNER/ADMIN role gate).

* feat(shared): closed Known* halves for the refusal and charge-failure unions

- KnownBillingRefusalCode / KnownChargeFailureReason are closed literal sets,
  so classification tables, copy maps and tests can be Record-exhaustive and
  break at compile time when a code is added but not mapped. The wire types
  keep the (string & {}) open arm for unknown future codes.
- Add network_error (client-originated transport code the gateway already
  serializes) to the known set.
- Export the union types from the root barrel alongside the other billing
  names.

* feat(shared): canonical billing refusal policy and charge-settlement driver

- billing-policy.ts: one exhaustive Record<KnownBillingRefusalCode,
  BillingRefusalPolicy> classifying every known code (recovery kind,
  mid-poll ambiguity, idempotency-key reuse) with a documented unknown-code
  fallback. Surfaces keep their own copy; the behavior classification now
  has a single home that breaks the build when a new code goes unmapped.
- charge-settlement.ts: the settlement poll state machine (2s cadence,
  5-minute cap, bounded retry-after backoff, ambiguous-on-revocation) as a
  pure dependency-injected driver returning a discriminated outcome.
- The TUI's pollCharge becomes a thin renderer over the shared driver —
  byte-identical output, and the desktop poller can now share the same
  machine instead of a drifting copy.

* refactor(shared): move terminal-billing wire types to @hermes/shared

The billing/subscription wire shapes (plus UsageBarData/UsageModelData,
which they reference) move verbatim from ui-tui/src/gatewayTypes.ts into
apps/shared/src/billing-types.ts so the desktop app can share the same
gateway contract. gatewayTypes.ts re-exports every moved name from the
new @hermes/shared/billing subpath, so no ui-tui consumer changes.

The subpath export keeps DOM-less ui-tui from pulling the barrel (whose
WebSocket helpers need the DOM lib). ui-tui also now declares its
@hermes/shared dependency explicitly instead of relying on workspace
hoisting.

* test(cli): pin nous_billing wire-layer status-to-exception mapping

The HTTP layer's error handling had zero coverage through _request:
only 2xx parsing and request shaping were tested, and the mapping cases
in test_remote_spending_gate_contract.py hit _raise_for_error directly.

Adds 19 tests driving _request via a monkeypatched urlopen: the
401-refresh-retry path (success, terminal plain/session_revoked,
idempotency-key preservation, base re-resolution), 403 variants through
the wire, 429/503 retry-after, non-JSON error bodies, 404/502
fallbacks, and URLError normalization.

Two behaviors are pinned as findings rather than fixed: a JSON-body
retryAfter hint is ignored unless the Retry-After header is present,
and a bare socket.timeout propagates uncaught (real urllib wraps
timeouts in URLError before this layer).

* fix(cli): normalize read-phase timeouts to the typed billing error

urlopen wraps connect-phase timeouts in URLError (already mapped to
network_error), but a timeout during resp.read() raises a bare
TimeoutError that escaped the typed-BillingError contract and reached
callers as an unhandled exception. Catch it narrowly and normalize.
The boundary test now asserts normalization instead of documenting the
leak.

* fix(shared): stop typing mutation success payloads as error payloads

BillingMutationResponse.payload was declared BillingErrorPayload, but on
ok:true the gateway passes through the raw NAS success body (rail,
changeType, cancelAtPeriodEnd, ...). The TUI never reads it so nothing
broke, but the shared contract now feeds the desktop app too — widen the
field deliberately and document both shapes.

* feat(shared): typed billing refusal and charge-failure unions

- BillingRefusalCode covers every code the gateway serializes today, with a
  (string & {}) arm so unknown future codes (the NAS W3 card-health family)
  stay assignable — consumers keep their unknown-code fallback.
- ChargeFailureReason models the four NAS terminal reasons plus the raw
  subscription_payment_intent_requires_action code NAS leaks pre-#711.
- billing.state now carries the server-derived can_change_plan the gateway
  emits; capability comments updated (canChangePlan is capability-based, not
  an OWNER/ADMIN role gate).

* feat(shared): closed Known* halves for the refusal and charge-failure unions

- KnownBillingRefusalCode / KnownChargeFailureReason are closed literal sets,
  so classification tables, copy maps and tests can be Record-exhaustive and
  break at compile time when a code is added but not mapped. The wire types
  keep the (string & {}) open arm for unknown future codes.
- Add network_error (client-originated transport code the gateway already
  serializes) to the known set.
- Export the union types from the root barrel alongside the other billing
  names.

* feat(shared): canonical billing refusal policy and charge-settlement driver

- billing-policy.ts: one exhaustive Record<KnownBillingRefusalCode,
  BillingRefusalPolicy> classifying every known code (recovery kind,
  mid-poll ambiguity, idempotency-key reuse) with a documented unknown-code
  fallback. Surfaces keep their own copy; the behavior classification now
  has a single home that breaks the build when a new code goes unmapped.
- charge-settlement.ts: the settlement poll state machine (2s cadence,
  5-minute cap, bounded retry-after backoff, ambiguous-on-revocation) as a
  pure dependency-injected driver returning a discriminated outcome.
- The TUI's pollCharge becomes a thin renderer over the shared driver —
  byte-identical output, and the desktop poller can now share the same
  machine instead of a drifting copy.

* chore: retrigger CI with the current base SHA (stale base pin flagged a false CI-sensitive change)

* refactor(shared): move terminal-billing wire types to @hermes/shared

The billing/subscription wire shapes (plus UsageBarData/UsageModelData,
which they reference) move verbatim from ui-tui/src/gatewayTypes.ts into
apps/shared/src/billing-types.ts so the desktop app can share the same
gateway contract. gatewayTypes.ts re-exports every moved name from the
new @hermes/shared/billing subpath, so no ui-tui consumer changes.

The subpath export keeps DOM-less ui-tui from pulling the barrel (whose
WebSocket helpers need the DOM lib). ui-tui also now declares its
@hermes/shared dependency explicitly instead of relying on workspace
hoisting.

* test(cli): pin nous_billing wire-layer status-to-exception mapping

The HTTP layer's error handling had zero coverage through _request:
only 2xx parsing and request shaping were tested, and the mapping cases
in test_remote_spending_gate_contract.py hit _raise_for_error directly.

Adds 19 tests driving _request via a monkeypatched urlopen: the
401-refresh-retry path (success, terminal plain/session_revoked,
idempotency-key preservation, base re-resolution), 403 variants through
the wire, 429/503 retry-after, non-JSON error bodies, 404/502
fallbacks, and URLError normalization.

Two behaviors are pinned as findings rather than fixed: a JSON-body
retryAfter hint is ignored unless the Retry-After header is present,
and a bare socket.timeout propagates uncaught (real urllib wraps
timeouts in URLError before this layer).

* fix(cli): normalize read-phase timeouts to the typed billing error

urlopen wraps connect-phase timeouts in URLError (already mapped to
network_error), but a timeout during resp.read() raises a bare
TimeoutError that escaped the typed-BillingError contract and reached
callers as an unhandled exception. Catch it narrowly and normalize.
The boundary test now asserts normalization instead of documenting the
leak.

* fix(shared): stop typing mutation success payloads as error payloads

BillingMutationResponse.payload was declared BillingErrorPayload, but on
ok:true the gateway passes through the raw NAS success body (rail,
changeType, cancelAtPeriodEnd, ...). The TUI never reads it so nothing
broke, but the shared contract now feeds the desktop app too — widen the
field deliberately and document both shapes.

* feat(shared): typed billing refusal and charge-failure unions

- BillingRefusalCode covers every code the gateway serializes today, with a
  (string & {}) arm so unknown future codes (the NAS W3 card-health family)
  stay assignable — consumers keep their unknown-code fallback.
- ChargeFailureReason models the four NAS terminal reasons plus the raw
  subscription_payment_intent_requires_action code NAS leaks pre-#711.
- billing.state now carries the server-derived can_change_plan the gateway
  emits; capability comments updated (canChangePlan is capability-based, not
  an OWNER/ADMIN role gate).

* feat(shared): closed Known* halves for the refusal and charge-failure unions

- KnownBillingRefusalCode / KnownChargeFailureReason are closed literal sets,
  so classification tables, copy maps and tests can be Record-exhaustive and
  break at compile time when a code is added but not mapped. The wire types
  keep the (string & {}) open arm for unknown future codes.
- Add network_error (client-originated transport code the gateway already
  serializes) to the known set.
- Export the union types from the root barrel alongside the other billing
  names.

* feat(shared): canonical billing refusal policy and charge-settlement driver

- billing-policy.ts: one exhaustive Record<KnownBillingRefusalCode,
  BillingRefusalPolicy> classifying every known code (recovery kind,
  mid-poll ambiguity, idempotency-key reuse) with a documented unknown-code
  fallback. Surfaces keep their own copy; the behavior classification now
  has a single home that breaks the build when a new code goes unmapped.
- charge-settlement.ts: the settlement poll state machine (2s cadence,
  5-minute cap, bounded retry-after backoff, ambiguous-on-revocation) as a
  pure dependency-injected driver returning a discriminated outcome.
- The TUI's pollCharge becomes a thin renderer over the shared driver —
  byte-identical output, and the desktop poller can now share the same
  machine instead of a drifting copy.

* refactor(shared): move terminal-billing wire types to @hermes/shared

The billing/subscription wire shapes (plus UsageBarData/UsageModelData,
which they reference) move verbatim from ui-tui/src/gatewayTypes.ts into
apps/shared/src/billing-types.ts so the desktop app can share the same
gateway contract. gatewayTypes.ts re-exports every moved name from the
new @hermes/shared/billing subpath, so no ui-tui consumer changes.

The subpath export keeps DOM-less ui-tui from pulling the barrel (whose
WebSocket helpers need the DOM lib). ui-tui also now declares its
@hermes/shared dependency explicitly instead of relying on workspace
hoisting.

* test(cli): pin nous_billing wire-layer status-to-exception mapping

The HTTP layer's error handling had zero coverage through _request:
only 2xx parsing and request shaping were tested, and the mapping cases
in test_remote_spending_gate_contract.py hit _raise_for_error directly.

Adds 19 tests driving _request via a monkeypatched urlopen: the
401-refresh-retry path (success, terminal plain/session_revoked,
idempotency-key preservation, base re-resolution), 403 variants through
the wire, 429/503 retry-after, non-JSON error bodies, 404/502
fallbacks, and URLError normalization.

Two behaviors are pinned as findings rather than fixed: a JSON-body
retryAfter hint is ignored unless the Retry-After header is present,
and a bare socket.timeout propagates uncaught (real urllib wraps
timeouts in URLError before this layer).

* fix(cli): normalize read-phase timeouts to the typed billing error

urlopen wraps connect-phase timeouts in URLError (already mapped to
network_error), but a timeout during resp.read() raises a bare
TimeoutError that escaped the typed-BillingError contract and reached
callers as an unhandled exception. Catch it narrowly and normalize.
The boundary test now asserts normalization instead of documenting the
leak.

* fix(shared): stop typing mutation success payloads as error payloads

BillingMutationResponse.payload was declared BillingErrorPayload, but on
ok:true the gateway passes through the raw NAS success body (rail,
changeType, cancelAtPeriodEnd, ...). The TUI never reads it so nothing
broke, but the shared contract now feeds the desktop app too — widen the
field deliberately and document both shapes.

* feat(shared): typed billing refusal and charge-failure unions

- BillingRefusalCode covers every code the gateway serializes today, with a
  (string & {}) arm so unknown future codes (the NAS W3 card-health family)
  stay assignable — consumers keep their unknown-code fallback.
- ChargeFailureReason models the four NAS terminal reasons plus the raw
  subscription_payment_intent_requires_action code NAS leaks pre-#711.
- billing.state now carries the server-derived can_change_plan the gateway
  emits; capability comments updated (canChangePlan is capability-based, not
  an OWNER/ADMIN role gate).

* feat(shared): closed Known* halves for the refusal and charge-failure unions

- KnownBillingRefusalCode / KnownChargeFailureReason are closed literal sets,
  so classification tables, copy maps and tests can be Record-exhaustive and
  break at compile time when a code is added but not mapped. The wire types
  keep the (string & {}) open arm for unknown future codes.
- Add network_error (client-originated transport code the gateway already
  serializes) to the known set.
- Export the union types from the root barrel alongside the other billing
  names.

* feat(shared): canonical billing refusal policy and charge-settlement driver

- billing-policy.ts: one exhaustive Record<KnownBillingRefusalCode,
  BillingRefusalPolicy> classifying every known code (recovery kind,
  mid-poll ambiguity, idempotency-key reuse) with a documented unknown-code
  fallback. Surfaces keep their own copy; the behavior classification now
  has a single home that breaks the build when a new code goes unmapped.
- charge-settlement.ts: the settlement poll state machine (2s cadence,
  5-minute cap, bounded retry-after backoff, ambiguous-on-revocation) as a
  pure dependency-injected driver returning a discriminated outcome.
- The TUI's pollCharge becomes a thin renderer over the shared driver —
  byte-identical output, and the desktop poller can now share the same
  machine instead of a drifting copy.
2026-07-18 19:38:02 +05:30
Siddharth Balyan 7668d289d6 Terminal-billing client hardening: shared wire types, wire-layer tests, dead RPC removal (#61067)
* refactor(shared): move terminal-billing wire types to @hermes/shared

The billing/subscription wire shapes (plus UsageBarData/UsageModelData,
which they reference) move verbatim from ui-tui/src/gatewayTypes.ts into
apps/shared/src/billing-types.ts so the desktop app can share the same
gateway contract. gatewayTypes.ts re-exports every moved name from the
new @hermes/shared/billing subpath, so no ui-tui consumer changes.

The subpath export keeps DOM-less ui-tui from pulling the barrel (whose
WebSocket helpers need the DOM lib). ui-tui also now declares its
@hermes/shared dependency explicitly instead of relying on workspace
hoisting.

* test(cli): pin nous_billing wire-layer status-to-exception mapping

The HTTP layer's error handling had zero coverage through _request:
only 2xx parsing and request shaping were tested, and the mapping cases
in test_remote_spending_gate_contract.py hit _raise_for_error directly.

Adds 19 tests driving _request via a monkeypatched urlopen: the
401-refresh-retry path (success, terminal plain/session_revoked,
idempotency-key preservation, base re-resolution), 403 variants through
the wire, 429/503 retry-after, non-JSON error bodies, 404/502
fallbacks, and URLError normalization.

Two behaviors are pinned as findings rather than fixed: a JSON-body
retryAfter hint is ignored unless the Retry-After header is present,
and a bare socket.timeout propagates uncaught (real urllib wraps
timeouts in URLError before this layer).

* fix(cli): normalize read-phase timeouts to the typed billing error

urlopen wraps connect-phase timeouts in URLError (already mapped to
network_error), but a timeout during resp.read() raises a bare
TimeoutError that escaped the typed-BillingError contract and reached
callers as an unhandled exception. Catch it narrowly and normalize.
The boundary test now asserts normalization instead of documenting the
leak.

* fix(shared): stop typing mutation success payloads as error payloads

BillingMutationResponse.payload was declared BillingErrorPayload, but on
ok:true the gateway passes through the raw NAS success body (rail,
changeType, cancelAtPeriodEnd, ...). The TUI never reads it so nothing
broke, but the shared contract now feeds the desktop app too — widen the
field deliberately and document both shapes.

* feat(shared): typed billing refusal and charge-failure unions

- BillingRefusalCode covers every code the gateway serializes today, with a
  (string & {}) arm so unknown future codes (the NAS W3 card-health family)
  stay assignable — consumers keep their unknown-code fallback.
- ChargeFailureReason models the four NAS terminal reasons plus the raw
  subscription_payment_intent_requires_action code NAS leaks pre-#711.
- billing.state now carries the server-derived can_change_plan the gateway
  emits; capability comments updated (canChangePlan is capability-based, not
  an OWNER/ADMIN role gate).

* feat(shared): closed Known* halves for the refusal and charge-failure unions

- KnownBillingRefusalCode / KnownChargeFailureReason are closed literal sets,
  so classification tables, copy maps and tests can be Record-exhaustive and
  break at compile time when a code is added but not mapped. The wire types
  keep the (string & {}) open arm for unknown future codes.
- Add network_error (client-originated transport code the gateway already
  serializes) to the known set.
- Export the union types from the root barrel alongside the other billing
  names.

* feat(shared): canonical billing refusal policy and charge-settlement driver

- billing-policy.ts: one exhaustive Record<KnownBillingRefusalCode,
  BillingRefusalPolicy> classifying every known code (recovery kind,
  mid-poll ambiguity, idempotency-key reuse) with a documented unknown-code
  fallback. Surfaces keep their own copy; the behavior classification now
  has a single home that breaks the build when a new code goes unmapped.
- charge-settlement.ts: the settlement poll state machine (2s cadence,
  5-minute cap, bounded retry-after backoff, ambiguous-on-revocation) as a
  pure dependency-injected driver returning a discriminated outcome.
- The TUI's pollCharge becomes a thin renderer over the shared driver —
  byte-identical output, and the desktop poller can now share the same
  machine instead of a drifting copy.
2026-07-18 07:05:58 -07:00
kshitijk4poor 8f657b8f74 test: add happy-path + no-markers coverage for autostash recovery
Port stronger test coverage from #52305 (avrum):
- Add test_install_sh_repository_stage_clean_apply_drops_stash: verifies
  non-conflicting restore still applies changes and drops the stash (no
  regression of the happy path).
- Add no-conflict-markers assertion to _assert_conflict_was_recovered:
  ensures <<<<<<< / >>>>>>> markers are never left in tracked source after
  recovery (they would crash the backend on import).
2026-07-18 19:18:03 +05:30
konsisumer de90f5831b fix(install): continue after autostash restore conflicts 2026-07-18 19:18:03 +05:30
Siddharth Balyan 21f971bbab fix(ci): null-safe files iteration in the paginated compare (#66867)
A PR more than 100 commits ahead of its merge-base paginates the compare
endpoint; pages after the first carry files: null, so the bare .files[]
made jq die with 'cannot iterate over: null' on every retry and forced
the classifier's fail-open path (all lanes on, ci_review gate demanding
a label with zero CI-sensitive files in the diff). .files[]? keeps the
page-one file list and ignores the null tail.
2026-07-18 19:17:09 +05:30
nousbot-engandgithub-actions[bot] 7fd419e5e6 fmt(js): npm run fix on merge (#66890)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-18 11:21:28 +00:00
Tranquil-Flow c7eb0cd22c fix(desktop): preserve dirty inline edits on blur 2026-07-18 04:15:22 -07:00
teknium1 e52c33cc9b chore: add contributor mapping for Bruce-anle (PR #64796 salvage) 2026-07-18 04:15:13 -07:00
Bruce-anle 56db7a5e6c test(tui): keep watchdog checks behavior-focused 2026-07-18 04:15:13 -07:00
Bruce-anle 68f7f15207 fix(tui): use direct parent identity in slash worker
Remove process creation time and pid_exists from the slash worker parent-death predicate. The worker remains attached while its original PPID matches and keeps the existing in-flight grace behavior.\n\nRefs #62505
2026-07-18 04:15:13 -07:00
Bruce-anle 3d031bdb29 fix(mcp): use direct parent identity in stdio watchdog
Use the direct POSIX parent relationship instead of process creation time and pid_exists checks. Remove the dead create-time argument chain while preserving process-group cleanup and signal forwarding.\n\nRefs #62505
2026-07-18 04:15:13 -07:00
Teknium 4c96172d9b test: stub discover_local_cdp_url in CLI connect context-note test
The dual-stack discovery change made the default-local /browser connect
path call discover_local_cdp_url instead of is_browser_debug_ready, so
the old is_browser_debug_ready patch no longer short-circuited the
probe. On the CI runner nothing listens on 9222, so the test fell
through to a REAL chromium launch (which dies headless:
'The platform failed to initialize') and no context note was queued.
Patch the new discovery helper at the mixin's import site instead.
2026-07-18 02:49:28 -07:00
Teknium d93c905808 fix: /browser connect times out when another app squats the CDP port
On Windows (and some Linux setups), an application like VS Code's
js-debug can hold 127.0.0.1:9222 while a Chromium browser launched
with --remote-debugging-port=9222 silently binds [::1]:9222 only.
The IPv4-only probe then (a) missed the live browser entirely and
(b) hung against the squatter — which accepts TCP but never answers
the /json/version HTTP probe — repeatedly, driving the whole connect
past the desktop GUI's RPC deadline:
'error: request timed out: browser.manage'.

Fix, applied to both the gateway browser.manage RPC and the CLI
/browser connect path via shared helpers in browser_connect.py:

- discover_local_cdp_url(): probe BOTH loopbacks (127.0.0.1 first,
  then [::1]) and adopt whichever actually speaks CDP.
- local_port_in_use() + find_free_debug_port(): when neither loopback
  speaks CDP but the port is held by another application, report the
  squatter explicitly and launch the debug browser on a nearby free
  port instead of fighting a bind conflict on 9222.
- Bound the gateway's post-launch wait to a 10s deadline (was up to
  20 unbounded probe cycles) so connect always answers inside the
  client RPC timeout.
- _wait_for_browser_debug_ready_or_exit() also probes dual-stack so a
  successful launch pushed onto [::1] is classified 'ready'.

Verified on a live Windows repro (VS Code holding 127.0.0.1:9222,
Chrome 148 on [::1]:9222): connect now resolves http://[::1]:9222
in ~4.5s instead of timing out.
2026-07-18 02:49:28 -07:00
teknium1 edfa4cd9b7 fix(cron): widen UTF-8 BOM tolerance to backup/curator jobs.json readers
Follow-up to the salvaged #66609 (4 primary readers) and #41604 (context
files): two more jobs.json readers rejected a BOM'd file —

- hermes_cli/backup.py _count_cron_jobs: a BOM made the count None,
  silently disabling the post-update cron-loss auto-restore safety net
- agent/curator_backup.py _backup_cron_jobs_into: BOM broke the job
  count (spurious parse_warning) and propagated the BOM into snapshots

Both now read utf-8-sig; curator snapshots are written BOM-free so
rollback restores a file load_jobs can read. AUTHOR_MAP entry added
for deacon-botdoctor.

Tests: BOM'd-live-file auto-restore + BOM'd snapshot count/BOM-free copy.
2026-07-18 02:31:20 -07:00
deacon-botdoctor f361232883 fix: tolerate UTF-8 BOM in cron jobs.json and context files 2026-07-18 02:31:20 -07:00
Paulo Nascimento 51e1fb8fb9 fix(cron): accept UTF-8 BOM when reading jobs.json
Windows Notepad and PowerShell 5.1 Set-Content -Encoding UTF8 write a
leading UTF-8 BOM. json.load under encoding=utf-8 raises
JSONDecodeError("Unexpected UTF-8 BOM"), and load_jobs wraps that as
RuntimeError("Cron database corrupted and unrepairable"), taking down
cron CRUD/scheduler for a hand-edited jobs.json.

Read with utf-8-sig on all four independent jobs.json readers
(load_jobs primary + strict=False repair, dump _cron_summary, status
Scheduled Jobs). Write path stays plain utf-8 so the next save_jobs
heals a BOM'd file. Matches the env-class dialect (#65123).

Tests: BOM load (crash repro), bomless regression, empty store,
BOM+bare-list auto-repair, BOM+control-char strict=False arm, dump and
status CLI readers.
2026-07-18 02:31:20 -07:00
teknium1 ddd34a98f3 fix(desktop): harden Windows sandbox fallback against false-positive sandbox loss
Follow-up to the salvaged #66803 (@HexLab98):

- Two-strike boot marker: a single mid-boot abort (task-manager kill,
  power loss) no longer disables the sandbox — only a second consecutive
  abort, or a signature-confirmed GPU/renderer STATUS_BREAKPOINT death,
  engages --no-sandbox.
- Version-scoped stickiness: the fallback marker records the app version
  and re-probes the sandbox once after an update (new Electron or
  installer ACL repair may have fixed the host) instead of degrading
  forever. A failed re-probe returns straight to fallback.
- Launch-time icacls repair now runs only when the marker shows a prior
  aborted boot (icacls /T recurses the whole install tree — healthy
  launches skip it; the installer grants the ACE at install time), and
  targets the install dir only. The userData grant is dropped: granting
  S-1-15-2-2 RX on userData would expose Hermes sessions/config to every
  AppContainer app on the machine.
- Renderer crash-loop recovery (same class as #56726, credit @Sahil-SS9
  in PR #57414): a Windows renderer crash loop bearing the breakpoint
  exit code gets the same one-shot --no-sandbox relaunch instead of a
  dead window; unrelated crash loops keep the sandbox.
- Manual --no-sandbox launches are honored but never made sticky.

Tests: 15/15 windows-sandbox-fallback vitest; full desktop electron
suite 432 passed / 1 skipped.
2026-07-18 02:26:19 -07:00
HexLab98 2b0c4d69ea test(desktop): cover Windows sandbox fallback marker and ACL helpers 2026-07-18 02:26:19 -07:00
HexLab98 e53d2dfccb fix(desktop): recover Windows GPU sandbox 0x80000003 startup crashes
Grant ALL APPLICATION PACKAGES RX on the unpacked app and stick a boot
marker so fatal Chromium sandbox deaths relaunch with --no-sandbox
(#38216).
2026-07-18 02:26:19 -07:00
teknium1 e5afc0d93b chore: add AUTHOR_MAP entry for juniperbevensee (PR #66650 salvage) 2026-07-18 02:08:39 -07:00
Juniper Bevensee fb0217c656 fix(agent): tolerate lone UTF-16 surrogates in tool-guardrail hashing
Tool results scraped from the web/social platforms can carry unpaired
UTF-16 surrogates (e.g. half of a mathematical-bold character pair).
_sha256() did a strict utf-8 encode, which raises UnicodeEncodeError on
that input and took down the whole conversation loop — the hash only
needs deterministic bytes, not valid UTF-8, so encode with
surrogatepass instead.
2026-07-18 02:08:39 -07:00
nousbot-engandgithub-actions[bot] 33300cdc7f fmt(js): npm run fix on merge (#66834)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-18 09:06:55 +00:00
Siddharth Balyan b51fbc738b feat(tui+cli): change your Nous plan from the terminal (/subscription, /topup, terminal-billing UX) (#51639)
* feat(tui): rename /billing slash command to /topup

Behavior-preserving rename of the /billing command surface to /topup.
Changes: billing.ts → topup.ts (export topupCommands, name 'topup', new
help string), registry.ts import+spread updated, billingOverlay.tsx
overview header 'Usage credits' → 'Top up credits', billingCommand.test.ts
→ topupCommand.test.ts with import/lookup/call updated. RPC method names
(billing.state, billing.charge, etc.) and component/symbol names unchanged.

* refactor(tui): extract overlay primitives to shared module

Lift MenuRow, ActionRow, footer, and barCells() out of billingOverlay.tsx
into overlayPrimitives.tsx so the upcoming subscriptionOverlay.tsx can
import them instead of duplicating. spendBar now calls barCells() —
output is byte-identical. Pure behavior-preserving refactor.

* feat(tui): add /subscription + /topup CTAs to /usage output

Every /usage render now ends with 'Run /subscription to change plan
· /topup to add credits' — both the healthy (with-calls) and depleted
(no-calls) paths. Strings-only change, no WS1 dependency.

* feat(tui): add subscription wire types

Add SubscriptionTierOption, SubscriptionStateResponse, and
SubscriptionManageLinkResponse to gatewayTypes.ts. Type-only — no
usages yet. Mirrors the BillingStateResponse conventions (snake_case,
Decimals as strings) and reuses BillingErrorPayload for error mapping.

* feat(gateway): add subscription.state + subscription.manage_link RPCs

- agent/subscription_view.py: SubscriptionState dataclass + fail-open
  build_subscription_state() (mirrors billing_view pattern) +
  get_subscription_manage_link() for the Stripe deep-link.
- hermes_cli/nous_billing.py: get_subscription_state() +
  post_subscription_manage_link() HTTP helpers for the two NAS endpoints
  (WS1 Phase A/C). The manage-link endpoint raises BillingScopeRequired
  when Remote-Spending is missing (Phase 4 step-up trigger).
- tui_gateway/server.py: _serialize_subscription_state() +
  subscription.state RPC (fail-open) + subscription.manage_link RPC
  (returns {ok,kind,url} or typed error envelope via
  _serialize_billing_error). NOT added to _LONG_HANDLERS — synchronous
  HTTP round-trip, not a device flow.

* feat(tui): add subscription overlay state types + store slot

Add SubscriptionScreen, SubscriptionOverlayCtx, SubscriptionOverlayState
to interfaces.ts and a 'subscription' slot to OverlayState. Wire it into
overlayStore.ts (buildOverlayState + $isBlocked). NOT added to
resetFlowOverlays preserve list — flow-scoped like billing, drops on
turn end.

* feat(tui): build SubscriptionOverlay — overview + confirm + handoff

Pure-render Ink component mirroring billingOverlay.tsx's structure.
Overview screen covers all 5 states (free-upgradeable, mid-tier,
top-tier, not-admin, downgrade-pending) + dunning. Confirm screen is
y/n deep-link to Stripe (NO in-terminal charge). Handoff is the
transient 'Opening Stripe' screen. Imports shared primitives from
overlayPrimitives.tsx. 8 render tests via renderSync covering every
state.

* feat(tui): add /subscription command + overlay wiring

- subscription.ts: SubscriptionOverlayCtx closure (openManageLink,
  refreshState, requestRemoteSpending) + run handler that fetches
  subscription.state and opens the overlay. Alias /upgrade.
- registry.ts: spread subscriptionCommands into SLASH_COMMANDS.
- appOverlays.tsx: render SubscriptionOverlay when overlay.subscription set.
- useInputHandlers.ts: Esc closes subscription overlay; promptOverlay OR
  includes subscription so input is intercepted while open.
- subscriptionCommand.test.ts: 4 tests (fetch+open, logged-out sys line,
  /upgrade alias, /subscription resolves).

* fix(tui/subscription): stop saying Stripe in deep-link copy + fix manage link kind type

Replace all user-facing 'Stripe' mentions in the /subscription overlay and
sys messages with 'your subscription page' — the deep-link target is NAS's
own /manage-subscription page, not the Stripe hosted portal. Stripe only
legitimately appears later at actual Checkout. Also add 'manage' to the
SubscriptionManageLinkResponse.kind union (NAS emits kind:'manage'; was
previously missing from the TypeScript type causing silent narrowing errors).

* feat(tui/subscription): render cancellation-scheduled note with headline precedence

Parse cancelAtPeriodEnd + cancellationEffectiveAt from the NAS contract
(camelCase) in the agent parser (_parse_current), emit cancel_at_period_end
+ cancellation_effective_at from the gateway serializer, extend the
SubscriptionStateResponse type, and render a warn note in OverviewScreen:
'Cancels on {date} — your plan stays active until then.'

Headline precedence when multiple flags co-occur:
  past-due > cancel-scheduled > downgrade-pending > active
The downgradeNote guard is tightened to suppress when cancel is scheduled,
so at most one status line renders at a time.

* feat(tui/subscription): team-context screen — redirect to /topup for team orgs

Parse the NAS context:'personal'|'team' field (defaults to 'personal' for
unknown/missing values), emit it on the gateway wire, add it to
SubscriptionStateResponse. When context is 'team', SubscriptionOverlay
renders a dedicated read-only screen instead of the tier picker:

  'This terminal is connected to {org_name}. Teams run on shared
   credits — use /topup to add funds. Personal subscriptions live
   on your personal account.'

The screen closes on Enter or Esc. The personal/tier-picker path is
unchanged.

* fix(subscription): drop manage-link gateway RPC, build URL locally

The NAS POST /api/billing/subscription/manage-link endpoint was dropped
(it added no server work — the target is the static /manage-subscription
page, not a Stripe-minted secret). Build the URL client-side instead:
{portal_base}/manage-subscription?org_id=<org.id>.

- Remove subscription.manage_link gateway RPC (server.py)
- Remove get_subscription_manage_link helper (subscription_view.py)
- Remove post_subscription_manage_link (nous_billing.py)
- Remove SubscriptionManageLinkResponse type (gatewayTypes.ts)
- Add org_id to SubscriptionState + wire through serializer + TS type
- openManageLink() builds the URL locally via buildManageUrl(), opens
  it with the existing openExternalUrl(), no gateway round-trip
- Drop targetTierId param from openManageLink (v1 sends everyone to
  /manage-subscription; no tier deep-link needed)
- Fix stale test expectations (Stripe copy → subscription page copy)

* chore(subscription): drop unused format_money import

* feat(cli): /subscription + /upgrade, /billing→/topup rename, /usage CTAs

Add the classic-CLI half of the terminal billing surface to match the TUI:
- /subscription (alias /upgrade) command + /topup (renamed /billing, keeps
  'billing' as a back-compat alias) in the command registry.
- Drop the stale 'billing' entry from _SLACK_VIA_HERMES_ONLY (now cli_only).

* feat(subscription): CLI /subscription handler, drop dunning, current:null no-plan

- CLI _show_subscription mirrors the TUI overlay (plan read + tier list + usage
  bar + browser deep-link via subscription_manage_url); credits render as counts.
- Adapt to the updated NAS read contract: remove is_past_due/dunning everywhere
  (a card-failing subscriber returns as a normal plan now), and treat no-plan as
  current:null (parser returns None) rather than an all-null object.
- HERMES_DEV_SUBSCRIPTION_FIXTURE env-driven fixtures + ui-tui fixture harness
  drive every state (CLI + live TUI) with no portal.

Verified against handoff 2026-06-24_subscription-tui-handoff.md.

* feat(billing): CF-4 Remote-Spending revoked-terminal UX (NAS PR #481)

Wire the Remote-Spending gate denial contract end to end:
- nous_billing: BillingRemoteSpendingRevoked (403 remote_spending_revoked →
  reconnect) + BillingSessionRevoked (401 session_revoked → re-login), distinct
  from insufficient_scope; capture actor/code/recovery; 503 stays transient.
- gateway _serialize_billing_error threads the new typed kinds + actor/code/
  recovery to the TUI.
- TUI renderBillingError: actor-aware revoke copy, kills the spend overlay
  immediately (no 15-min zombie button), handles session_revoked, the dual-
  emitted cli_billing_disabled/remote_spending_disabled, role_required,
  idempotency_conflict; poll treats a mid-poll revoke as ambiguous (check
  balance before retry), not a failure.
- CLI _billing_render_charge_error: same denial matrix, actor-aware copy.

Tests: gate-contract mapping + envelope (py) and revoke/session/disabled (TUI).
Per handoff 2026-06-24_remote-spending-TUI-contract-handoff.md.

* refactor(subscription): remove dead step-up scaffolding from /subscription

/subscription only opens a browser deep-link to manage-subscription — that needs
no billing scope, so it can never hit insufficient_scope. Drop the never-fired
'stepup' screen type, requestRemoteSpending ctx fn, and resumeScreen bookkeeping
(leftovers from a superseded plan). The resumable step-up lives on /topup, where
the charge actually gets gated.

* feat(tui/topup): resumable 'Allow Remote Spending' step-up on the charge path

Phase 4: when a charge returns insufficient_scope, the /topup modal no longer
tears down with a 'run /billing again' ConfirmReq. Instead it stays MOUNTED and
switches to a step-up screen:
- charge() is now awaitable, returning a discriminated outcome (submitted |
  needs_remote_spending | error) so the overlay can route without closing.
- StepUpScreen: 'Allow Remote Spending' → await the device-flow grant (browser
  opens via the existing out-of-band billing.step_up.verification event) →
  replay the held charge (pendingCharge.amount) and settle, with no command
  re-run. Never surfaces the raw billing:manage scope.
- armStepUp's fire-and-forget ConfirmReq replaced by requestRemoteSpending();
  the leaky 'billing:manage' / 'Re-authorize' / 'run /billing again' copy is gone.

Tests: charge-outcome routing, step-up grant/deny, and a render test asserting
the step-up copy holds the amount and never leaks billing:manage.
Per handoff 2026-06-24_remote-spending-TUI-contract-handoff.md §2 (Grady #6).

* feat(billing): shared dollar usage model + two-bar view (drop "credits")

Single source of truth for the /usage and /subscription usage bars across
TUI + CLI. Reads the NAS account-info dollar fields (subscription/top-up/total
remaining, monthly allowance, renewal) and produces a surface-agnostic model:
two full-resolution bars (plan allowance + purchased top-up), a status
classification (free | healthy | low | depleted), and a human renewal date.

- agent/billing_usage.py: UsageModel/UsageBar, usage_model_from_account
  (fail-open), build_usage_model (HERMES_DEV_CREDITS_FIXTURE-aware),
  format_renews (ISO -> "Jul 24, 2026", Windows-safe), $5 low-balance threshold.
- tui_gateway/server.py: _serialize_usage_model/_serialize_usage_bar, a
  usage.bars RPC, and the model embedded into subscription.state so the overlay
  renders the same bars from its single fetch.
- Dollars only, never "credits"; two separate bars (not a crammed
  three-segment one) for legibility at terminal widths.
- tests/agent/test_billing_usage.py: status classification, bar math
  (clamp/over-cap), NaN/Inf rejection, fail-open invariants.

* feat(tui): dollar usage bars on /usage + /subscription, drop tier picker

Render the shared two-bar dollar model in both overlays; strip "credits" and
the in-terminal tier selection per UX feedback.

- overlayPrimitives.tsx: UsageBars (themed plan/top-up bars — gold allowance,
  green top-up) + usageBarsText for the /usage panel. Plan name labels the
  bar; "$X left of $Y · N% used" (disambiguated so the % matches); top-up
  "never expires".
- subscriptionOverlay.tsx: status line dedupes ($X left once; bar carries the
  breakdown), human renewal date, state-matched nudges (free upsell / <$5
  low alert) with box-safe ASCII markers (! / >) instead of the width-unstable
  emoji that broke the border. Tier picker removed — overview shows usage +
  plan, then "Manage on portal" / "Close" (free users get "Start a
  subscription"). No "credits" anywhere.
- session.ts: /usage renders the dollar bars + balance summary, falling back
  to the legacy credits lines only when the model is unavailable; CTA reworded.
- gatewayTypes.ts: UsageModelData/UsageBarData wire types + usage on
  SessionUsageResponse/SubscriptionStateResponse.
- Tests updated to the new contract (no "credits", "left of", dedup, markers).

* feat(cli): mirror dollar usage bars on /usage + /subscription

CLI parity with the TUI billing rework, from the same shared usage model.

- _print_nous_credits_block (/usage) and _subscription_overview render the
  two-bar dollar view (plan name on the bar, "$X left of $Y · N% used",
  top-up "never expires", total spendable) instead of the credits-worded block.
- Dollars only — dropped the tier catalog (no more "$N/mo (… credits)") and
  every user-facing "credits"; team copy says "shared balance".
- Human renewal date via the shared format_renews; status line dedupes the
  "$X left"; free upsell + <$5 low alert with ASCII markers.
- /subscription manage modal no longer dumps the raw manage-subscription URL
  in its detail — the [1] Open / [2] Copy link / [3] Cancel options carry it.
  Title is "Manage your subscription" (no in-terminal plan change). The raw URL
  stays only in the non-interactive / not-admin fallbacks, which have no menu.
- /usage token-usage panel (model, tokens, cost, context) left untouched.

* feat(billing): embed dollar usage model into billing.state for /topup

The /topup overview renders the same two-bar dollar usage (plan + top-up) as
/usage and /subscription. Embed the shared usage model into the billing.state
RPC payload (mirrors subscription.state) so the overlay gets the bars from its
single fetch, and add the `usage` field to BillingStateResponse.

* feat(tui/topup): reorder overview + in-flight reauth with press-Enter resume

Reworks the /topup overlay per the Jun 19 review and the no-preflight decision.

Overview:
- Balance leads in the title ("Top up · balance $X"); the shared two-bar dollar
  usage (plan + top-up) renders below. Dropped the old monthly-cap spend bar.
- "Add funds" is the first action (was "Buy credits"); auto-reload / monthly
  limit / manage-on-portal follow. Dollars only — no "credits" anywhere.
- No "Enable terminal billing" menu item and NO scope preflight: whether the
  terminal can charge is discovered reactively at pay time. (We deliberately do
  not read/refresh the OAuth token to gate UI.)

Step-up (reached only on a charge's insufficient_scope 403):
- New 4-phase flow that keeps the modal mounted: prompt (one-time-setup
  heads-up) → waiting (browser authorize) → granted (explicit "Press Enter to
  resume") → replay the held charge → settle. The press-Enter beat is the
  reassuring "you're back, finish your purchase" moment.
- Renamed user copy "Allow Remote Spending" → "Enable terminal billing"; never
  leaks the raw billing:manage scope (guarded by the render test).
- topup.ts error copy de-crufted to terminal-billing wording, emoji removed.

Tests: step-up prompt copy, the no-raw-scope invariant, and new overview tests
(balance-in-title, Add-funds-first, two-bar usage, no "credits").

* feat(cli/topup): mirror overview reorder + in-flight reauth resume

CLI parity with the TUI /topup rehaul, from the same shared usage model.

- _billing_overview: balance in the title, the two-bar dollar usage (plan name
  on the plan bar, top-up "never expires") in place of the old cap spend bar,
  "Add funds" first, dollars throughout — no "credits", no scope preflight.
- _billing_handle_scope_required: now takes the held amount + idempotency key
  and runs the in-flight flow — "Enable terminal billing" → browser device-flow
  → re-check the org kill-switch → press-Enter to resume → replay the held
  charge (reusing the key so a double-submit collapses to one). Stops leaking
  the raw billing:manage scope.
- Charge-error + buy/auto-reload copy de-crufted to terminal-billing/dollars.
- Tests updated to the new overview + buy copy.

* fix(billing): guard non-JSON 2xx responses in the billing HTTP client

A 2xx response with a non-JSON body — e.g. a reverse-proxy / SPA fallback HTML
page served when a billing route isn't actually mounted on a deployment — hit
json.loads() on the success path of _request() and raised a raw
json.JSONDecodeError. That escaped the typed-BillingError contract, so callers'
`except BillingError` missed it and fell through to a generic fail-open that
rendered as a misleading "not logged in" (observed when /api/billing/subscription
was briefly unshipped on staging: 200 text/html, x-matched-path /[...notFound]).

Now a non-JSON 2xx body raises a typed BillingError(error="endpoint_unavailable")
so surfaces degrade gracefully ("could not load …") instead of crashing or
mislabeling a valid session as logged-out. The 4xx/5xx path already guarded its
.json(); this closes the same hole on the success path.

Test: tests/hermes_cli/test_nous_billing_request.py — non-JSON 2xx → typed
error (not JSONDecodeError, not BillingAuthError), empty body → {}, valid JSON
parses.

* feat(billing/dev): add HERMES_DEV_BILLING_FIXTURE for offline card/scope testing

build_billing_state short-circuits to a fixture when HERMES_DEV_BILLING_FIXTURE
is set (mirrors HERMES_DEV_CREDITS_FIXTURE for the usage model). States:
nocard | card | card-autoreload | notadmin | billing-off | logged-out — so the
card-on-file gate, admin role, and kill-switch paths are exercisable offline
without a live portal. Env-var gated; returns None when unset (no prod leak).

Adds 8 behavior tests asserting the card/admin/billing-on contract per state.

* refactor(billing): fold /credits into /topup

/credits is redundant now that /topup shows the dollar balance + portal handoff.
Make 'credits' (and 'billing') aliases of /topup so typing /credits still works,
resolving to topup everywhere (CLI, gateway, Slack, TUI, autocomplete, help).

Remove the standalone /credits surface across 6 places:
- CLI _show_credits handler + dispatch
- gateway _handle_credits_command -> renamed _handle_topup_command, copy softened
  to 'Manage billing on the portal' (the messaging billing surface; /topup is now
  gateway-available so messaging keeps billing — credits was the only one before)
- TUI commands/credits.ts + creditsCommand.test.ts (deleted), registry entry
- tui_gateway credits.view RPC + the CreditsViewResponse type
- Slack _SLACK_VIA_HERMES_ONLY: credits -> topup

Sweep user-facing /credits -> /topup (usage-block hint, depletion notice) and
stale doc-comments. OpenRouter's /credits endpoint URL left untouched. Tests
updated (test_credits_folds_into_topup) or pruned for the removed symbols.

* fix(billing): card-on-file heads-up, no-card portal gate, /usage bar ordering, modal glyph

In-terminal charge (POST /charge against the org's server-held card, no card ref
leaves the client):
- card present: confirm screen shows 'Your card saved on the portal will be
  charged' + a 'Manage on portal' escape option (CLI); heads-up line (TUI)
- no card on file: /topup overview + buy flow detect it and route to the portal
  to add a card, instead of offering a charge that 403s no_payment_method

/usage bar ordering: route the dollar block through _cprint consistently. The
Plan: line (_cprint) and the bar (raw print) flushed to different buffers under
patch_stdout and interleaved nondeterministically; now Plan: -> bar -> status/CTA
is stable across all states.

Modal glyph: strip the leading emoji from bordered _prompt_text_input_modal
titles — it measures 1 char but renders 2 columns, shifting the box's right
border (the stray '|'). Includes the f-string 'Pay $X?' title.

Small /credits -> /topup string bits in cli.py ride along with the surrounding
charge edits (the fold lives in the sibling refactor commit).

* refactor(billing): apply safe simplify-pass fixes

Three low-risk cleanups from a parallel simplify review (reuse/quality/efficiency):
- dev fixture portal URL: reuse the prod host (was drifted to staging-* — a real
  mismatch vs subscription_view's _DEV_FIXTURE_PORTAL)
- TUI billingOverlay choose(): collapse two byte-identical branches (needsCard +
  the not-full else both = portal-or-close at index 0) into one tail; the only
  divergent path (full && !needsCard → buy/auto/limit) stays explicit
- /topup overview comment: correct the stale 'buy_flow detects no_payment_method'
  note (the overview's no-card gate fires first, so reaching Add funds implies a
  card on file)

Skipped (judgment): the orphaned CreditsView.depleted field (harmless, on a live
dataclass), the defensive card gates in _billing_buy_flow/_confirm_and_charge
(cheap correct defense on the money path), and folding the no-card handoff into a
shared helper (touches 4 money-path sites for tidiness — not worth the risk here).

* fix(billing): reactive charge gating — drop card preflight, react to 403 (scope→reauth, no-card→portal)

* refactor(billing): drop the /credits alias entirely

The /credits fold made it an alias of /topup; now remove that too. Typing
/credits is an unknown command, not a silent redirect — billing lives only on
/topup (with /billing kept as the old command's back-compat name). Dropped the
alias from the registry CommandDef and the TUI topup.ts; updated the test to
assert /credits resolves to nothing (no command, no alias).

* docs(billing): fix stale comment in _billing_overview — describe reactive no-card path

The comment still described the removed overview-level card gate ('no-card case
handled above'). Corrected to: the buy flow reacts to the server's
no_payment_method 403 and hands off to the portal at charge time (no preflight).

* refactor(billing): simplify-pass — share usage-payload helper, drop dead bar wire fields + redundant admin gate

* refactor(billing): drop the /billing alias too — /topup is the only billing command

Following /credits removal, retire the old /billing name as well. /topup now has
NO aliases — both /credits and /billing are unknown commands. Dropped the alias
from the registry CommandDef and TUI topup.ts; fixed the one live user-facing
straggler (the not-logged-in message said 'then /billing' → /topup) and the
_show_billing docstring/default-arg references. Test asserts /topup carries no
aliases and neither old name resolves.

* fix(billing): code-review fixes — money-path + parity bugs

Money path (TUI):
- auto-reload "Turn off" now echoes current threshold/top_up_amount so the
  PATCH succeeds (was sending {enabled:false} → invalid_request → stayed ON)
- charge poll honors the 5-min cap on the 429/503 throttle branch too (was
  rescheduling forever); cap folded into one timedOut() helper
- step-up resume reacts to the replay outcome instead of unconditionally
  closing on a reassuring line with no charge made
- synchronous submit guard on Confirm so two key events can't double-charge

Gateway:
- billing.step_up routes typed errors through _serialize_billing_error (was a
  raw {error:'error'} dict → generic copy for session_revoked)
- billing.state / subscription.state / usage.bars / session.usage moved to
  _LONG_HANDLERS (blocking portal HTTP no longer stalls the main stdin loop)

CLI:
- _billing_render_charge_error handles insufficient_scope without leaking the
  raw billing:manage scope name on a post-grant replay re-raise

Python model:
- subscription_view tier parse None-coalesces tierOrder/dollarsPerMonth so a
  free tier's 0 survives ($0, not "—"; correct sort order)

TUI parity/robustness:
- /usage shows formatted renews_display, not raw ISO renews_at
- subscription overview guards a null pending_downgrade_at (was "on null.")
- subscription overview surfaces a message instead of silently closing when
  portal_url is missing
- buildManageUrl wraps new URL() so a malformed portal_url can't throw out of
  the Ink key handler

* fix(billing): cross-surface bar direction, formatted cancel/downgrade dates, Slack alias gating

- CLI plan bar now fills by REMAINING (fuel-gauge), matching the shared model's
  fill_fraction, the top-up bar, and the TUI — same account renders identically
  on both surfaces (#8)
- subscription serializer emits cancellation_effective_display /
  pending_downgrade_display (format_renews); TUI shows 'Jul 1, 2026' not raw ISO (#14b)
- _SLACK_VIA_HERMES_ONLY now includes the 'billing' alias so it follows its
  canonical /topup via /hermes instead of leaking a native Slack slot (#9)

* fix(billing): thread idempotency key through the TUI step-up replay (#2)

Mint a stable idempotency key when the purchase amount is chosen; it rides
pendingCharge into both the Confirm charge and the post-grant step-up replay,
so a retried charge dedups server-side (the gateway already echoes the key).
A fresh amount selection gets a fresh key. Combined with the sync submit guard,
a double-submit now collapses to one charge.

* refactor(billing): remove dead /subscription tier-picker scaffolding (#18)

The in-terminal plan picker was cut (deep-link only), leaving a whole unreached
state machine. Removed end-to-end:
- TUI: ConfirmScreen, HandoffScreen, the 'confirm'/'handoff' screen types,
  pendingTargetTierId, and the now-dead onPatch threading (collapsed the dispatch
  to a single overview screen + folded the duplicate Box wrapper)
- gateway: the tiers serialization + SubscriptionTierOption wire type
- model: SubscriptionTier, _parse_tier, _coalesce, _dev_tiers and the tiers field
  (never displayed on either surface, so this supersedes the tier-parse fix)
- tests: dropped the confirm/handoff/tier-passthrough tests; slimmed the overview
  render tests

Net: a large dead-code cull (no behavior change — the picker never ran).

* test(billing): parametrize usage-model tests; drop dead is_low/is_free props

Collapse the fail-open + status-classification cases into parametrized tables
(same coverage, ~80 fewer lines) and remove the now-unused UsageModel.is_low /
is_free properties (only a test pinned them).

* fix(billing): revert dead 'billing' Slack-via-hermes entry — the alias was dropped

#9 was based on a stale review diff: /billing is no longer an alias of /topup
(dropped earlier), so routing it via /hermes filtered a name that doesn't exist.

* test(billing): cull redundant TUI billing tests (parametrize, merge dupes)

usageCommand: collapse 3 CTA tests into one + a panel helper.
billingStepUp: merge the two step-up render asserts.
topupCommand: parametrize requestRemoteSpending + the revoked-actor pair, drop
the redundant happy-path-submitted test. Money-path + error-mapping coverage
preserved.

* refactor(billing): extract _usage_bar_lines — one source of truth for the CLI bars

The plan + top-up bar format was copy-pasted across _print_nous_credits_block,
_subscription_overview, and _billing_overview. Extract a helper returning the
ready-to-print lines; each caller keeps its own print fn (the _cprint-ordering
constraint stays) and resolves its plan-name label. Centralizes the format so
the three surfaces can't drift.

* feat(billing): NAS V3 subscription-change HTTP client wrappers

Add the four write-side wrappers for the V3 subscription contract to nous_billing,
each a thin _request() call (reusing auth, JSON, 401-retry, typed errors):
- post_subscription_preview      → POST  /subscription/preview      (chargeless quote)
- put_subscription_pending_change→ PUT   /subscription/pending-change (downgrade/cancel)
- delete_subscription_pending_change → DELETE .../pending-change      (resume/undo)
- post_subscription_upgrade      → POST  /subscription/upgrade        (the money route)

pending-change takes a discriminated body (tier_change | cancellation); upgrade
requires an Idempotency-Key (mandatory, validated client-side before any I/O).
Tests assert the exact method/path/body/header each wrapper puts on the wire.

* feat(billing): subscription tier catalog + change-preview models

Reinstate the catalog the in-terminal picker needs (was culled when /subscription
was deep-link-only): SubscriptionTier + SubscriptionState.tiers + _parse_tier, with
_coalesce so the free tier's 0 tierOrder/price survives a falsy-or. Parse the
catalog from GET /subscription's tiers and seed _dev_tiers into every fixture.

Add SubscriptionChangePreview + subscription_change_preview_from_payload for the
POST /preview quote (effect/amountDueNowCents/effectiveAt/reason + tier delta); a
malformed/missing effect fails safe to 'blocked' so a bad quote never reads as a
charge. Module docstring updated: the overlay is no longer deep-link-only.

* feat(billing): gateway RPCs for the V3 subscription change flow

Add subscription.preview / .change / .resume / .upgrade RPCs, each wrapping its
nous_billing call and reusing _serialize_billing_error for the typed envelope
(so a 403 still drives the device step-up). upgrade mints + echoes the
idempotency key and surfaces status + recovery_url so the TUI can route an
SCA/decline to the portal. Re-add the tier catalog to _serialize_subscription_state
(price pre-formatted) for the picker. All four are pool-routed (_LONG_HANDLERS) —
preview + upgrade hit Stripe and must not stall the main stdin loop.

* feat(billing): in-terminal subscription change flow (TUI)

/subscription is no longer deep-link-only: it drives the change in-terminal
against the V3 contract via the new gateway RPCs. The overlay is a state machine
overview → picker → confirm → result:
- picker lists the tier catalog with upgrade/downgrade hints (current + free
  excluded; free=cancel, on the overview);
- confirm shows the previewed effect — pay $X now (upgrade) / scheduled at date
  (downgrade) / cancel at period end / blocked-with-reason — then applies it;
- an upgrade's SCA/decline routes to the portal via the result screen's recovery
  link; resume/cancel/downgrade are chargeless.

Starting a NEW subscription still deep-links (needs a fresh card). insufficient_scope
points to /topup (the step-up stays there, not duplicated here). Adds the wire
types (tiers + preview/upgrade responses), widens the overlay ctx + screen state,
and threads onPatch. Render tests cover every screen.

* feat(billing): in-terminal step-up + clearer scheduled-change UX (TUI)

Two improvements to the /subscription overlay:

Step-up re-auth in place. When a mutation (preview/change/upgrade/resume) returns
insufficient_scope, route to a new 'stepup' screen that grants terminal billing
via billing.step_up and AUTO-REPLAYS the held action on grant — no bounce to
/topup. Scope routing is centralized in previewAndRoute/applyPendingAndRoute/
resumeAndRoute (shared by the picker, confirm, overview + the step-up replay). The
browser opens via the shared global verification handler; copy never leaks the raw
billing:manage scope.

Make a scheduled change unmissable. A downgrade/cancel was one buried warn line
that read as 'nothing happened'. Now the overview leads with a banner
( Scheduled change · Ultra ──▶ Plus · <date> · you keep Ultra until then), the
status line echoes the transition (Plan: Ultra → Plus), 'Keep <tier> (undo)' is
promoted to the first olive action, the result screen says 'your plan doesn't
change today', and confirm gets a charged-now / scheduled chip.

* feat(billing): full in-terminal subscription change flow in the classic CLI

Bring the CLI to parity with the TUI overlay — /subscription is no longer
deep-link-only. A paid admin/owner gets picker → preview → confirm → apply,
mirroring the /topup buy flow's modal idioms:
- _subscription_change_menu (change / undo-or-cancel / manage-on-portal),
- _subscription_pick_tier (catalog with upgrade/downgrade hints),
- _subscription_preview_and_confirm (POST /preview → effect-aware confirm),
- _subscription_apply (schedule / cancel / resume chargeless; upgrade charges
  the sub's card, SCA/decline → portal),
- _subscription_handle_scope_required (insufficient_scope → step_up_nous_billing_scope
  inline, then replays the held preview/mutation — reusing the upgrade idempotency key).

Also the scheduled-change UX fix: the overview leads with a prominent banner
( Scheduled change · Super ──▶ Plus · <date> · you keep Super until then) and the
status line echoes the transition, matching the TUI. Members / non-interactive /
free still deep-link. Tests drive every branch via a mocked modal + nous_billing.

* fix(billing): close TUI subscription money-path holes (ultracode review)

- Un-consented charge (P1): the step-up now HOLDS at a 'granted' phase requiring
  an explicit Continue, and an abortedRef gates the grant's late .then — a cancel
  during the browser flow can no longer replay the held upgrade + charge.
- Missing idempotency key (P2): mint it when building an upgrade 'pending' so it
  rides into confirm AND the step-up replay (was always undefined → gateway minted
  a fresh key per call, defeating dedup).
- Navigate-away re-charge (P2): confirm 'back' is guarded by submittingRef while an
  apply is in flight.
- Ambiguous charge (P2): a transport-null upgrade is reported as 'may or may not
  have charged — re-check', never a flat failure that invites a blind retry.
- Typed step-up denial (P2): requestRemoteSpending returns {granted,error,message};
  the screen maps session_revoked / remote_spending_revoked / rate_limited to the
  right recovery instead of always 'an admin must allow it'.

* fix(billing): close CLI subscription money-path holes (ultracode review)

- Bounded step-up (P2): bust the 30s token cache after a grant (it held the
  pre-grant unscoped token; _request only busts on 401, not 403) and replay ONCE
  with allow_stepup=False so a still-denied scope can't re-prompt/re-open in a loop.
- Stray-keystroke charge (P3→near-P2): the upgrade confirm defaults to 'Go back',
  not 'Pay ' — a bare Enter can't move money.
- Fail-open on unknown effect (P3→near-P2): an unrecognized preview effect now
  fails SAFE (portal hand-off) instead of scheduling a real PUT.
- 'cancel' word collision (P3): the Close row uses value 'close' so typing 'cancel'
  can't hit it and falsely report 'Cancelled'.
- blocked effect re-offers the portal; undo is promoted to the first row when a
  change is pending (TUI parity).

* fix(billing): guard the step-up resume against double-fire (2nd ultracode pass, BUG A)

The P1 fix split the auto-replay into a user-triggered resume() on the granted
screen, where the default row is the charging action — but resume() had no
re-entrancy guard, so a double-Enter fired two replays (the upgrade dedups on the
shared key, but schedule/cancel/resume replays carry none → duplicate PUT/DELETEs).
Mirror billingOverlay.resume(): flip to a 'resuming' phase + a resumingRef so it
fires at most once, and block 'back' once resuming (no re-mount → no second submit).

* fix(billing): CLI charge-route ambiguous-charge caveat (2nd ultracode pass, BUG B)

The TUI hardened upgradeResult(null) but the CLI charging route did not: a
transport/timeout/500 (or unknown 2xx status) on post_subscription_upgrade — after
NAS may have already prorated + charged — printed a flat failure, and a manual
re-run mints a FRESH idempotency key the server can't dedup → a real second charge.
Now the charge route reports 'your card may or may not have been charged — re-run
/subscription to check before trying again' and steers away from a blind retry
(the CLI can't persist the key across a command re-run). Also thread allow_stepup
through the preview→apply replay (BUG C.1) and route the requires_action/
payment_failed portal lines through _cprint for deterministic ordering.

* fix(billing): cap the TUI step-up replay to avoid a resume-deadlock (final pass, R1)

The round-2 resume guard ('resuming' phase + resumingRef) could deadlock: on a
REPEAT insufficient_scope during the post-grant replay, the route helpers did
onPatch({screen:'stepup'}) — a no-op since we're already mounted on stepup (no key
→ no remount) — leaving phase='resuming'/resumingRef=true frozen on 'Applying your
change…'. Thread allowStepUp through previewAndRoute/applyPendingAndRoute/
resumeAndRoute; the resume() replay passes false, so a repeat scope denial surfaces
a 'still isn't enabled' result instead (mirrors the CLI's allow_stepup=False cap).
Also: applyPendingAndRoute(pending=null) now routes to overview, not a stranded
Promise.resolve().

* fix(billing): narrow the CLI ambiguous-charge catch to indeterminate outcomes (final pass, R2)

The round-2 fix caught EVERY non-scope BillingError as 'may or may not have been
charged' — but typed pre-charge rejections (BillingRateLimited 429, BillingSessionRevoked
401, BillingRemoteSpendingRevoked 403, role_required/no_payment_method 4xx) never
reached Stripe, so the ambiguity copy was wrong and dropped their real recovery hints.
Now route those to _subscription_render_error, and reserve the ambiguous copy for
genuinely indeterminate outcomes (network_error / endpoint_unavailable / status None /
5xx). Tests: rate-limit stays deterministic; a real transport failure stays ambiguous.

* feat(billing): card visibility + guided add-card path in /topup and /subscription

Consume the NAS card-resolver contract (card.resolvedVia + chargeability) across
both surfaces, degrading cleanly on today's NAS (fields absent → prior behavior):

- WHICH card: the payment lines render provenance — 'Visa ····4242 — the card on
  your subscription' (resolvedVia → label; unknown rung/older NAS → masked card +
  the old generic line). Link payment methods render the brand alone (last4 is
  empty — never 'Link ····').
- Presence at a glance: the /topup overview now shows 'Card: …' or 'No saved
  card on file' for the full-menu case, plus a warning when the resolver marks
  the card needs_repair (failing auto-reloads) on overview/buy/confirm.
- Add-card path: with no card on file, 'Add funds' becomes a guided screen —
  open the portal billing page, then 'I've added it — check again' re-fetches
  billing state and continues straight into the purchase (also recovers a
  transient display miss). Cards are never entered in-terminal.
- /subscription upgrade confirm names the exact card ('Visa ····4242 — the card
  on your subscription — will be charged'), best-effort via billing.state and
  only when the resolution rung matches what a subscription charge actually
  uses (subPin/customerDefault, mirroring Stripe's precedence); otherwise the
  generic line stands. Fail-soft: any lookup error keeps the generic line.
- Gateway serializes display/resolved_via/needs_repair; TUI ctx gains
  refreshState (topup) + fetchCard (subscription); new offline fixtures
  card-sub / card-repair.

Tests: TUI ctx mocks extended; CLI suites cover provenance + repair-warning
render, the Link guard, the add-card path (continue-after-recheck + abandon),
the sub-confirm card line, and keep the confirm-time lookup offline in tests.

* fix(billing): consume server canChangePlan, preserve distinct refusal codes, drop dead chargeability

- Parse canChangePlan verbatim from NAS payloads into BillingState and
  SubscriptionState; fall back to the legacy OWNER/ADMIN check only when the
  server omits the field (FINANCE_ADMIN stops being locked out where NAS
  authorizes it). Role model updated to the 5-role enum.
- Add the autoReload.card union (canonical | distinct | none) end-to-end:
  parse + gateway serialization, distinct carries payment_method_id/brand/last4
  with nullable display fields.
- stripe_unavailable (503, transient) and upgrade_cap_exceeded (429, daily cap)
  now survive to the wire as their own codes instead of collapsing into
  rate_limited; new exception types subclass BillingRateLimited so existing
  backoff call sites keep working.
- Remove card.chargeability / needs_repair parsing, serialization, fixtures and
  the cli warning blocks: NAS #670 removed the field, so the repair path was
  permanently dead. The future card-health signal belongs to the NAS W1/W3 work.
- Tests: five-role fixtures, canChangePlan override/fallback, all three
  auto-reload card variants, 429-vs-503 code preservation end-to-end.

* feat(tui): render the full NAS billing refusal surface

- billingOverlay: divergence notice when auto-refill charges a distinct card
  (portal deep-link to reconcile); needs_repair warnings removed with the field.
- topup: explicit copy for consent_required, org_access_denied,
  upgrade_cap_exceeded, auto_top_up_disabled_failures and stripe_unavailable
  (honors retry_after); processing_error is an explicit charge-failure case;
  transport loss during charge polling now reads as an unconfirmed outcome
  (check balance before retrying), matching the revocation path.
- subscriptionOverlay: branch on upgrade reason, not status, so an SCA-needing
  upgrade routes to portal verification even while NAS pre-#711 labels it
  payment_failed; after an upgrade, poll subscription state until the tier
  flips (bounded), rendering applying/still-applying rather than assuming
  immediacy.
- Capability-neutral refusal copy (owner, admin, or finance admin) replaces
  the stale org admin/owner wording.
- gatewayTypes: BillingAutoReload.card union added, needs_repair removed.

* docs(billing): client-side billing state and refusal lifecycle table

Enumerates, from the code, every billing.state shape and typed refusal the
gateway serves and the exact TUI copy + recovery each renders. Acceptance from
the billing-integration handoff: no NAS billing state or typed refusal falls
through to a generic toast; unknown codes still degrade to the default branch
that surfaces the server message.
2026-07-18 14:30:24 +05:30
teknium1 5402cb5531 chore(contributors): map mason@masontanguay.com -> DictatorBacon 2026-07-18 01:30:17 -07:00
Mason TanguayandTeknium 1aadb02eaf fix(delegation): stop mixed platform bundles from re-exposing blocked tools to leaf children
A leaf subagent is meant to be denied delegate_task, execute_code, memory,
clarify, cronjob, and send_message. _strip_blocked_tools() only drops a
toolset when EVERY tool in it is blocked, so mixed platform bundles
(hermes-cli, hermes-telegram, and every other gateway bundle) survived
stripping and re-exposed the blocked tools after composite expansion. A
leaf child spawned from any gateway platform could recursively delegate,
run code, and write memory.

Pass exact one-tool deny toolsets into the child's disabled_toolsets so
model_tools subtracts the blocked names AFTER composite expansion, and the
restriction survives later registry/MCP refreshes. Orchestrators regain
only delegate_task.

Salvaged from #66036 by Mason Tanguay (@DictatorBacon); scoped to the
authority fix + its regressions (docs/interrupt changes dropped).

Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
2026-07-18 01:30:17 -07:00
Frowtek 95b09d3f78 fix(gateway): route inbound-image decision off the event loop
`_prepare_inbound_message_text` (async) called `_decide_image_input_mode`
inline for every inbound image. That decision is synchronous and does
blocking network I/O on the way to a capability answer:

- `agent.models_dev.fetch_models_dev` — an HTTP GET to models.dev (15s
  timeout) whenever the 1-hour in-memory cache is cold or models.dev is slow.
- `agent.model_metadata.query_ollama_supports_vision` — HTTP probes
  (`detect_local_server_type` + `/api/show`) against a local Ollama server
  when the active provider fronts one.

Running that inline blocks the gateway event loop for up to the request
timeout — so a single user attaching an image freezes EVERY session on that
gateway (no other messages processed, no heartbeats) until the fetch/probe
returns or times out. This is the same off-the-loop class as the cron-fire
verifier and the async_is_safe_url work.

Wrap the call in `asyncio.to_thread` so the blocking capability lookup runs
on a worker thread and the loop stays responsive. The decision result and
routing are unchanged.

Test: a gateway image-routing runtime test asserts the capability lookup runs
off the main (event-loop) thread; it runs on the main thread before the fix.
2026-07-18 01:30:05 -07:00
HexLab98 06c729706f test(docker): cover tini -g legacy entrypoint boot path
Unit-test flag stripping without Docker, and assert the image shim
rejects the rc.init '-g: not found' restart loop from #66679.
2026-07-18 01:11:03 -07:00
HexLab98 be3c160a85 fix(docker): strip tini -g flags in legacy entrypoint shim
A plain /usr/bin/tini → /init symlink forwarded tini's -g into
s6-overlay's rc.init as the container CMD, causing boot loops after
image updates that preserve old entrypoints (#66679).
2026-07-18 01:11:03 -07:00
Clifford Garwood 3d9be27895 fix(delegate): declare stateless channel in one-shot and cron so delegate_task returns results
run_agent._dispatch_delegate_task forces background=True for every top-level
delegation, and async_delivery_supported() returns True for any session that
never binds the capability. On runners that cannot receive a completion after
their turn ends, that combination silently discards every subagent result: the
model gets a dispatch handle, ends its turn, and reports 'waiting for results'.

Two such runners never bind the capability:

* hermes -z (one-shot) prints one final response and exits. It bypasses cli.py,
  so nothing drains process_registry.completion_queue (only the interactive
  process_loop and the gateway watchers do).

* cron run_job clears the HERMES_SESSION_* routing keys, so a completion event
  carries session_key="" — _enrich_async_delegation_routing cannot resolve it
  and _inject_watch_notification drops it ("no routing metadata"). By then
  run_job has already shipped the job's final response via _deliver_result;
  there is no turn left to re-enter. Worse, get_current_session_key() can fall
  back to the ambient os.environ HERMES_SESSION_KEY, so a cron subagent's output
  can be routed into an unrelated user chat rather than merely dropped.

Add declare_stateless_channel() and bind it in both runners, routing
delegate_task to its existing inline/synchronous path — the same fallback the
stateless HTTP adapter already relies on, and the fix suggested in #63142. The
helper binds only the capability: set_session_vars() would also latch
_session_context_engaged, which a pure single-process one-shot must not trigger.

Also correct two agent-facing strings that hardcoded 'stateless HTTP API' as the
only channel without async delivery (delegate_tool, terminal_tool); they now name
the actual condition.

Repro (before): hermes -z 'Use delegate_task to spawn a subagent that replies
BANANA. Report its reply.' -> "Waiting for the subagent's response...", exit 0,
no BANANA. After: BANANA is returned in-turn.

Fixes #53027
Fixes #63142
2026-07-18 00:05:25 -07:00
Brooklyn Nicholson 39b936e299 perf(desktop): cut startup serialization and per-turn REST amplification
Hot-path pass following the switch-latency work. Four independent costs,
one theme — work that runs on every boot or every turn but only needed
to run on actual change:

- electron: start the Python backend in parallel with the renderer load
  instead of on did-finish-load. The backend cold boot is the dominant
  startup cost and was serialized behind Chromium's load; the connection
  promise is shared, so the renderer's getConnection() joins the
  in-flight boot, and its getBootProgress() pull on mount recovers any
  progress events emitted before the renderer was listening.
- boot/soft-switch: after the socket connects, run the independent
  post-connect fetches (cwd seed, config, session lists) concurrently
  instead of serially — profile adoption still lands first because the
  session fetch scopes by it.
- session.info: config refetch is now gated to the foreground context
  and coalesced (one trailing fetch per event burst) — it used to fire
  two REST calls per event, including background sessions' heartbeats.
  model-options invalidation now requires a VALUE change vs the
  session's cached runtime state; the backend stamps model/provider on
  every event, so the presence-typed flags refetched the provider
  catalog once or twice per turn for a model that never changed.
- turn complete: sidebar refreshes (recents + cron + messaging fan-out,
  each scanning profile state.dbs server-side) coalesce across
  near-simultaneous completions; $sessions and profile totals keep
  their identity when a refresh returns content-identical rows (same
  signature gate cron/messaging already use), and the loading flag no
  longer flickers over a populated list.
2026-07-18 01:23:18 -04:00
nousbot-engandgithub-actions[bot] c48d53413a fmt(js): npm run fix on merge (#66741)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-18 05:16:25 +00:00
brooklyn! 443981ae97 Merge pull request #66738 from NousResearch/fix/ci-timings-fork-token
fix(ci): make timings report fork-safe (missed by #66577)
2026-07-18 01:16:08 -04:00
brooklyn! 65f1c94d2d Merge pull request #66737 from NousResearch/bb/tooltip-focus-open
fix(desktop): stop tooltips re-opening when a menu/dialog restores focus to its trigger
2026-07-18 01:09:25 -04:00
Brooklyn Nicholson ecd54a001e fix(ci): make timings report fork-safe (missed by #66577)
#66373 swapped GITHUB_TOKEN -> AUTOFIX_BOT_PAT across the workflows and
#66577 restored the `|| github.token` fork fallback for detect-changes and
the label gates -- but it missed the ci-timings "Collect timings and
generate report" step, which still passes a bare AUTOFIX_BOT_PAT. On fork
PRs that PAT is empty, so timings_report.py hard-fails at
expect_env("GITHUB_TOKEN") before it can reach its own "degraded run must
never redden the PR" soft-fail path. Every fork PR gets a red run from this
advisory job (e.g. #66573).

- ci.yml: apply the same `secrets.AUTOFIX_BOT_PAT || github.token` fallback
  to the timings step. github.token has `actions: read`, enough to read the
  run's job/step durations on forks.
- timings_report.py: treat a missing/empty GITHUB_TOKEN as a degraded run
  (TimingsUnavailable) instead of a hard ValueError, so this whole class of
  failure can never redden a PR again even if a future workflow drops the
  token. Still writes no JSON, so no empty baseline is ever cached.
2026-07-18 01:06:36 -04:00
brooklyn! 126559d6bb Merge pull request #66734 from NousResearch/bb/desktop-spawn-helper-exec-bit
fix(desktop): restore exec bit on node-pty spawn-helper for dev terminals
2026-07-18 01:05:22 -04:00
Brooklyn Nicholson 7f69494c3a fix(desktop): stop tooltips re-opening when a menu/dialog restores focus to its trigger
Picking a model from the composer model pill left the pill's tooltip
stuck open over the fresh selection: Radix Tooltip opens on ANY trigger
focus (its isPointerDownRef guard only covers a pointerdown on the
trigger itself), and Radix menus/dialogs restore focus to their trigger
on close — so every mouse-driven pick ended with a phantom tip. Same
pattern on every Tip-wrapped trigger that opens an overlay.

Gate the focus-open to KEYBOARD focus: the trigger's own onFocus runs
before Radix's composed handler and calls preventDefault() unless the
trigger matches :focus-visible — composeEventHandlers skips onOpen for
defaultPrevented events. Chromium keeps focus-visible modality across
the menu round-trip, so a mouse pick's focus restore no longer opens
the tip, while Tab-focus still shows it (a11y unchanged). Fails open if
:focus-visible is unsupported.

Tests cover the three branches (suppress on non-keyboard focus, keep on
keyboard focus, fail open on selector error); chat/shell suites green.
2026-07-18 01:04:24 -04:00
Brooklyn Nicholson da805810d4 fix(desktop): restore exec bit on node-pty spawn-helper for dev terminals
node-pty's published npm tarball ships the POSIX `spawn-helper` with mode
0644 (no exec bit). node-pty `posix_spawnp`s that helper on macOS/Linux, so a
non-executable copy fails every embedded-terminal spawn with
`Error: posix_spawnp failed.`. Packaged builds are unaffected because
stage-native-deps.mjs chmods the staged copy, but the dev flow
(`npm run dev` -> `electron .`) resolves node-pty straight from node_modules,
which nothing chmods -- so the first terminal in dev always dies.

Restore the exec bit once, lazily, right before the first spawn, via a small
DI-testable helper. Idempotent: already-executable copies (packaged builds)
are left untouched, and stat/chmod failures are collected and logged rather
than thrown so terminal startup never breaks.
2026-07-18 01:00:48 -04:00
brooklyn! 77a33111c7 Merge pull request #66470 from NousResearch/bb/picker-dialog-latency
perf: fast model picker + dialogs — config-load hot path, model.options off the reader thread, off-screen turns skip rendering
2026-07-18 00:58:29 -04:00
nousbot-engandgithub-actions[bot] 7f78046e5d fmt(js): npm run fix on merge (#66731)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-18 04:56:56 +00:00
xxxigm 3bcd0c1b00 fix(dashboard): only open the chat PTY once the chat tab is active (#59551)
* fix(dashboard): only open the chat PTY once the chat tab is active

The dashboard mounts ChatPage persistently (hidden with CSS) on every route
so the embedded chat PTY survives tab switches. But the PTY-connect effect
never checked whether the chat tab was active, so it opened `/api/pty` on
mount for ANY dashboard page. On a source/RPi install that spawns the whole
TUI + agent bootstrap (`Installing TUI dependencies…` → `npm install`) merely
by loading /sessions, /system, etc. — work the user never asked for, and the
trigger behind "dashboard loses custom themes on /chat load".

Gate the connect effect on a sticky activation latch: the PTY is not spawned
until the chat tab has been active at least once, and stays connected across
later tab switches so the persistence UX is preserved.

* test(dashboard): cover chat PTY activation latch

Asserts the invariant behind the fix: activation is sticky. It stays false
while the chat tab has never been active (so the persistently-mounted,
hidden ChatPage never opens /api/pty), flips true when the tab activates,
and stays true after the user navigates away (PTY persistence).
2026-07-18 00:50:13 -04:00
xxxigm bf517f9301 fix(dashboard): keep custom themes visible after embedded chat starts (#60601)
* fix(dashboard): resolve dashboard-owned assets from the process launch home

Profile-scoped chat / ?profile= requests install a context-local
HERMES_HOME override, which made custom dashboard themes AND user
dashboard-plugin extensions disappear once the embedded /chat started
under a different profile than the dashboard process.

Add get_process_hermes_home() (sharing _hermes_home_from_env() with
get_hermes_home() so the two can't drift, and splitting the profile
fallback warning into _warn_profile_fallback_once()) and use it for both
the theme YAML scan and the user dashboard-plugin scan — machine-level
assets that belong to the server's launch home and must not follow a
transient per-request override.

Genuinely profile-scoped callers (memories/backups/checkpoints/provider
config) and the paired _merged_plugins_hub classification are left
untouched so they keep following the override.

* test(dashboard): cover process-home asset discovery under profile override

- get_process_hermes_home(): env set returns that path, unset falls back
  to the platform default, and an active context-local override is ignored.
- _discover_user_themes() and _discover_dashboard_plugins() keep returning
  launch-home assets while a profile override scopes the request elsewhere.
2026-07-18 00:34:54 -04:00
Austin Pickettandoppih d59b79fadd fix(model-picker): show exhausted-pool providers in interactive /model picker (#66584)
Salvages #66257 by @oppih (CI attribution check blocked the external
branch from merging).

When a provider's credential pool has entries but all are temporarily
rate-limited (exhausted), list_authenticated_providers() excluded the
provider from the interactive /model picker. Rate limits are per-model
for many providers (e.g. Google Gemini), so an exhausted key for
model-A may still work for model-B — the user should still be able to
select a different model under the same provider.

Adds a for_picker flag to list_authenticated_providers() that relaxes
the credential-pool availability check for the picker path only, falling
back to pool.has_credentials() when the pool has entries but none are
currently available. The runtime resolution path
(get_authenticated_provider_slugs) is unchanged, preserving the #45759
invariant that exhausted pools do not count as authenticated.

Co-authored-by: oppih <oppih@users.noreply.github.com>
2026-07-17 19:16:48 -04:00
Austin PickettandAI on behalf of Álvaro Sánchez-Mariscal 5122ddd478 fix(cli): pass TUI Python env from dashboard chat (salvage #44797) (#66581)
* fix: pass TUI Python env from dashboard chat

* fix: share TUI Python env setup

* fix: preserve TUI Python path semantics

* chore: map contributor email for releases

---------

Co-authored-by: AI on behalf of Álvaro Sánchez-Mariscal <alvaro.sanchez-mariscal@oracle.com>
2026-07-17 23:04:33 +00:00
UnathiCodex 9803b2fb89 fix(desktop): preserve numeric and display LaTeX (#66173) 2026-07-17 19:02:18 -04:00
David MetcalfeandDavid Metcalfe e7a8b374b7 fix(desktop): expose Local / custom endpoint in Providers API-keys tab (#62818)
The onboarding overlay already contains a 'Local / custom endpoint' card
that writes model.provider:custom + base_url + api_key, but no reachable
Desktop GUI path opens it for a fresh add. The composer model pill falls
back to the gateway menu panel (Edit Models…), and Settings → Providers →
API keys is env-var-driven and never lists a custom endpoint — so users
following their instincts cannot add an OpenAI-compatible endpoint (Zyphra,
vLLM, Ollama, …) from the GUI.

Add a 'Local / custom endpoint' row to the API-keys tab that calls
startManualLocalEndpoint(), landing the overlay directly on the existing
custom-endpoint form. Reuses the tested onboarding flow; no new UI surface.

Regression test in providers-settings.test.tsx asserts the row renders and
opens the custom-endpoint flow.

Fixes #62817

Co-authored-by: David Metcalfe <80915+DavidMetcalfe@users.noreply.github.com>
2026-07-17 18:58:37 -04:00
teknium1 b9267b50ea docs(delegation): fix stale internal batch-lifecycle comments
Two internal comments in delegate_tool.py still described the superseded
"N independent handles, no combined wait" model, contradicting the
authoritative batch contract (one async unit, one consolidated result
when all children finish). Aligns the comments with the runtime path in
_execute_and_aggregate / dispatch_async_delegation_batch.
2026-07-17 15:55:49 -07:00
Gille 35c578177b docs(delegation): align guidance with current contract 2026-07-17 15:55:49 -07:00
HexLab 38233b61c2 fix(desktop): trust Windows system CAs for remote gateways (#66304)
* fix(desktop): trust Windows system CAs for remote gateways

Load Windows-trusted roots into Node's default TLS context before Desktop probes remote backends, while preserving bundled and extra CAs.

* test(desktop): cover Windows system CA installation

Verify existing trust roots survive the merge and that unsupported or unavailable stores fail open without changing TLS defaults.
2026-07-17 18:55:34 -04:00
Teknium 1e01a4bbe7 fix(ci): restore fork-safe token fallback on PR gates broken by #66373 (#66577)
#66373 swapped GITHUB_TOKEN -> AUTOFIX_BOT_PAT across the workflows. That
PAT is empty on fork PRs (forks get no repo secrets), which broke every
fork PR two ways:

1. detect-changes classified with the empty PAT -> the compare API failed
   all 3 retries -> the classifier failed open and force-enabled the
   ci_review lane on EVERY fork PR.
2. The ci-reviewed / mcp-catalog-reviewed label gates then read labels with
   the same empty PAT via a hard-failing retry step -> the job failed with
   no recovery a fork contributor could perform (they can't self-add the
   label; re-running can't fix it).

Restores the pre-#66373 fork-safe behavior without reverting the commit's
real improvements (job timeouts, per-file flake retry, network-install
retries):

- detect-changes + ci.yml: token falls back to the built-in read-only
  github.token when AUTOFIX_BOT_PAT is empty. On main it uses the PAT
  (authoritative); on forks it uses github.token, which can read the
  public compare endpoint. (An input `default:` only applies on omission,
  not on an empty passed value — hence the explicit `|| github.token`.)
- lint ci-review + supply-chain mcp-catalog gates: restore the inline
  `gh pr view ... || true` label read with the github.token fallback,
  dropping the hard-failing retry "Fetch PR labels" step. Graceful
  degrade to "label absent" on an API blip, same as before #66373.

Same-repo enforcement is unchanged (byte-identical logic; the PAT is still
used there). Fork PRs classify correctly and the gates read labels via the
read-only token exactly as they did before the regression.
2026-07-17 15:52:59 -07:00
teknium1 ae1cd746cb docs(kanban): explain why an unblocked task can later land in triage
The reported confusion was an unblocked task 'unpredictably' ending up in
triage. unblock itself only ever routes to ready/todo; a subsequent same-cause
re-block hitting BLOCK_RECURRENCE_LIMIT is what escalates to triage. Document
this deterministic loop-breaker at the human-facing lifecycle level so users
stop reading it as an LLM decision.
2026-07-17 15:47:39 -07:00
Gille f29c28d6d0 docs(kanban): clarify unblock status routing 2026-07-17 15:47:39 -07:00
Dusk1e 9a7a43b5d6 fix(tools/kanban): sync kanban_unblock response status with DB state 2026-07-17 15:47:39 -07:00
teknium1 296494db0e fix: stop infinite loop when assistant content is a block list
strip_think_blocks() ran re.sub() directly on content that could be a
list of blocks (Anthropic via OpenRouter returns assistant content as
[{type:text,...},{type:thinking,...}]). A list reaching re.sub raised
'TypeError: expected string or bytes-like object, got list', which the
outer conversation loop swallowed and retried forever — the observed
infinite 'preparing terminal...' loop that re-emitted the same
assistant text every iteration.

The live-turn path normalized list content to a string, but
_interim_assistant_visible_text reads a *stored* history message whose
content was persisted as a list and passes it straight into the shared
strip_think_blocks helper. Fix at the shared choke point: coerce
list/dict content to visible text (dropping reasoning blocks, which is
the function's job) before any regex runs, so every caller is safe.
2026-07-17 15:47:10 -07:00
joaomarcos e7f208fd74 test(cli): cover picker-path persist_global=True in #25106 regression tests
hermes-sweeper review on #60970 flagged that _apply_model_switch_result
(the interactive-picker sibling of _handle_model_switch) was only ever
tested with persist_global=False elsewhere, so the picker's global-switch
base_url/api_mode persistence branch had no coverage.
2026-07-17 15:47:08 -07:00
joaomarcosandClaude Sonnet 5 398634eeda fix(cli,gateway): sync base_url/api_mode on global model switch persist
Same bug family as #47828, at the config-persistence layer instead of
the in-memory agent layer:

- cli.py (#25106): the --global /model handlers (both the typed-name
  path in _handle_model_switch and the picker path in
  _apply_model_switch_result) wrote model.default/model.provider to
  config.yaml but never touched base_url/api_mode at all. A provider
  switch left the OLD endpoint on disk; the next launch reconnected to
  the previous provider's host under the new model name.

- gateway/slash_commands.py (#25107): both persist-global blocks (the
  picker-tap callback and the typed /model --global path) guarded the
  write with two INDEPENDENT ifs — `if result.base_url: ...` and
  `if target_provider != "custom": clear_model_endpoint_credentials(...)`.
  For named providers the second if always cleared stale values, masking
  the bug. For a custom provider with an empty resolved base_url, neither
  branch fired, so the previous custom endpoint's base_url/api_key/
  api_mode survived untouched in config.yaml.

Fix: explicit set-if-truthy/clear-if-falsy for base_url and api_mode at
all four call sites, matching the already-correct pattern in
tui_gateway/server.py:_persist_model_switch (fixed for #48305).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 15:47:08 -07:00
Tekniumandsjiangtao2024 c7aa01ff06 fix(model-switch): override stale api_mode with host-mandated mode on OpenAI-direct switch
Switching to a GPT-5.x model on api.openai.com while the session carried a
stale chat_completions api_mode (e.g. from a prior openrouter default) left
the request on /v1/chat/completions, which 400s with "Function tools with
reasoning_effort are not supported" once the switched model's reasoning is
applied. switch_model() only re-derived api_mode inside the
provider-changed branch, so a same-provider/carryover switch kept the wrong
wire protocol.

Add host_mandated_api_mode(base_url): the endpoints that accept exactly one
protocol (api.openai.com -> codex_responses, api.anthropic.com / *…/anthropic*
-> anthropic_messages, api.kimi.com /coding -> anthropic_messages,
bedrock-runtime -> bedrock_converse), matched by EXACT hostname so lookalike
hosts and path-segment spoofs are rejected (#32243). switch_model() now uses
it to override a stale carried api_mode, not merely fill an empty one;
determine_api_mode() shares the same helper.

Credit sjiangtao2024 (#15880) for the recompute-before-validation approach;
this strengthens it from fill-if-empty to a host-mandated override.

Co-Authored-By: sjiangtao2024 <siage@139.com>
2026-07-17 15:47:08 -07:00
Gille 7498eae3f7 fix(cron): preserve POSIX script decoding defaults 2026-07-17 15:40:50 -07:00
helix4u 1b63737f55 fix(cron): avoid Windows Python launcher popups 2026-07-17 15:40:50 -07:00
nousbot-engandgithub-actions[bot] d9ee342414 fmt(js): npm run fix on merge (#66527)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-17 21:02:05 +00:00
Tekniumandethernet 597615ade4 fix(ci): make tests, workflows, and attribution reliable under load (#66373)
* feat(attribution): conflict-free contributor mappings via contributors/emails/ directory

The AUTHOR_MAP dict in scripts/release.py was a merge-conflict magnet:
every concurrent salvage PR appended entries to the same lines of the
same file, so parallel PRs re-conflicted on every merge to main.

New system: one file per email under contributors/emails/ — filename is
the commit-author email, first non-comment line is the GitHub login.
File additions never conflict, so any number of PRs can add mappings
concurrently.

- scripts/release.py: AUTHOR_MAP is now LEGACY_AUTHOR_MAP (frozen)
  merged with the directory at import time (directory wins). All
  existing consumers (resolve_author, contributor_audit.py) unchanged.
- scripts/add_contributor.py: idempotent CLI to add a mapping; refuses
  conflicting reassignments (incl. against the legacy map), validates
  email/login shapes.
- contributor-check.yml: attribution gate now accepts a mapping file OR
  a legacy entry; failure message prints the exact add_contributor
  command. Also auto-resolves bare <login>@users.noreply.github.com
  emails is intentionally NOT added (kept id+login form only, matching
  previous behavior).
- contributor_audit.py: guidance now points at add_contributor.py.
- tests/scripts/test_contributor_map.py: 12 tests covering loader,
  merge precedence, CLI idempotency/conflict/validation, subprocess E2E.

* feat(ci): one-shot per-file flake retry in the parallel test runner

A failing test FILE is re-run once in a fresh subprocess. Pass-on-retry
counts as green but is loudly reported in a '⚠ FLAKY' summary section
(with both attempts' output preserved) so the flake gets fixed instead
of eating a full-run rerun. Deterministic failures fail both attempts —
regressions cannot be laundered green.

- --file-retries N / HERMES_TEST_FILE_RETRIES (default 1, 0 disables)
- E2E verified: simulated first-run-fail flake goes green with banner;
  deterministic failure still exits 1; retries=0 restores old behavior.

This converts the dominant CI failure mode (one timing-sensitive test
flaking a 4600-test shard, requiring a manual 10-minute rerun and an
agent triage loop) into a self-healing retry that costs one file's
runtime.

* test(approval): loosen wall-clock perf bounds 0.15s -> 2.0s

These guard against catastrophic regex backtracking (seconds-to-minutes
class), but 0.15s is within scheduler-stall noise on loaded shared CI
runners — test_max_accepted_separator_free_input_is_fast failed a CI
shard this week on runner load alone. 2.0s still catches the regression
class with zero flake surface.

* fix(ci): job timeouts everywhere + retries on all network installs

Reliability pass over every workflow:
- timeout-minutes on all 21 jobs that lacked one (a hung job previously
  burned the 6-hour default runner budget)
- ./.github/actions/retry wrapped around every network-fetching install
  that lacked it: pip installs (deploy-site, skills-index), npm ci
  (deploy-site website, upload_to_pypi web + ui-tui), uv sync (docker
  test deps). Deterministic build steps (npm run build) deliberately
  NOT retried — split into separate steps so a real build failure fails
  fast instead of retrying 3x.

* docs(agents): document the file-retry flake policy

* fix(ci): curl retries on deploy hook + skills-index probe

* fix(ci): kill the remaining transient-failure classes in workflows + Dockerfile

From the workflow reliability audit:
- tests.yml: duration-cache restore had NO restore-keys while saves use
  run_id-suffixed keys — the cache never matched once, so LPT slicing
  always ran blind and unbalanced slices pushed heavy files toward the
  per-file timeout. One-line restore-keys fixes slice balancing.
- Label gates (lint ci-reviewed, supply-chain mcp-catalog-reviewed):
  'gh pr view || true' turned an API blip into 'label absent' → false
  BLOCKING failure. Now 3x retry, and API failure is reported as an API
  failure instead of a missing label.
- detect-changes action: compare API retried before failing open (was
  silently running all lanes on any blip).
- uv-lockfile-check: 'uv lock --check' resolves against PyPI — retried
  so registry blips don't read as 'lockfile stale'.
- docker.yml merge job: imagetools create retried (Docker Hub eventual
  consistency on just-pushed digests).
- Dockerfile: apt-get Acquire::Retries=3; s6-overlay ADDs converted to
  curl --retry 3 (ADD cannot retry; checksums still enforced); npm
  --fetch-retries=5; playwright chromium fetch retried 3x.
- Advisory artifact uploads (per-slice durations, ci-timings report)
  get continue-on-error so an artifact-service blip can't fail a green
  test slice.

* fix(tests): kill the two root-cause flakes — leaking pre-warm timer + env-dependent provider list

- test_tui_gateway_server.py: session.create / non-eager session.resume
  arm a 50ms threading.Timer (_schedule_agent_build) that outlives its
  test and fires into the NEXT test's _make_agent mock, racily
  corrupting captured state (the recurring session_resume shard
  failures). Replaced the per-test whack-a-mole stub with a module-wide
  autouse fixture; the 3 worker-lifecycle tests that genuinely need the
  deferred build opt back in via @pytest.mark.real_agent_prewarm (new
  marker in pyproject).
- test_api_key_providers.py: PROVIDER_ENV_VARS is now derived from the
  live PROVIDER_REGISTRY instead of a hand-list that had drifted
  (missing HF_TOKEN / DEEPINFRA_API_KEY) — resolve_provider('auto')
  tests failed on any machine with HF_TOKEN exported. E2E-verified with
  HF_TOKEN/DEEPINFRA_API_KEY set: 42/42 pass.

* test: de-flake 30 timing-sensitive test files for loaded CI runners

Root-cause fixes from the flake audit (session-DB mining + repo sweep):

Event-based sync instead of sleep-sync:
- title_generator: mock sets threading.Event, wait(10) replaces
  sleep(0.3) hoping the daemon thread got scheduled
- docker zombie_reaping / profile_gateway: poll-for-state helpers
  replace fixed 1-3s sleeps (s6 transitions + SIGCHLD reaping are async)
- process_registry tree test: select()-bounded readline replaces an
  unbounded blocking read (parent wedge now fails THIS test with a clear
  message instead of an opaque rc=124 file kill); SIGTERM grace 1s->2s
  (the 1s partition window mid-interpreter-startup is how a child PID
  escaped the live-system guard in CI)

Timeout raises (loaded 8-way-sliced runners see ~5s scheduling floors;
all of these complete in ms-to-1s when healthy so the raises cost
nothing on green runs):
- subprocess/thread waits <= 2s raised to 10-15s across mcp_tool,
  mcp_circuit_breaker, mcp_reconnect_retry_reset, mcp_parked_self_probe,
  mcp_cancelled_error_propagation, registry, clarify_gateway, interrupt,
  voice_cli_integration, docker_environment, session_store_lock_io,
  planned_stop_watcher, cli_interrupt_subagent, thread_scoped_output
  (joins now also assert not is_alive() so stragglers fail loudly)
- wall-clock discrimination ceilings loosened where the guarded hang is
  10x larger: local_background_child_hang 4s->10s, interrupt_cleanup
  setup 5s->20s + pgid-exit 30s->60s, mcp_stability grandchild spinup
  5s->15s, protocol/gil-starvation fast-handler 0.5s->2s,
  iso_certify_seam 1.5s->5s, wait_for_mcp_discovery 0.1s->1s
- narrow assertion windows widened: honcho first-turn wait 0.4..0.65 ->
  0.25..2.0 (property is bounded-not-hung, not an exact wall-clock);
  compression fork-lock TTL 1s->3s (12 refresh chances per lease);
  compression-lock expiry margins symmetric (ttl 0.05->0.5, sleep 1.0)
- telegram hung-DNS bound 1.0->1.4 (fake hang is 1.5s — must stay under)

* fix(tests): repair indentation from de-flake batch edit

* fix(tests): harden env isolation and replace remaining sleep-sync races

The full 42k-test run and complete npm check surfaced three more classes:

- Environment isolation: local ~/.honcho defaultHost and SSH_* variables
  leaked into Python/TUI tests. Pin the default Honcho host in the
  hermetic fixture, isolate the one fallback test from ~/.honcho, and
  blank SSH_* around terminalSetup tests. This flipped 20 false failures
  back to deterministic behavior on developer machines.
- Background-thread sleep-sync: Honcho async writer tests patched
  time.sleep globally, then busy-polled with that same mocked sleep. Under
  full-suite load the poller could starve the writer. Each test now waits
  on an Event emitted by the exact flush/retry transition; 30/30 passed
  under 15-way contention.
- Desktop streaming: the test slept 80ms and assumed a 500ms timer could
  not fire before its assertion. A loaded runner descheduled the test for
  >500ms and both chunks arrived. Producer controls now gate second-chunk
  and completion transitions explicitly.

Also make file-retry observability complete: a self-healed flaky file now
prints BOTH attempts' full output in the FLAKY summary. Two behavioral
runner tests prove pass-on-retry is green+loud+traceback-preserving, while
a deterministic failure remains red.

* refactor(ci): use gh bot pat, better retries

refactor(ci): use retry action for PR label fetch
the retry action now captures stdout as a step output, so it can serve
double duty: retry + output capture for commands like 'gh pr view' whose
result must be consumed by later steps.

Retry action gains:
- 'stdout' output (heredoc-delimited to preserve newlines)
- tee to temp file so stdout still streams to the job log
- step id 'retry' for output reference

Both lint.yml and supply-chain-audit.yml now use the retry action
directly with 'command: gh pr view ...' and read
steps.<id>.outputs.stdout.

ci: use AUTOFIX_BOT_PAT for all gh CLI / GitHub API auth

Replace secrets.GITHUB_TOKEN and github.token with
secrets.AUTOFIX_BOT_PAT across all workflows and composite actions
that use the gh CLI or GitHub API. The PAT has consistent permissions
across fork PRs (where GITHUB_TOKEN is read-only), avoids API rate
limit sharing with the default token, and is already used by
js-autofix.yml for the same reasons.

19 sites swapped across 9 files:
- lint.yml (3): label fetch, comment post/edit, comment update
- supply-chain-audit.yml (5): scan, critical comment, unbounded dep
  comment, label fetch, mcp-catalog comment
- lockfile-diff.yml (1): PR comment post/update
- skills-index-freshness.yml (1): issue creation on degraded probe
- skills-index.yml (2): index build, trigger deploy workflow
- upload_to_pypi.yml (2): release view poll, release upload
- ci.yml (1): timings report
- deploy-site.yml (2): skills index crawl
- detect-changes/action.yml (1): compare API call

---------

Co-authored-by: ethernet <arilotter@gmail.com>
2026-07-17 20:55:24 +00:00
Teknium 07f07c7b51 fix(mem0): migrate legacy OSS base URL aliases
Normalize stale api_base keys to each mem0 provider's accepted URL field before Memory.from_config, without mutating the saved config.
2026-07-17 13:49:29 -07:00
Teknium 4c0546c9cc fix(moa): surface stale presets without retries
Keep invalid persisted preset names fail-closed, list the valid configured choices, and classify the local lookup failure as deterministic so it reaches Desktop immediately.
2026-07-17 13:49:12 -07:00
TekniumandNick Taylor 61bbc39330 fix(codex): harden final cache-key boundaries
Fold #62349's broader provider-boundary handling into the header fix: bound top-level and xAI override keys again at preflight after middleware, preserve unrelated headers, and cover boundaries and collisions.

Co-authored-by: Nick Taylor <nicktaylor@TheWorldofNick-Lappy.local>
2026-07-17 13:48:41 -07:00
Teknium 81496a8925 test(codex): cover overlength cache-scope headers
Exercise the real transport path for long session ids, including stable hashing and bounded body/header cache keys.
2026-07-17 13:48:41 -07:00
webtecnica 8051ebae30 fix: cap cache-scope headers at 64 chars to avoid Codex 400 error (#66045) 2026-07-17 13:48:41 -07:00
Teknium 05b5e2b6e9 docs(codex): document live app-server display; AUTHOR_MAP entries
- codex-app-server-runtime.md: add a Live display section covering the
  stream/reasoning/tool-card bridge and show_commentary gating.
- release.py: AUTHOR_MAP entries for HaiderSultanArc, jjadeo-oss, juanfradb
  (the latter two for forthcoming follow-up salvages of #62396 / #18050).
2026-07-17 13:44:12 -07:00
Haider Sultan 18331b9bbd feat(codex): stream live app-server events to TUI/desktop tool cards
Extends the app-server event bridge (make_codex_app_server_event_bridge)
to fire the authoritative stable-ID tool_start_callback /
tool_complete_callback alongside the existing tool_progress_callback,
and route item/reasoning/summaryDelta through the reasoning channel.

Surfaces that render structured tool cards (TUI, desktop) — not just
progress bubbles — now correlate live cards with the projected history
entry after a resume, because the call ids mirror CodexEventProjector's
_deterministic_call_id. Guarded per-callback so a broken display
consumer can't tear down the codex turn loop.

Grafted from PR #65412 by @HaiderSultanArc onto the merged bridge (the
PR's parallel _codex_live_event implementation was reconciled into the
bridge's existing _fire_tool_started/_fire_tool_completed helpers).
2026-07-17 13:44:12 -07:00
nousbot-engandgithub-actions[bot] 8702e6a6cb fmt(js): npm run fix on merge (#66505)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-17 20:27:12 +00:00
nousbot-eng 7f76fc040a Merge pull request #64576 from joelbrilliant/fix/desktop-update-stream-output
fix(update): stream update child output to the live log (PYTHONUNBUFFERED)
2026-07-17 16:20:39 -04:00
Erosika e4cdd8d9ad fix(honcho): delegate the config.yaml timeout read to load_config_readonly
The staleness check's bespoke mtime memo keyed only on the user
config.yaml, but load_config() merges the managed-scope config
(HERMES_MANAGED_DIR/config.yaml, /etc/hermes) whose leaf keys win. A
managed honcho.timeout with no user config.yaml made the memo cache
'no timeout' while _build resolved the managed value — the same
perpetual-rebuild mismatch this PR fixes for honcho.json. A managed
timeout edit was likewise invisible while the user file's mtime stayed
put.

load_config_readonly() is already cached on both files' signatures plus
the env-ref snapshot, so use it instead of duplicating that
invalidation logic; the defensive deepcopy the old memo existed to
avoid is skipped by the readonly variant. Drive the rebuild test
through a real config.yaml and add a HERMES_MANAGED_DIR regression
test covering stable reuse and managed-timeout edits.
2026-07-17 13:20:16 -07:00
Erosika 9769facae1 fix(honcho): resolve the timeout staleness check from honcho.json like the build path
The staleness check added in #66052 resolved the timeout from env,
config.yaml, and the default only, while the build path also reads the
honcho.json host block (timeout/requestTimeout). With a timeout
configured in honcho.json, the two permanently disagreed: every
no-config get_honcho_client() call — i.e. every HonchoSessionManager
.honcho property access — interpreted the mismatch as a config change
and tore down and rebuilt the client, defeating the singleton on the
hot path it was meant to protect.

Teach the check to read honcho.json through the same host-aware chain
as from_global_config, memoized on the file's mtime_ns so the per-call
cost stays one stat(). A genuine honcho.json timeout change is now also
detected, extending #57437 to that config surface.
2026-07-17 13:20:16 -07:00
ethernet 19bc02ff8e feat(dev-sandbox): add --from DIR to seed sandbox HERMES_HOME (#66486)
Adds a --from DIR flag to scripts/dev-sandbox.sh that copies an existing
HERMES_HOME directory into the sandbox as the starting point before the
command runs. Lets you spin up a sandbox pre-populated with your real
config, sessions, skills, etc.

  scripts/dev-sandbox.sh --from ~/.hermes hermes desktop

Design:
- cp -a dir/. dest/ — preserves perms, symlinks, hidden files
- Clobber guard: only seeds when sandbox HERMES_HOME is empty, so
  re-running --persistent doesn't blow away existing sandbox state
- Validates: errors on nonexistent dir, missing arg, flag-like arg,
  empty --from=
- Supports both --from DIR and --from=DIR forms
- Backwards compatible: no --from = unchanged behavior
2026-07-17 19:45:41 +00:00
ethernetandBrooklyn Nicholson 11d36232c0 fix(desktop): stop button sends interrupt to wrong session + stale events re-arm busy (#66485)
Co-authored-by: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com>
2026-07-17 19:44:10 +00:00
Brooklyn Nicholson 9b8b054c2d perf: fast model picker + dialogs — config-load hot path, model.options off the reader thread, off-screen turns skip rendering
Third profiling round (after #66033 / #66347), targeting the composer
model picker and dialog opens (worktree dialog etc.), measured over CDP
on real 1000+-message sessions.

Backend — model.options took 4.8s cold / 1.8s warm per call, and the
desktop model pill/picker blocks on it every open:

- agent/credential_pool: _load_config_safe uses load_config_readonly().
  Every consumer only reads, and the per-call deepcopy was the dominant
  cost — list_authenticated_providers calls load_pool() per provider
  row, and each load_pool loaded (and deep-copied) the full config
  again via get_pool_strategy.
- hermes_cli/config: memoize ensure_hermes_home() per home path. It
  runs inside the config lock on EVERY load_config(), paying ~14
  mkdir/chmod syscalls per call. The fast path still re-checks that the
  home dir exists, so a deleted home is recreated as before; profile
  switches hit the new path and re-run. Tests cover both.
- tui_gateway/server: add model.options to _LONG_HANDLERS. It measured
  seconds inline on the WS reader thread — while it ran, prompt.submit
  and session.interrupt sat unread (same class as #21123).

Together: model.options RPC 4825/1842ms → 426/230ms (measured on the
live desktop backend); build_models_payload in isolation 6.2s → 0.97s
cold, 0.27s warm.

Desktop — every Radix dialog/popover open forced a whole-document style
recalc (Presence reads getComputedStyle on mount), which on a
1300-message transcript cost ~650-730ms per open (CPU profile:
getAnimationName 483ms self). The worktree dialog (⌘⇧B) paid it on
every single open:

- thread/list: content-visibility:auto + contain-intrinsic-size on the
  per-turn group wrappers. Off-screen turns now skip style recalc,
  layout, and paint entirely; never-rendered turns hold a placeholder
  height (auto: remembered real size once rendered) so scrollbar and
  anchoring stay stable. Verified over CDP: worktree dialog open 656-
  730ms → ~200ms on the same session; stick-to-bottom pin, scroll-to-
  top rendering, and sticky human bubbles all intact.

Also: profile-session-switch harness accepts CDP_HTTP (Chrome tends to
squat on 9222).

Verification:
- scripts/run_tests.sh: config, credential-pool, inventory,
  model-switch routing, tui_gateway protocol, profiles suites green
  (test_profiles has one pre-existing failure on main, unrelated);
  new tests for the ensure_hermes_home memo.
- apps/desktop: tsc clean, eslint/prettier clean, thread + session
  suites green (326 tests).
- E2E over CDP on the live app: numbers above, plus scroll/pin sanity.
2026-07-17 15:01:54 -04:00
nousbot-engandgithub-actions[bot] bcea5371c8 fmt(js): npm run fix on merge (#66465)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-17 18:59:51 +00:00
nousbot-engandgithub-actions[bot] 29dac61d77 fmt(js): npm run fix on merge (#66460)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-17 18:53:03 +00:00
brooklyn! cf52edbb59 Merge pull request #66454 from NousResearch/ethie/session-status-sync
refactor(desktop): derive working/attention session sets from $sessionStates
2026-07-17 14:46:23 -04:00
nousbot-engandgithub-actions[bot] 29e3983fa8 fmt(js): npm run fix on merge (#66457)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-17 18:44:45 +00:00
brooklyn! 9930c2b47f Merge pull request #66034 from NousResearch/bb/review-store-tests
test(desktop): cover the review store
2026-07-17 14:37:55 -04:00
brooklyn! 3e7c563ddd Merge pull request #66449 from NousResearch/audit/desktop-model-picker
fix(desktop): session-scope fast mode, surface profile ownership + pinned model override
2026-07-17 14:36:21 -04:00
brooklyn! 270486226c Merge pull request #66347 from NousResearch/bb/profile-switch-prewarm
perf(desktop): pre-warm profile backends and gateway sockets on hover intent
2026-07-17 14:34:53 -04:00
ethernet a75a8eda72 refactor(desktop): derive working/attention session sets from $sessionStates
$workingSessionIds and $attentionSessionIds were independently maintained
atoms that updateSessionState had to manually keep in sync with the session
cache (paired setSessionWorking/setSessionAttention calls, plus a rotation
special-case in ensureSessionState). Make them computed() projections of
$sessionStates instead, so the data flow is one-directional:
gateway event → cache → $sessionStates → computed views.

Transition side-effects (watchdog arm/disarm, settle grace, unread marker,
compression id rotation signal) move into handleTransition, fired from
publishSessionState by diffing previous vs next — one choke point instead
of per-callsite bookkeeping. The watchdog's force-clear reaches the cache
through setWatchdogClearFn rather than a listener set.

Also:
- clearAllSessionStates disarms all watchdog timers and drops settle-grace
  entries so a gateway switch can't leak stale timers or keep-set rows
- dropSessionState disarms the dropped runtime's watchdog timer
- watchdog tests now exercise the real timer→callback wiring instead of
  manually simulating the clear
2026-07-17 14:33:38 -04:00
Brooklyn Nicholson ba542338ea fix(desktop): session-scope fast mode, surface profile ownership + pinned model override
Model-picker audit follow-through — closes the remaining pieces of the
"switch one session, switches everywhere / can't tell whose session this
is" report class:

- tui_gateway: `config.set key=fast` with a session no longer writes the
  global agent.service_tier to config.yaml (sibling of the earlier
  `reasoning` scoping fix). It pins create_service_tier_override
  ("priority" / "" for explicit normal) so lazy builds and rebuilds keep
  the choice; the desktop's per-model presets were rewriting the global
  tier on every model pick. Fast-support validation now checks a draft's
  picked model, and `config.get key=fast` reads the pre-build pin.
- desktop: owning-profile tag (initial chip + tooltip/aria label) on
  pinned rows and search results in the All-profiles sidebar, and on the
  chat header once a second profile exists (#66003).
- desktop: composer model pill shows a pin dot + tooltip when a manual
  sticky pick is overriding the Settings default for new chats (#62055).

Closes #66003. Addresses #62055.
2026-07-17 14:31:10 -04:00
brooklyn! 41fdcae688 fix(streaming): make the single-writer fence best-effort so a missing guard can't crash a turn (#66448)
A cron job ("Daily Buzz Report") died with 'AIAgent' object has no
attribute '_claim_stream_writer'. The #65991 single-writer fence lives on
AIAgent (run_agent.py), but the streaming paths that use it live in other
modules — chat_completion_helpers (chat / anthropic / bedrock) and
codex_runtime (codex responses) — and called it directly as
agent._claim_stream_writer() / agent._stream_writer_is_current(). That makes
those modules hard-depend on the method being present on whatever object is
passed as agent.

The fence is an *additive* safety net that may only ever drop a provably
superseded stream, never the sole legitimate writer. But the direct calls
turned any agent that doesn't expose it — a version-skewed checkout (the
streaming helper module newer than run_agent), a hot-reloaded gateway mid
git-pull, a duck-typed agent, or a test double — into a fatal AttributeError
that aborts the whole turn (and, on cron, fails the job).

Route every cross-module claim/check through agent/stream_single_writer.py.
claim_stream_writer(agent) returns 0 when the fence is unavailable (or
raises), and stream_writer_is_current(agent, token) treats a 0 token or an
absent guard as "current" — so a guard-less agent degrades to "no fence"
instead of crashing, while a real AIAgent keeps the full single-writer
protection. Internal self.* uses inside run_agent are unchanged (self is
always a full AIAgent there).
2026-07-17 18:31:09 +00:00
Brooklyn Nicholson 81a140266f perf(desktop): pre-warm opens the gateway socket too, not just the spawn
Answering the review question on the PR table — why a hovered-cold
switch still showed ~440ms click → WS open: getConnection-only
pre-warming left the WS connect chain to the click, and its microtask
continuation can only run after the click's fresh-draft React flush
(unmounting a large open transcript costs ~300-400ms of render work),
so the socket didn't even START connecting until the flush finished.

Add openGatewayForProfile: the same spawn + connect chain as a real
switch, minus activation — so the hover leaves the profile's socket
fully OPEN and the click's ensureGatewayForProfile just activates it
(no ws:new after the click at all; measured ws open at hover+136ms on
a warm backend). No scheduleReconnect on failure: a hover is
speculative, so a dead backend must not start a background retry loop
— the real switch owns retry and error UX. Pruning semantics are
unchanged: a hover-opened socket for an idle profile is dropped by the
next pruneSecondaryGateways recompute, which just returns the click to
the previous behavior.

Tests updated: pre-warm asserts openGatewayForProfile is called and
that activation (ensureGatewayForProfile) is NOT.
2026-07-17 13:11:05 -04:00
Brooklyn Nicholson e0390c0f70 perf(desktop): pre-warm profile pool backends on hover intent
A cold profile switch pays the full pool-backend spawn — Python boot,
port announcement, readiness probe, token adoption — before the
profile's gateway can even open. Measured with the new CDP harness
(scripts/measure-profile-switch.mjs, same family as
profile-session-switch.mjs): click → WS open is ~2.5-2.9s on a cold
profile, ~3-3.6s to a settled sidebar; a warm profile settles in
~0.5-0.8s. The pointer entering a profile square telegraphs the switch
hundreds of ms before the click lands, so start the spawn then.

- store/profile: prewarmProfileBackend(name) — fires the existing
  hermesDesktop.getConnection IPC, which is idempotent (ensureBackend
  returns the pooled connectionPromise), so the real switch joins the
  in-flight spawn instead of starting it. Skips the active gateway
  profile, throttles per profile (60s) so drive-by hovers can't spam
  spawn attempts, and swallows failures — error UX belongs to the real
  switch. No new IPC surface; the pool's existing LRU cap + idle reaper
  still bound resource use, and the LRU guard never evicts a
  keepalive-fresh backend for a hover spawn.
- sidebar/use-profile-prewarm: pointerenter/pointerleave handlers with
  a 120ms dwell so sweeping the pointer across the rail or a
  mixed-profile session list doesn't spawn a backend per element
  crossed.
- Wired at the three switch surfaces: rail ProfileSquare, the condensed
  ProfileDropdown items (extracted ProfileDropdownItem so each row owns
  its dwell timer), and SidebarSessionRow (covers cross-profile resumes
  from the all-profiles view; same-profile rows no-op inside the guard).

Measured E2E over CDP: synthetic hover on a cold profile square spawns
its backend in the background; the subsequent click settles in ~519ms
vs ~3.0-3.6s unhovered — and any hover shorter than the spawn still
shaves its dwell off the click's wait.

Verification: apps/desktop `npx tsc --noEmit` clean; full
`npx vitest run` 212 files / 1777 passed (new prewarm guard/throttle
tests in store/profile.test.ts); eslint + prettier clean.
2026-07-17 10:23:49 -04:00
Brooklyn Nicholson f6edcb3d76 test(desktop): cover the review store (refresh, selection, mutations, ship flow) 2026-07-16 21:51:16 -04:00
Brooklyn Nicholson dc58758a4b perf(desktop): kill the layout-thrash cascade on session switch
Follow-up to #65890 (router transitions off) and #65898 (structural
compare + first-paint budget): profiling the switch path on real 1000+-
message sessions with a new CDP harness showed the remaining freeze is
NOT markdown rendering — it's a forced-reflow cascade from mount-time
layout reads interleaved with style writes across the transcript's
layout effects, plus the first-paint budget cut landing too late to
stop the full-budget commit.

Measured on the two largest local sessions (996 and 1363 messages),
main-thread longtask totals per switch: warm 2450ms -> 557ms and
1158ms -> 194ms; first paint 1690ms -> 444ms. Harness:
scripts/profile-session-switch.mjs (same CDP family as
profile-real-stream.mjs).

- use-resize-observer: drop the synchronous initial callback and ride
  the observer's spec-guaranteed first delivery instead (same frame,
  after layout, before paint). The sync call ran while the commit's
  layout was dirty, so every size read in a callback forced a full
  reflow — with one instance per user bubble (measureClamp read
  scrollHeight, then WROTE --human-msg-full, re-dirtying layout for the
  next bubble), the switch commit thrashed for over a second. Inside RO
  timing the same reads are free. Composer metrics (2x
  getBoundingClientRect + documentElement style writes) rides the same
  fix.
- Same class, same fix at the remaining call sites profiling surfaced:
  ExpandableBlock and TerminalOutput (dozens per tool-heavy transcript)
  now measure/pin via RO initial delivery; the tool-window and
  thinking-preview pins drop their sync pin() call; the thread
  timeline's initial active-tick compute joins its existing
  scroll-time rAF batching so back-to-back transcript updates coalesce.
- thread/list: cut the render budget in the RENDER phase (state-from-
  props adjustment) instead of the post-commit layout effect. The
  effect-time cut was too late — on a warm switch React first built and
  committed the full 300-part tree, then re-rendered at 60, then bumped
  back to 300, so the expensive commit still happened (and on a cold
  switch the bump rAF usually fired while the transcript was still
  empty, so the prefetched messages rendered at full budget anyway).
  The render-phase cut restarts the component before any child renders;
  a second trigger handles the cold path where messages land later
  under the same sessionKey.
- thread/list: backfill 60 -> 300 inside startTransition so the older
  turns' markdown+shiki render is interruptible background work instead
  of a synchronous freeze one frame after the switch paints. Functional
  Math.max so an urgent "Show earlier" click can't be rebased back down.
- composer focus: skip the rAF/timeout focus retries when the element
  is already focused — focus() runs the full focusing steps (forcing
  layout) even on the active element, ~585ms per switch on a large
  dirty DOM.
- Replace the tautological render-budget test (it re-declared the
  constants locally and asserted 60 < 300) with behavior tests of the
  now-exported buildGroups + firstVisibleGroupIndex.

Verification: apps/desktop `npx tsc --noEmit` clean; full
`npx vitest run` 210 files / 1763 passed; manual CDP check confirms the
deferred backfill commits the full transcript, stays pinned to bottom,
and "Show earlier" still pages.
2026-07-16 21:48:39 -04:00
joelbrilliantandClaude Fable 5 86eba6f6a9 fix(update): stream update child output to the live log (PYTHONUNBUFFERED)
hermes update is a Python CLI writing to a pipe when the Tauri updater or
the desktop's in-app POSIX path spawns it, so CPython block-buffers stdout.
Long quiet steps stream nothing to the progress UI. Worst case is the
pre-update backup (updates.pre_update_backup: true): it can zip multi-GB
archives for minutes while the updater still shows the previous line
('waiting for Hermes to exit...'). Users read that as a hang, cancel a
healthy update, and the orphaned child keeps mutating the install.

Set PYTHONUNBUFFERED=1 in both spawn sites (update_child_env in the Tauri
updater, applyUpdatesPosixInApp in the desktop) so output streams line by
line.

Also make the lock-probe unit test pass on macOS: the packaged payload
lives under Contents/Resources there, and Path::ends_with is
case-sensitive, so the lowercase resources/app.asar assertion only ever
matched the Windows/Linux layouts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 13:48:05 +10:00
464 changed files with 41435 additions and 5121 deletions
+39 -5
View File
@@ -5,6 +5,12 @@ description: >-
the sub-workflows a PR can affect. Outputs are always "true" on push/dispatch
events and fail open (everything "true") when the diff cannot be computed.
inputs:
github-token:
description: Token for the GitHub API (gh CLI). Pass secrets.AUTOFIX_BOT_PAT from the calling workflow.
required: false
default: ${{ github.token }}
outputs:
python:
description: Run Python tests / ruff / ty / windows-footguns.
@@ -41,7 +47,12 @@ runs:
id: classify
shell: bash
env:
GH_TOKEN: ${{ github.token }}
# Fall back to the built-in read-only token when the caller passes an
# empty value. Fork PRs get no repo secrets, so AUTOFIX_BOT_PAT is ""
# there, and an input `default:` only applies when the input is omitted,
# not when it's passed empty. Without this fallback the compare API
# fails on forks and the classifier fails open (every lane forced on).
GH_TOKEN: ${{ inputs.github-token || github.token }}
REPO: ${{ github.repository }}
EVENT_NAME: ${{ github.event_name }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
@@ -57,10 +68,33 @@ runs:
# event payload instead of the "current PR files" endpoint. The SHAs
# are frozen at trigger time, so the file list is deterministic even
# if the PR receives a new push between trigger and detect.
CHANGED="$(gh api \
--paginate \
"repos/${REPO}/compare/${BASE_SHA}...${HEAD_SHA}" \
--jq '.files[].filename' || true)"
#
# Retried: a rate-limit blip or eventual-consistency 404 on a
# freshly-pushed HEAD would otherwise silently fall open (all lanes
# run — safe, but wasteful and it masks the API failure).
#
# `.files[]?` (null-safe): with --paginate, a PR more than 100
# commits ahead of its merge-base paginates the compare, and pages
# after the first carry `files: null` — bare `.files[]` makes jq
# die with "cannot iterate over: null", which fails every retry
# and forces the fail-open path (seen on stacked PRs). The full
# file list (up to the API's 300-file cap) is on page one.
CHANGED=""
for i in 1 2 3; do
if CHANGED="$(gh api \
--paginate \
"repos/${REPO}/compare/${BASE_SHA}...${HEAD_SHA}" \
--jq '.files[]?.filename')"; then
break
fi
if [ "$i" = 3 ]; then
echo "::warning::compare API failed after 3 attempts — failing open (all lanes run)"
CHANGED=""
break
fi
echo "::warning::compare API failed (attempt $i); retrying in 10s"
sleep 10
done
fi
echo "Changed files:"
+23 -3
View File
@@ -3,7 +3,8 @@ description: >-
Run a shell command, retrying on non-zero exit. For dependency installs
(npm ci, uv sync) whose only failures are transient network/toolchain
flakes — a node-gyp header fetch, a registry blip — so CI self-heals
instead of needing a manual re-run.
instead of needing a manual re-run. Can also capture stdout as a step
output for commands whose result must be consumed by later steps.
inputs:
command:
@@ -19,10 +20,16 @@ inputs:
description: Directory to run in.
default: "."
outputs:
stdout:
description: Captured stdout from the successful attempt (empty if not needed).
value: ${{ steps.retry.outputs.stdout }}
runs:
using: composite
steps:
- shell: bash
- id: retry
shell: bash
working-directory: ${{ inputs.working-directory }}
# command goes through env, never interpolated into the script body, so
# a command with quotes/specials can't break or inject into the runner.
@@ -32,12 +39,25 @@ runs:
_DELAY: ${{ inputs.delay }}
run: |
set -uo pipefail
_OUTFILE="$(mktemp)"
trap 'rm -f "$_OUTFILE"' EXIT
n=0
while :; do
n=$((n + 1))
echo "::group::attempt $n/$_ATTEMPTS: $_CMD"
if bash -c "$_CMD"; then
# Run the command, capturing stdout to a temp file while still
# streaming to the log. We redirect first, then tee the file to
# stdout — this avoids pipefail + tee exit-code interactions that
# can cause the if-branch to be skipped under set -e.
if bash -c "$_CMD" > "$_OUTFILE"; then
cat "$_OUTFILE"
echo "::endgroup::"
# Preserve newlines in the output via heredoc delimiter.
{
echo 'stdout<<__RETRY_STDOUT_EOF__'
cat "$_OUTFILE"
echo '__RETRY_STDOUT_EOF__'
} >> "$GITHUB_OUTPUT"
exit 0
fi
echo "::endgroup::"
+24 -1
View File
@@ -35,6 +35,7 @@ jobs:
detect:
name: Detect affected areas
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
python: ${{ steps.classify.outputs.python }}
frontend: ${{ steps.classify.outputs.frontend }}
@@ -51,6 +52,10 @@ jobs:
- name: Detect affected areas
id: classify
uses: ./.github/actions/detect-changes
with:
# Forks get no repo secrets (AUTOFIX_BOT_PAT is empty); fall back to
# the built-in read-only token so classification still works there.
github-token: ${{ secrets.AUTOFIX_BOT_PAT || github.token }}
# ─────────────────────────────────────────────────────────────────────
# Lane-gated sub-workflows. Each runs in parallel after detect finishes.
@@ -63,6 +68,7 @@ jobs:
uses: ./.github/workflows/tests.yml
with:
slice_count: 8
secrets: inherit
lint:
name: Python lints
@@ -72,47 +78,55 @@ jobs:
with:
event_name: ${{ needs.detect.outputs.event_name }}
ci_review: ${{ needs.detect.outputs.ci_review == 'true' }}
secrets: inherit
js-tests:
name: JS & TS checks
needs: detect
if: needs.detect.outputs.frontend == 'true'
uses: ./.github/workflows/js-tests.yml
secrets: inherit
docs-site:
name: Docs Site
needs: detect
if: needs.detect.outputs.site == 'true'
uses: ./.github/workflows/docs-site-checks.yml
secrets: inherit
history-check:
name: Deny unrelated histories
needs: detect
if: needs.detect.outputs.event_name == 'pull_request'
uses: ./.github/workflows/history-check.yml
secrets: inherit
contributor-check:
name: Check contributors
needs: detect
if: needs.detect.outputs.python == 'true'
uses: ./.github/workflows/contributor-check.yml
secrets: inherit
uv-lockfile:
name: Check uv.lock
needs: detect
uses: ./.github/workflows/uv-lockfile-check.yml
secrets: inherit
lockfile-diff:
name: package-lock.json diff
needs: detect
if: needs.detect.outputs.event_name == 'pull_request' && needs.detect.outputs.npm_lock == 'true'
uses: ./.github/workflows/lockfile-diff.yml
secrets: inherit
docker-lint:
name: Lint Docker scripts
needs: detect
if: needs.detect.outputs.docker_meta == 'true'
uses: ./.github/workflows/docker-lint.yml
secrets: inherit
docker:
name: Build&Test Docker image
@@ -131,10 +145,12 @@ jobs:
scan: ${{ needs.detect.outputs.scan == 'true' }}
deps: ${{ needs.detect.outputs.deps == 'true' }}
mcp_catalog: ${{ needs.detect.outputs.mcp_catalog == 'true' }}
secrets: inherit
osv-scanner:
name: OSV scan
uses: ./.github/workflows/osv-scanner.yml
secrets: inherit
# ─────────────────────────────────────────────────────────────────────
# Gate: runs after everything. ``if: always()`` ensures it reports a
@@ -161,6 +177,7 @@ jobs:
# - docker
if: always()
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Evaluate job results
env:
@@ -191,6 +208,7 @@ jobs:
needs: [all-checks-pass, docker]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -208,7 +226,10 @@ jobs:
- name: Collect timings and generate report
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Forks get no repo secrets (AUTOFIX_BOT_PAT is empty); fall back to
# the built-in read-only token so the timings API read still works
# there instead of hard-failing this advisory job on every fork PR.
GITHUB_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT || github.token }}
run: |
python3 scripts/ci/timings_report.py \
--baseline ci-timings-baseline.json \
@@ -217,6 +238,8 @@ jobs:
--summary-out ci-timings-summary.md
- name: Upload HTML report
# Advisory report — artifact-service blips must not fail the job.
continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
id: ci-timings-artifact
with:
+13 -7
View File
@@ -9,6 +9,7 @@ permissions:
jobs:
check-attribution:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -27,7 +28,9 @@ jobs:
exit 0
fi
# Check each email against AUTHOR_MAP in release.py
# An email is mapped if it has a file in contributors/emails/
# (one file per email — conflict-free) or an entry in the frozen
# legacy AUTHOR_MAP in scripts/release.py.
MISSING=""
while IFS= read -r email; do
# Skip teknium and bot emails
@@ -36,9 +39,12 @@ jobs:
continue ;;
esac
# Check if email is in AUTHOR_MAP (either as a key or matches noreply pattern)
if echo "$email" | grep -qP '\+.*@users\.noreply\.github\.com'; then
continue # GitHub noreply emails auto-resolve
continue # GitHub id+login noreply emails auto-resolve
fi
if [ -f "contributors/emails/${email}" ]; then
continue # mapped via the contributors directory
fi
if ! grep -qF "\"${email}\"" scripts/release.py 2>/dev/null; then
@@ -49,19 +55,19 @@ jobs:
if [ -n "$MISSING" ]; then
echo ""
echo "⚠️ New contributor email(s) not in AUTHOR_MAP:"
echo "⚠️ New contributor email(s) without a mapping:"
echo -e "$MISSING"
echo ""
echo "Please add mappings to scripts/release.py AUTHOR_MAP:"
echo "Add a mapping file (do NOT edit AUTHOR_MAP in release.py):"
echo -e "$MISSING" | while read -r line; do
email=$(echo "$line" | sed 's/^ *//' | cut -d' ' -f1)
[ -z "$email" ] && continue
echo " \"${email}\": \"<github-username>\","
echo " python3 scripts/add_contributor.py ${email} <github-username>"
done
echo ""
echo "To find the GitHub username for an email:"
echo " gh api 'search/users?q=EMAIL+in:email' --jq '.items[0].login'"
exit 1
else
echo "✅ All contributor emails are mapped in AUTHOR_MAP."
echo "✅ All contributor emails are mapped."
fi
+12 -6
View File
@@ -41,13 +41,15 @@ jobs:
# doesn't auto-deploy via the deploy-docs path.
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Trigger Vercel Deploy
run: curl -X POST "${{ secrets.VERCEL_DEPLOY_HOOK }}"
run: curl -fsS --retry 3 --retry-delay 10 -X POST "${{ secrets.VERCEL_DEPLOY_HOOK }}"
deploy-docs:
if: github.repository == 'NousResearch/hermes-agent'
runs-on: ubuntu-latest
timeout-minutes: 30
environment:
name: github-pages
url: ${{ steps.deploy.outputs.page_url }}
@@ -65,12 +67,14 @@ jobs:
python-version: '3.11'
- name: Install PyYAML for skill extraction
run: pip install pyyaml==6.0.2 httpx==0.28.1
uses: ./.github/actions/retry
with:
command: pip install pyyaml==6.0.2 httpx==0.28.1
- name: Prepare skills index (unified multi-source catalog)
env:
GH_TOKEN: ${{ github.token }}
GITHUB_TOKEN: ${{ github.token }}
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
GITHUB_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
SKILLS_INDEX_RUN_ID: ${{ github.event.inputs.skills_index_run_id || '' }}
REBUILD_SKILLS_INDEX: ${{ github.event.inputs.rebuild_skills_index || 'false' }}
run: |
@@ -150,8 +154,10 @@ jobs:
run: python3 website/scripts/generate-skill-docs.py
- name: Install dependencies
run: npm ci
working-directory: website
uses: ./.github/actions/retry
with:
command: npm ci
working-directory: website
- name: Build Docusaurus
run: npm run build
+22 -13
View File
@@ -127,12 +127,13 @@ jobs:
run: uv python install 3.11
- name: Install Python dependencies (for docker tests)
run: |
# ``dev`` extra pulls in pytest, pytest-asyncio —
# everything tests/docker/ needs. We deliberately avoid ``all``
# here because the docker tests only drive the container via
# subprocess and don't import hermes_agent's optional deps.
uv sync --locked --python 3.11 --extra dev
# ``dev`` extra pulls in pytest, pytest-asyncio —
# everything tests/docker/ needs. We deliberately avoid ``all``
# here because the docker tests only drive the container via
# subprocess and don't import hermes_agent's optional deps.
uses: ./.github/actions/retry
with:
command: uv sync --locked --python 3.11 --extra dev
- name: Run docker integration tests
env:
@@ -188,15 +189,23 @@ jobs:
args+=("${IMAGE_NAME}@sha256:${digest_file}")
done
if [ "${{ github.event_name }}" = "release" ]; then
docker buildx imagetools create \
-t "${IMAGE_NAME}:${RELEASE_TAG}" \
"${args[@]}"
tags=(-t "${IMAGE_NAME}:${RELEASE_TAG}")
else
docker buildx imagetools create \
-t "${IMAGE_NAME}:main" \
-t "${IMAGE_NAME}:latest" \
"${args[@]}"
tags=(-t "${IMAGE_NAME}:main" -t "${IMAGE_NAME}:latest")
fi
# Retry: Docker Hub API + just-pushed digest eventual consistency
# can transiently fail the create; the operation is idempotent.
for i in 1 2 3; do
if docker buildx imagetools create "${tags[@]}" "${args[@]}"; then
break
fi
if [ "$i" = 3 ]; then
echo "::error::imagetools create failed after 3 attempts"
exit 1
fi
echo "::warning::imagetools create failed (attempt $i); retrying in 20s"
sleep 20
done
- name: Inspect image
env:
+1
View File
@@ -9,6 +9,7 @@ permissions:
jobs:
docs-site-checks:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+1
View File
@@ -22,6 +22,7 @@ permissions:
jobs:
check-common-ancestor:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
+2
View File
@@ -8,6 +8,7 @@ jobs:
workspaces:
name: List npm workspaces
runs-on: ubuntu-latest
timeout-minutes: 20
outputs:
packages: ${{ steps.set-matrix.outputs.packages }}
steps:
@@ -32,6 +33,7 @@ jobs:
name: Typecheck & Test
needs: workspaces
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
matrix:
package: ${{ fromJson(needs.workspaces.outputs.packages) }}
+7 -3
View File
@@ -180,7 +180,11 @@ jobs:
- name: Require ci-reviewed label
id: label-check
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Read-only label lookup. Use the built-in GITHUB_TOKEN (present and
# read-only on forks) so the gate works on fork PRs; fall back to it
# when AUTOFIX_BOT_PAT is empty. `|| true` degrades an API blip to
# "label absent" rather than hard-failing the step.
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT || github.token }}
run: |
set -euo pipefail
PR="${{ github.event.pull_request.number }}"
@@ -200,7 +204,7 @@ jobs:
- name: Post or update review warning
if: steps.label-check.outputs.reviewed != 'true' && github.event.pull_request.head.repo.fork != true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT || github.token }}
run: |
set -euo pipefail
PR="${{ github.event.pull_request.number }}"
@@ -248,7 +252,7 @@ jobs:
- name: Update previous warning to passed
if: steps.label-check.outputs.reviewed == 'true' && github.event.pull_request.head.repo.fork != true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT || github.token }}
run: |
set -euo pipefail
PR="${{ github.event.pull_request.number }}"
+1 -1
View File
@@ -61,7 +61,7 @@ jobs:
- name: Post or update PR comment
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
REPO: ${{ github.repository }}
PR: ${{ github.event.pull_request.number }}
CHANGED: ${{ steps.diff.outputs.changed }}
+3 -2
View File
@@ -20,6 +20,7 @@ jobs:
check-freshness:
if: github.repository == 'NousResearch/hermes-agent'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Probe live index
id: probe
@@ -28,7 +29,7 @@ jobs:
URL="https://hermes-agent.nousresearch.com/docs/api/skills-index.json"
echo "Probing $URL"
# -L follows redirects; -f fails on HTTP errors; -s suppresses progress
if ! curl -fsSL -o /tmp/skills-index.json "$URL"; then
if ! curl -fsSL --retry 3 --retry-delay 10 -o /tmp/skills-index.json "$URL"; then
echo "status=fetch-failed" >> "$GITHUB_OUTPUT"
echo "detail=Could not download $URL" >> "$GITHUB_OUTPUT"
exit 0
@@ -110,7 +111,7 @@ jobs:
- name: Open issue on degraded / failed probe
if: steps.probe.outputs.status != 'ok'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
STATUS: ${{ steps.probe.outputs.status }}
DETAIL: ${{ steps.probe.outputs.detail }}
run: |
+7 -3
View File
@@ -20,6 +20,7 @@ jobs:
# Only run on the upstream repository, not on forks
if: github.repository == 'NousResearch/hermes-agent'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -28,11 +29,13 @@ jobs:
python-version: "3.11"
- name: Install dependencies
run: pip install httpx==0.28.1 pyyaml==6.0.2
uses: ./.github/actions/retry
with:
command: pip install httpx==0.28.1 pyyaml==6.0.2
- name: Build skills index
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
run: python scripts/build_skills_index.py
- name: Upload index artifact
@@ -49,8 +52,9 @@ jobs:
needs: build-index
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Trigger Deploy Site workflow
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
run: gh workflow run deploy-site.yml --repo ${{ github.repository }} -f skills_index_run_id=${{ github.run_id }}
+11 -4
View File
@@ -43,6 +43,7 @@ jobs:
name: Scan PR for critical supply chain risks
if: inputs.scan
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -52,7 +53,7 @@ jobs:
- name: Scan diff for critical patterns
id: scan
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
run: |
set -euo pipefail
@@ -141,7 +142,7 @@ jobs:
- name: Post critical finding comment
if: steps.scan.outputs.found == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
run: |
BODY="## 🚨 CRITICAL Supply Chain Risk Detected
@@ -164,6 +165,7 @@ jobs:
name: Check PyPI dependency upper bounds
if: inputs.deps
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -201,7 +203,7 @@ jobs:
- name: Post unbounded dep warning
if: steps.bounds.outputs.found == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
run: |
BODY="## ⚠️ Unbounded PyPI Dependency Detected
@@ -229,6 +231,7 @@ jobs:
name: MCP catalog security review
if: inputs.mcp_catalog
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -237,7 +240,11 @@ jobs:
- name: Require explicit MCP catalog review label
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Read-only label lookup. Use the built-in GITHUB_TOKEN (present and
# read-only on forks) so the gate works on fork PRs; fall back to it
# when AUTOFIX_BOT_PAT is empty. `|| true` degrades an API blip to
# "label absent" rather than hard-failing the step.
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT || github.token }}
run: |
set -euo pipefail
PR="${{ github.event.pull_request.number }}"
+11
View File
@@ -20,6 +20,7 @@ jobs:
generate:
name: "Generate slices"
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
matrix: ${{ steps.matrix.outputs.matrix }}
steps:
@@ -31,6 +32,12 @@ jobs:
with:
path: test_durations.json
key: test-durations
# Saves use test-durations-${run_id}, so the exact key above never
# matches — without this prefix fallback the cache ALWAYS missed,
# LPT slicing ran on no data, and unbalanced slices pushed heavy
# files toward the per-file timeout under load.
restore-keys: |
test-durations-
- name: Generate test slices
id: matrix
@@ -114,6 +121,9 @@ jobs:
NOUS_API_KEY: ""
- name: Upload per-slice durations
# Advisory artifact (feeds slice balancing) — a transient artifact-
# service blip must not fail an otherwise-green test slice.
continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: test-durations-slice-${{ matrix.slice.index }}
@@ -126,6 +136,7 @@ jobs:
needs: test
if: needs.test.result == 'success' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Download all slice durations
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+21 -4
View File
@@ -26,6 +26,7 @@ jobs:
build:
name: Build distribution 📦
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -56,10 +57,24 @@ jobs:
node-version: "22"
- name: Build web dashboard
run: cd web && npm ci && npm run build
uses: ./.github/actions/retry
with:
command: npm ci
working-directory: web
- name: Compile web dashboard
run: npm run build
working-directory: web
- name: Build TUI bundle
run: cd ui-tui && npm ci && npm run build
uses: ./.github/actions/retry
with:
command: npm ci
working-directory: ui-tui
- name: Compile TUI bundle
run: npm run build
working-directory: ui-tui
- name: Bundle TUI into hermes_cli
run: |
@@ -90,6 +105,7 @@ jobs:
name: Publish to PyPI
needs: build
runs-on: ubuntu-latest
timeout-minutes: 30
environment:
name: pypi
url: https://pypi.org/p/hermes-agent
@@ -115,6 +131,7 @@ jobs:
if: startsWith(github.ref, 'refs/tags/')
needs: publish
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: write # attach assets to the existing release
id-token: write # sigstore signing
@@ -128,7 +145,7 @@ jobs:
- name: Wait for GitHub Release to exist
env:
GITHUB_TOKEN: ${{ github.token }}
GITHUB_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
# release.py creates the GitHub Release after pushing the tag,
# but this workflow starts from the tag push — wait for it.
run: |
@@ -154,7 +171,7 @@ jobs:
- name: Attach signed artifacts to GitHub Release
if: env.skip_sign != 'true'
env:
GITHUB_TOKEN: ${{ github.token }}
GITHUB_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
# release.py already created the GitHub Release — just upload
# the Sigstore signatures alongside the existing assets.
run: >-
+14 -1
View File
@@ -74,7 +74,20 @@ jobs:
# rebase and regenerate uv.lock."
- name: Verify uv.lock is up-to-date
run: |
if ! uv lock --check; then
# uv lock --check re-resolves against PyPI (network). Retry so a
# registry blip doesn't read as "lockfile stale". A genuinely stale
# lockfile fails all attempts (deterministic), costing only seconds.
ok=false
for i in 1 2 3; do
if uv lock --check; then
ok=true
break
fi
[ "$i" = 3 ] && break
echo "::warning::uv lock --check failed (attempt $i); retrying in 10s"
sleep 10
done
if [ "$ok" != true ]; then
cat <<'EOF' >> "$GITHUB_STEP_SUMMARY"
## ❌ uv.lock is out of sync with pyproject.toml
+8
View File
@@ -1294,6 +1294,14 @@ scripts/run_tests.sh tests/agent/test_foo.py::test_x # one test
scripts/run_tests.sh -v --tb=long # pass-through pytest flags
```
**Flake policy:** the runner auto-retries a failing test FILE once in a fresh
subprocess (`--file-retries`, default 1; `HERMES_TEST_FILE_RETRIES=0` to
disable). Pass-on-retry counts as green but is printed in a `⚠ FLAKY` summary
section with both attempts' output. A FLAKY report is a bug to fix, not noise
to ignore — timing-sensitive tests must not assume a quiet runner (loose
wall-clock bounds ≥ 2s, event-based sync, no `assert not _wait_until(...)`
negative-timing races).
#### Subprocess-per-test-file isolation
Every test file runs in a freshly-spawned Python subprocess via `run_tests_parallel.py`. This means module-level dicts/sets and
+31 -29
View File
@@ -26,8 +26,8 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright
# replaces tini with s6-overlay's /init (PID 1 = s6-svscan), which reaps
# zombies non-blockingly on SIGCHLD and additionally supervises the main
# hermes process, the dashboard, and per-profile gateways.
RUN apt-get update && \
apt-get install -y --no-install-recommends \
RUN apt-get -o Acquire::Retries=3 update && \
apt-get -o Acquire::Retries=3 install -y --no-install-recommends \
ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc g++ make cmake python3-dev python3-venv libffi-dev libolm-dev procps git openssh-client docker-cli xz-utils && \
rm -rf /var/lib/apt/lists/*
@@ -40,33 +40,30 @@ RUN apt-get update && \
# we map between them inline. The noarch + symlinks tarballs are
# architecture-independent and reused as-is.
#
# We use `curl` instead of `ADD` for the per-arch tarball because `ADD`
# evaluates its URL at parse time, before any ARG / TARGETARCH substitution
# — splitting one URL per arch into two ADDs would download both on every
# build and leave dead bytes in the cache. A single curl + arch-keyed URL
# is simpler and cache-friendlier.
#
# Supply-chain integrity: every tarball is checksum-verified against the
# upstream-published SHA256. To bump S6_OVERLAY_VERSION, fetch the four
# `.sha256` files from the corresponding release and update the ARGs. The
# checksum lookup happens during build, so a compromised release artifact
# fails the build loudly instead of silently producing a tampered image.
# We use `curl` instead of `ADD` for ALL three tarballs: `ADD` evaluates its
# URL at parse time (no ARG / TARGETARCH substitution) and — critically for
# CI reliability — cannot retry, so a single GitHub-release CDN blip fails
# the whole 15-45 min build. curl -fsSL --retry 3 self-heals those blips,
# and every tarball is still checksum-verified below before extraction.
ARG TARGETARCH
ARG S6_OVERLAY_VERSION=3.2.3.0
ARG S6_OVERLAY_NOARCH_SHA256=b720f9d9340efc8bb07528b9743813c836e4b02f8693d90241f047998b4c53cf
ARG S6_OVERLAY_X86_64_SHA256=a93f02882c6ed46b21e7adb5c0add86154f01236c93cd82c7d682722e8840563
ARG S6_OVERLAY_AARCH64_SHA256=0952056ff913482163cc30e35b2e944b507ba1025d78f5becbb89367bf344581
ARG S6_OVERLAY_SYMLINKS_SHA256=a60dc5235de3ecbcf874b9c1f18d73263ab99b289b9329aa950e8729c4789f0e
ADD https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-noarch.tar.xz /tmp/
ADD https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-symlinks-noarch.tar.xz /tmp/
RUN set -eu; \
case "${TARGETARCH:-amd64}" in \
amd64) s6_arch="x86_64"; s6_arch_sha="${S6_OVERLAY_X86_64_SHA256}" ;; \
arm64) s6_arch="aarch64"; s6_arch_sha="${S6_OVERLAY_AARCH64_SHA256}" ;; \
*) echo "Unsupported TARGETARCH=${TARGETARCH} for s6-overlay" >&2; exit 1 ;; \
esac; \
base="https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}"; \
curl -fsSL --retry 3 -o /tmp/s6-overlay-noarch.tar.xz \
"${base}/s6-overlay-noarch.tar.xz"; \
curl -fsSL --retry 3 -o /tmp/s6-overlay-symlinks-noarch.tar.xz \
"${base}/s6-overlay-symlinks-noarch.tar.xz"; \
curl -fsSL --retry 3 -o /tmp/s6-overlay-arch.tar.xz \
"https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-${s6_arch}.tar.xz"; \
"${base}/s6-overlay-${s6_arch}.tar.xz"; \
{ \
printf '%s %s\n' "${S6_OVERLAY_NOARCH_SHA256}" /tmp/s6-overlay-noarch.tar.xz; \
printf '%s %s\n' "${s6_arch_sha}" /tmp/s6-overlay-arch.tar.xz; \
@@ -76,17 +73,19 @@ RUN set -eu; \
tar -C / -Jxpf /tmp/s6-overlay-noarch.tar.xz; \
tar -C / -Jxpf /tmp/s6-overlay-arch.tar.xz; \
tar -C / -Jxpf /tmp/s6-overlay-symlinks-noarch.tar.xz; \
rm /tmp/s6-overlay-*.tar.xz /tmp/s6-overlay.sha256; \
# #34192: backward-compat shim for orchestration templates that still\
# reference the legacy /usr/bin/tini entrypoint (e.g. Hostinger's\
# 'Hermes WebUI' catalog). The image has moved to s6-overlay /init\
# as PID 1 (see ENTRYPOINT below + the migration comment at the top\
# of this file), but external wrappers pinned to /usr/bin/tini will\
# crash with 'tini: No such file or directory' on startup. The shim\
# symlinks /usr/bin/tini -> /init so legacy wrappers exec the right\
# PID-1 reaper without behavior change for users on the current\
# ENTRYPOINT. Safe to drop once the affected catalogs are updated.\
ln -sf /init /usr/bin/tini
rm /tmp/s6-overlay-*.tar.xz /tmp/s6-overlay.sha256
# #34192 / #66679: backward-compat shim for orchestration templates that
# still reference the legacy /usr/bin/tini entrypoint (Hostinger's
# 'Hermes WebUI' catalog, NAS compose projects that preserve an old
# entrypoint on image update, etc.). A plain symlink to /init made the
# path exist, but forwarded tini flags like `-g` into s6-overlay's
# rc.init as the container CMD (`rc.init: 91: -g: not found`) and
# boot-looped any `restart: unless-stopped` deploy. The shim strips the
# tini CLI surface, then exec's /init + main-wrapper — see
# docker/tini-shim.sh. Safe to drop once the affected catalogs are
# updated.
COPY --chmod=0755 docker/tini-shim.sh /usr/bin/tini
# Non-root user for runtime; UID can be overridden via HERMES_UID at runtime
RUN useradd -u 10000 -m -d /opt/data hermes
@@ -135,8 +134,11 @@ COPY apps/shared/ apps/shared/
# guards against a future regression if the source npm version changes.
ENV npm_config_install_links=false
RUN npm install --prefer-offline --no-audit && \
npx playwright install --with-deps chromium --only-shell && \
RUN npm install --prefer-offline --no-audit --fetch-retries=5 && \
for i in 1 2 3; do \
npx playwright install --with-deps chromium --only-shell && break || \
{ [ "$i" = 3 ] && exit 1; echo "playwright install failed (attempt $i); retrying in 10s"; sleep 10; }; \
done && \
npm cache clean --force
# ---------- Layer-cached Python dependency install ----------
+7 -7
View File
@@ -214,7 +214,7 @@ def build_nous_credits_snapshot(account_info) -> Optional[AccountUsageSnapshot]:
return None
details.append(f"Top up: {nous_portal_topup_url(account_info)}")
details.append("(or run /credits)")
details.append("(or run /topup)")
plan = getattr(sub, "plan", None) if sub is not None else None
return AccountUsageSnapshot(
@@ -340,7 +340,7 @@ def _snapshot_from_credits_state(state) -> Optional[AccountUsageSnapshot]:
@dataclass(frozen=True)
class CreditsView:
"""Surface-agnostic data for the ``/credits`` command.
"""Surface-agnostic data for the ``/topup`` balance view.
One portal fetch, one parse — consumed identically by the CLI panel, the
gateway button, and any other money surface. Fail-open: when not logged in
@@ -356,11 +356,11 @@ class CreditsView:
def build_credits_view(*, markdown: bool = False, timeout: float = 10.0) -> CreditsView:
"""Build the /credits view: balance block + identity line + top-up URL.
"""Build the /topup balance view: balance block + identity line + top-up URL.
Reuses the same account fetch + snapshot + URL builder as the /usage credits
block, so the numbers always match. The balance block is the rendered
snapshot MINUS its trailing top-up/command-hint lines (the /credits surface
snapshot MINUS its trailing top-up/command-hint lines (the /topup surface
supplies its own affordance). Fail-open → ``CreditsView(logged_in=False)``.
"""
not_logged_in = CreditsView(logged_in=False)
@@ -386,7 +386,7 @@ def build_credits_view(*, markdown: bool = False, timeout: float = 10.0) -> Cred
timeout=timeout
)
except Exception:
logger.debug("credits ▸ /credits portal fetch failed (fail-open)", exc_info=True)
logger.debug("credits ▸ /topup portal fetch failed (fail-open)", exc_info=True)
return not_logged_in
if account is None or not getattr(account, "logged_in", False):
@@ -394,8 +394,8 @@ def build_credits_view(*, markdown: bool = False, timeout: float = 10.0) -> Cred
snapshot = build_nous_credits_snapshot(account)
# Balance lines = the snapshot block minus the two trailing affordance lines
# ("Top up: <url>" + "(or run /credits)") that build_nous_credits_snapshot
# appends for the /usage surface. /credits renders its own button/panel.
# ("Top up: <url>" + "(or run /topup)") that build_nous_credits_snapshot
# appends for the /usage surface. /topup renders its own button/panel.
balance_lines: list[str] = []
if snapshot is not None:
rendered = render_account_usage_lines(snapshot, markdown=markdown)
+46 -46
View File
@@ -275,71 +275,71 @@ def _merge_custom_provider_extra_body(agent, custom_providers: List[Dict[str, An
def init_agent(
agent,
base_url: str | None = None,
api_key: str | None = None,
provider: str | None = None,
api_mode: str | None = None,
acp_command: str | None = None,
base_url: str = None,
api_key: str = None,
provider: str = None,
api_mode: str = None,
acp_command: str = None,
acp_args: list[str] | None = None,
command: str | None = None,
command: str = None,
args: list[str] | None = None,
model: str = "",
max_iterations: int = 90, # Default tool-calling iterations (shared with subagents)
tool_delay: float = 1.0,
enabled_toolsets: List[str] | None = None,
disabled_toolsets: List[str] | None = None,
enabled_toolsets: List[str] = None,
disabled_toolsets: List[str] = None,
save_trajectories: bool = False,
verbose_logging: bool = False,
quiet_mode: bool = False,
tool_progress_mode: str = "all",
ephemeral_system_prompt: str | None = None,
ephemeral_system_prompt: str = None,
log_prefix_chars: int = 100,
log_prefix: str = "",
providers_allowed: List[str] | None = None,
providers_ignored: List[str] | None = None,
providers_order: List[str] | None = None,
provider_sort: str | None = None,
providers_allowed: List[str] = None,
providers_ignored: List[str] = None,
providers_order: List[str] = None,
provider_sort: str = None,
provider_require_parameters: bool = False,
provider_data_collection: str | None = None,
provider_data_collection: str = None,
openrouter_min_coding_score: Optional[float] = None,
session_id: str | None = None,
tool_progress_callback: Callable | None = None,
tool_start_callback: Callable | None = None,
tool_complete_callback: Callable | None = None,
thinking_callback: Callable | None = None,
reasoning_callback: Callable | None = None,
clarify_callback: Callable | None = None,
read_terminal_callback: Callable | None = None,
step_callback: Callable | None = None,
stream_delta_callback: Callable | None = None,
interim_assistant_callback: Callable | None = None,
tool_gen_callback: Callable | None = None,
status_callback: Callable | None = None,
notice_callback: Callable | None = None,
notice_clear_callback: Callable | None = None,
session_id: str = None,
tool_progress_callback: callable = None,
tool_start_callback: callable = None,
tool_complete_callback: callable = None,
thinking_callback: callable = None,
reasoning_callback: callable = None,
clarify_callback: callable = None,
read_terminal_callback: callable = None,
step_callback: callable = None,
stream_delta_callback: callable = None,
interim_assistant_callback: callable = None,
tool_gen_callback: callable = None,
status_callback: callable = None,
notice_callback: callable = None,
notice_clear_callback: callable = None,
event_callback: Optional[Callable[[str, dict], None]] = None,
reaction_callback: Optional[Callable[[str], None]] = None,
max_tokens: int | None = None,
reasoning_config: Dict[str, Any] | None = None,
service_tier: str | None = None,
request_overrides: Dict[str, Any] | None = None,
prefill_messages: List[Dict[str, Any]] | None = None,
platform: str | None = None,
user_id: str | None = None,
user_id_alt: str | None = None,
user_name: str | None = None,
chat_id: str | None = None,
chat_name: str | None = None,
chat_type: str | None = None,
thread_id: str | None = None,
gateway_session_key: str | None = None,
max_tokens: int = None,
reasoning_config: Dict[str, Any] = None,
service_tier: str = None,
request_overrides: Dict[str, Any] = None,
prefill_messages: List[Dict[str, Any]] = None,
platform: str = None,
user_id: str = None,
user_id_alt: str = None,
user_name: str = None,
chat_id: str = None,
chat_name: str = None,
chat_type: str = None,
thread_id: str = None,
gateway_session_key: str = None,
skip_context_files: bool = False,
load_soul_identity: bool = False,
skip_memory: bool = False,
session_db=None,
parent_session_id: str | None = None,
iteration_budget: Optional["IterationBudget"] = None,
fallback_model: Dict[str, Any] | None = None,
parent_session_id: str = None,
iteration_budget: "IterationBudget" = None,
fallback_model: Dict[str, Any] = None,
credential_pool=None,
checkpoints_enabled: bool = False,
checkpoint_max_snapshots: int = 20,
+42 -4
View File
@@ -37,6 +37,7 @@ from agent.tool_dispatch_helpers import _trajectory_normalize_msg, make_tool_res
from agent.trajectory import convert_scratchpad_to_think
from agent.credential_pool import STATUS_EXHAUSTED
from agent.error_classifier import FailoverReason
from agent.turn_context import drop_stale_api_content
from utils import base_url_host_matches, base_url_hostname, env_var_enabled, atomic_json_write
logger = logging.getLogger(__name__)
@@ -246,7 +247,7 @@ def sanitize_tool_call_arguments(
messages: list,
*,
logger=None,
session_id: str | None = None,
session_id: str = None,
) -> int:
"""Repair corrupted assistant tool-call argument JSON in-place."""
log = logger or logging.getLogger(__name__)
@@ -587,6 +588,10 @@ def repair_message_sequence(agent, messages: List[Dict]) -> int:
if prev_content and new_content
else (prev_content or new_content)
)
# Merged content invalidates the api_content sidecar (exact
# bytes previously sent for the pre-merge message) — drop it
# so replay can't substitute stale bytes.
drop_stale_api_content(prev)
repairs += 1
continue
merged.append(msg)
@@ -643,16 +648,16 @@ def strip_think_blocks(agent, content: str) -> str:
"""Remove reasoning/thinking blocks from content, returning only visible text.
Handles four cases:
1. Closed tag pairs (``<think>…</think>``) — the common path when
1. Closed tag pairs (`` <think>… ``) — the common path when
the provider emits complete reasoning blocks.
2. Unterminated open tag at a block boundary (start of text or
after a newline) — e.g. MiniMax M2.7 / NIM endpoints where the
closing tag is dropped. Everything from the open tag to end
of string is stripped. The block-boundary check mirrors
``gateway/stream_consumer.py``'s filter so models that mention
``<think>`` in prose aren't over-stripped.
`` <think>`` in prose aren't over-stripped.
3. Stray orphan open/close tags that slip through.
4. Tag variants: ``<think>``, ``<thinking>``, ``<reasoning>``,
4. Tag variants: `` <think>``, ``<thinking>``, ``<reasoning>``,
``<REASONING_SCRATCHPAD>``, ``<thought>`` (Gemma 4), all
case-insensitive.
@@ -672,6 +677,39 @@ def strip_think_blocks(agent, content: str) -> str:
"""
if not content:
return ""
# Coerce non-string content to text before any regex runs. Providers
# that return assistant ``content`` as a list of blocks (Anthropic via
# OpenRouter emits ``[{"type":"text",...}, {"type":"thinking",...}]``) or
# as a dict flow into this shared helper from several callers — most
# notably ``_interim_assistant_visible_text`` reading a *stored* history
# message whose content was persisted as a list. A raw list/dict reaching
# ``re.sub`` below raises ``TypeError: expected string or bytes-like
# object, got 'list'``, which the outer conversation loop swallows and
# retries forever (observed as an infinite "preparing terminal…" loop on
# Anthropic models via OpenRouter). Flatten here so every caller is safe.
if not isinstance(content, str):
if isinstance(content, list):
_parts: list[str] = []
for _part in content:
if isinstance(_part, str):
_parts.append(_part)
elif isinstance(_part, dict):
_ptype = str(_part.get("type") or "").strip().lower()
# Drop reasoning/thinking blocks outright — this function's
# whole job is to strip them, and their text lives under
# different keys ("thinking", "reasoning") per provider.
if _ptype in {"thinking", "reasoning", "redacted_thinking"}:
continue
_text = _part.get("text")
if isinstance(_text, str) and _text:
_parts.append(_text)
content = "".join(_parts)
elif isinstance(content, dict):
content = str(content.get("text") or content.get("content") or "")
else:
content = str(content)
if not content:
return ""
# 1. Closed tag pairs — case-insensitive for all variants so
# mixed-case tags (<THINK>, <Thinking>) don't slip through to
# the unterminated-tag pass and take trailing content with them.
+4 -4
View File
@@ -633,8 +633,8 @@ def _common_betas_for_base_url(
def _build_anthropic_client_with_bearer_hook(
token_provider,
base_url: str | None = None,
timeout: float | None = None,
base_url: str = None,
timeout: float = None,
*,
drop_context_1m_beta: bool = False,
):
@@ -709,8 +709,8 @@ def _build_anthropic_client_with_bearer_hook(
def build_anthropic_client(
api_key,
base_url: str | None = None,
timeout: float | None = None,
base_url: str = None,
timeout: float = None,
*,
drop_context_1m_beta: bool = False,
):
+16
View File
@@ -66,3 +66,19 @@ def safe_schedule_threadsafe(
coro.close()
log.log(log_level, "%s: %s", log_message, exc)
return None
def consume_detached_task_result(task: "asyncio.Future[Any]") -> None:
"""Retrieve a detached task's result without surfacing cancellation.
Used as an ``add_done_callback`` on tasks that were cancelled and
detached (e.g. an adapter close path that swallows ``CancelledError``
past its teardown deadline). Observing ``task.exception()`` prevents
"exception was never retrieved" noise on the event loop; cancellation
and any terminal error are deliberately swallowed the task's owner
already gave up on it.
"""
try:
task.exception()
except (asyncio.CancelledError, Exception):
pass
+42 -35
View File
@@ -3972,7 +3972,7 @@ async def _call_fallback_candidate_async(
def _try_payment_fallback(
failed_provider: str,
task: str | None = None,
task: str = None,
reason: str = "payment error",
) -> Tuple[Optional[Any], Optional[str], str]:
"""Try alternative providers after a payment/credit or connection error.
@@ -4023,7 +4023,7 @@ def _try_payment_fallback(
def _try_main_agent_model_fallback(
failed_provider: str,
task: str | None = None,
task: str = None,
reason: str = "error",
) -> Tuple[Optional[Any], Optional[str], str]:
"""Last-resort fallback to the user's main agent provider + model.
@@ -4665,12 +4665,12 @@ def _normalize_resolved_model(model_name: Optional[str], provider: str) -> Optio
def resolve_provider_client(
provider: str,
model: str | None = None,
model: str = None,
async_mode: bool = False,
raw_codex: bool = False,
explicit_base_url: str | None = None,
explicit_api_key: str | None = None,
api_mode: str | None = None,
explicit_base_url: str = None,
explicit_api_key: str = None,
api_mode: str = None,
main_runtime: Optional[Dict[str, Any]] = None,
is_vision: bool = False,
task: Optional[str] = None,
@@ -6086,11 +6086,11 @@ def _compat_model(client: Any, model: Optional[str], cached_default: Optional[st
def _get_cached_client(
provider: str,
model: str | None = None,
model: str = None,
async_mode: bool = False,
base_url: str | None = None,
api_key: str | None = None,
api_mode: str | None = None,
base_url: str = None,
api_key: str = None,
api_mode: str = None,
main_runtime: Optional[Dict[str, Any]] = None,
is_vision: bool = False,
task: Optional[str] = None,
@@ -6222,11 +6222,11 @@ _AUX_DIRECT_API_BASE_URLS: Dict[str, str] = {
def _resolve_task_provider_model(
task: str | None = None,
provider: str | None = None,
model: str | None = None,
base_url: str | None = None,
api_key: str | None = None,
task: str = None,
provider: str = None,
model: str = None,
base_url: str = None,
api_key: str = None,
) -> Tuple[str, Optional[str], Optional[str], Optional[str], Optional[str]]:
"""Determine provider + model for a call.
@@ -6253,6 +6253,13 @@ def _resolve_task_provider_model(
cfg_model = str(task_config.get("model", "")).strip() or None
cfg_base_url = str(task_config.get("base_url", "")).strip() or None
cfg_api_key = str(task_config.get("api_key", "")).strip() or None
# Resolve key_env → env var when api_key is not set directly
if not cfg_api_key:
cfg_key_env = str(
task_config.get("key_env") or task_config.get("api_key_env") or ""
).strip()
if cfg_key_env:
cfg_api_key = os.getenv(cfg_key_env, "").strip() or None
cfg_api_mode = str(task_config.get("api_mode", "")).strip() or None
# 'auto' is a sentinel meaning "inherit from main runtime / auto-detect", not
@@ -6900,23 +6907,23 @@ def _obj_get(obj: Any, key: str, default: Any = None) -> Any:
def call_llm(
task: str | None = None,
task: str = None,
*,
provider: str | None = None,
model: str | None = None,
base_url: str | None = None,
api_key: str | None = None,
provider: str = None,
model: str = None,
base_url: str = None,
api_key: str = None,
main_runtime: Optional[Dict[str, Any]] = None,
messages: list,
temperature: Optional[float] = None,
max_tokens: int | None = None,
tools: list | None = None,
timeout: float | None = None,
extra_body: dict | None = None,
max_tokens: int = None,
tools: list = None,
timeout: float = None,
extra_body: dict = None,
reasoning_config: Optional[dict] = None,
api_mode: str | None = None,
api_mode: str = None,
stream: bool = False,
stream_options: dict | None = None,
stream_options: dict = None,
) -> Any:
"""Centralized synchronous LLM call.
@@ -7567,19 +7574,19 @@ def extract_content_or_reasoning(response) -> str:
async def async_call_llm(
task: str | None = None,
task: str = None,
*,
provider: str | None = None,
model: str | None = None,
base_url: str | None = None,
api_key: str | None = None,
provider: str = None,
model: str = None,
base_url: str = None,
api_key: str = None,
main_runtime: Optional[Dict[str, Any]] = None,
messages: list,
temperature: Optional[float] = None,
max_tokens: int | None = None,
tools: list | None = None,
timeout: float | None = None,
extra_body: dict | None = None,
max_tokens: int = None,
tools: list = None,
timeout: float = None,
extra_body: dict = None,
reasoning_config: Optional[dict] = None,
) -> Any:
"""Centralized asynchronous LLM call.
+16
View File
@@ -789,6 +789,7 @@ def stream_converse_with_callbacks(
on_tool_start=None,
on_reasoning_delta=None,
on_interrupt_check=None,
on_event=None,
) -> SimpleNamespace:
"""Process a Bedrock ConverseStream event stream with real-time callbacks.
@@ -808,6 +809,12 @@ def stream_converse_with_callbacks(
on supported models (Claude 4.6+).
on_interrupt_check: Called on each event. Should return True if the
agent has been interrupted and streaming should stop.
on_event: Called once at the top of the loop body for EVERY yielded
Bedrock event (text/tool-input/reasoning/metadata deltas alike),
before any branching. Provides a wire-level liveness signal so an
external watchdog can distinguish "still receiving events" from
"stream wedged with no data". Errors raised by the callback are
swallowed so a liveness hook can never abort the stream.
Returns:
An OpenAI-compatible SimpleNamespace response, identical in shape to
@@ -823,6 +830,15 @@ def stream_converse_with_callbacks(
usage_data: Dict[str, int] = {}
for event in event_stream.get("stream", []):
# Wire-level liveness signal: fire on EVERY yielded event (text, tool
# input, reasoning, metadata) before branching so an external watchdog
# can tell a still-flowing stream from a wedged one. Best-effort — a
# liveness callback must never be able to abort the stream.
if on_event is not None:
try:
on_event()
except Exception:
pass
# Check for interrupt
if on_interrupt_check and on_interrupt_check():
break
+323
View File
@@ -0,0 +1,323 @@
"""Shared dollar-denominated usage model for the billing/subscription surfaces.
The single source of truth behind the ``/usage`` and ``/subscription`` usage
bars (TUI + CLI). User feedback (Jun 2026): the terminal surfaces show
**dollars**, never "credits", and every usage bar must make the monthly
subscription allowance and separately-purchased top-up dollars distinctly
visible.
Data source: the NAS account-info fetch (``NousPortalAccountInfo``), whose
``paid_service_access_info`` carries the three dollar magnitudes we render
(despite the legacy ``*_credits`` field names, these are USD floats):
- ``subscription_credits_remaining`` -> plan dollars left this month
- ``purchased_credits_remaining`` -> top-up dollars left (rolls over)
- ``total_usable_credits`` -> total spendable
plus ``subscription.monthly_credits`` (the plan's monthly $ allowance, the
denominator for the "% used" plan bar) and ``current_period_end`` (renewal).
Design: two SEPARATE bars (decided with the user) rather than one crammed
three-segment bar at terminal widths three same-glyph density segments are
unreadable. The plan bar is "spent vs allowance this month" (carries % used);
the top-up bar is "money you bought, doesn't expire". Each gets full
resolution and a single fill glyph, so the bar is never ambiguous and never
relies on color.
Fail-open everywhere: any missing/non-finite field degrades to fewer bars or a
magnitudes-only view; a logged-out / unreachable portal yields
``available=False`` and the surface shows nothing.
"""
from __future__ import annotations
import logging
import math
import os
from dataclasses import dataclass, field
from typing import Any, Optional
logger = logging.getLogger(__name__)
# Below this TOTAL spendable ($), a paid account is flagged "low" — the alert
# state that nudges top-up/upgrade before a mid-run cutoff. Product threshold
# (user feedback): "any amount below $5 should be an alert status."
LOW_BALANCE_THRESHOLD_USD = 5.0
def _finite(value: Any) -> Optional[float]:
"""Return value as a float iff it's a real finite number (not bool/NaN/Inf)."""
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
f = float(value)
return f if math.isfinite(f) else None
def _fmt_usd(value: Optional[float]) -> str:
"""``$X.YY`` for display. ``None`` -> ``$0.00`` (callers gate on presence)."""
return f"${(value or 0.0):,.2f}"
def format_renews(value: Optional[str]) -> Optional[str]:
"""Format an ISO date/timestamp as a human date, e.g. ``Jul 24, 2026``.
Accepts ``2026-07-24``, ``2026-07-24T11:05:01.000Z``, etc. Returns the raw
string unchanged if it can't be parsed (never raises), and ``None`` for
empty input.
"""
if not value:
return None
from datetime import datetime
text = str(value).strip()
if not text:
return None
iso = text[:-1] + "+00:00" if text.endswith("Z") else text
try:
dt = datetime.fromisoformat(iso)
except ValueError:
# Fall back to a bare date prefix (YYYY-MM-DD) if present.
try:
dt = datetime.strptime(text[:10], "%Y-%m-%d")
except ValueError:
return text
# %-d isn't portable to Windows; build the day without a leading zero.
return f"{dt.strftime('%b')} {dt.day}, {dt.year}"
@dataclass(frozen=True)
class UsageBar:
"""One full-resolution bar: ``spent`` of ``total``, plus a remaining figure.
``kind`` is ``"plan"`` (monthly allowance, shows % used) or ``"topup"``
(purchased dollars, no denominator ``spent`` is 0 and ``total`` ==
``remaining`` so it renders as a full bar of available balance).
"""
kind: str # "plan" | "topup"
remaining_usd: float
total_usd: float
spent_usd: float = 0.0
@property
def pct_used(self) -> Optional[int]:
if self.kind != "plan" or self.total_usd <= 0:
return None
return max(0, min(100, round(self.spent_usd / self.total_usd * 100)))
@property
def fill_fraction(self) -> float:
"""Fraction of the bar that should read as 'remaining' (filled)."""
if self.total_usd <= 0:
return 0.0
return max(0.0, min(1.0, self.remaining_usd / self.total_usd))
@dataclass(frozen=True)
class UsageModel:
"""Surface-agnostic dollar usage model shared by /usage and /subscription.
``status`` classifies the account for copy selection:
- ``"free"`` : no paid access / no subscription (free models only)
- ``"low"`` : paid, but total spendable < $5 (ALERT)
- ``"healthy"`` : paid, total spendable >= $5
- ``"depleted"`` : paid access lost (balance exhausted)
"""
available: bool
status: str = "free"
plan_name: Optional[str] = None
renews_at: Optional[str] = None
renews_display: Optional[str] = None
subscription_remaining_usd: Optional[float] = None
topup_remaining_usd: Optional[float] = None
total_spendable_usd: Optional[float] = None
plan_bar: Optional[UsageBar] = None
topup_bar: Optional[UsageBar] = None
@property
def has_topup(self) -> bool:
return bool(self.topup_remaining_usd and self.topup_remaining_usd > 0)
def usage_model_from_account(account_info: Any) -> UsageModel:
"""Build a :class:`UsageModel` from a ``NousPortalAccountInfo``. Fail-open.
Returns ``UsageModel(available=False)`` when there's no usable account info
(logged out, no entitlement block). Never raises.
"""
try:
if account_info is None or not getattr(account_info, "logged_in", False):
return UsageModel(available=False)
access = getattr(account_info, "paid_service_access_info", None)
sub = getattr(account_info, "subscription", None)
paid = getattr(account_info, "paid_service_access", None)
sub_remaining = _finite(getattr(access, "subscription_credits_remaining", None)) if access else None
topup_remaining = _finite(getattr(access, "purchased_credits_remaining", None)) if access else None
total_usable = _finite(getattr(access, "total_usable_credits", None)) if access else None
plan_name = getattr(sub, "plan", None) if sub is not None else None
renews_at = getattr(sub, "current_period_end", None) if sub is not None else None
monthly = _finite(getattr(sub, "monthly_credits", None)) if sub is not None else None
has_subscription = bool(plan_name) or (monthly is not None and monthly > 0)
# Total spendable: prefer the server's total; else sum the parts we have.
if total_usable is not None:
total_spendable = total_usable
else:
parts = [v for v in (sub_remaining, topup_remaining) if v is not None]
total_spendable = sum(parts) if parts else None
# Status classification.
if paid is False:
status = "depleted"
elif not has_subscription and not (topup_remaining and topup_remaining > 0):
# No plan and no purchased balance -> free-models-only.
status = "free"
elif total_spendable is not None and total_spendable < LOW_BALANCE_THRESHOLD_USD:
status = "low"
else:
status = "healthy"
# Plan bar — only with a positive monthly allowance AND a remaining we
# can place on it. spent = cap - remaining, clamped (a debt/over-cap
# balance reads as fully spent rather than a nonsensical negative).
plan_bar: Optional[UsageBar] = None
if monthly is not None and monthly > 0 and sub_remaining is not None:
remaining = max(0.0, min(monthly, sub_remaining))
plan_bar = UsageBar(
kind="plan",
remaining_usd=remaining,
total_usd=monthly,
spent_usd=max(0.0, monthly - sub_remaining),
)
# Top-up bar — only when there are purchased dollars to show. No
# denominator (top-up has no monthly cap), so it renders full = balance.
topup_bar: Optional[UsageBar] = None
if topup_remaining is not None and topup_remaining > 0:
topup_bar = UsageBar(
kind="topup",
remaining_usd=topup_remaining,
total_usd=topup_remaining,
spent_usd=0.0,
)
return UsageModel(
available=True,
status=status,
plan_name=plan_name,
renews_at=renews_at,
renews_display=format_renews(renews_at),
subscription_remaining_usd=sub_remaining,
topup_remaining_usd=topup_remaining,
total_spendable_usd=total_spendable,
plan_bar=plan_bar,
topup_bar=topup_bar,
)
except Exception:
logger.debug("usage ▸ model build failed (fail-open)", exc_info=True)
return UsageModel(available=False)
def build_usage_model(*, timeout: float = 10.0) -> UsageModel:
"""Fetch account-info and build the shared usage model. Fail-open.
Dev override: ``HERMES_DEV_CREDITS_FIXTURE`` short-circuits to a fixture so
every usage state is testable without a live account (mirrors the existing
``/usage`` credits-block fixture path).
"""
fixture = _dev_fixture_usage_model()
if fixture is not None:
return fixture
try:
from hermes_cli.auth import get_provider_auth_state
tok = (get_provider_auth_state("nous") or {}).get("access_token")
if not (isinstance(tok, str) and tok.strip()):
return UsageModel(available=False)
except Exception:
return UsageModel(available=False)
try:
import concurrent.futures
from hermes_cli.nous_account import get_nous_portal_account_info
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
account = pool.submit(get_nous_portal_account_info, force_fresh=True).result(timeout=timeout)
return usage_model_from_account(account)
except Exception:
logger.debug("usage ▸ portal fetch failed (fail-open)", exc_info=True)
return UsageModel(available=False)
# =============================================================================
# Dev fixtures (throwaway scaffolding — env-var driven, no live portal)
# =============================================================================
def _dev_fixture_usage_model() -> Optional[UsageModel]:
"""Map ``HERMES_DEV_CREDITS_FIXTURE`` to a usage model for offline UX work.
Recognized names: ``free | healthy | low | topup | depleted``. Returns
``None`` when the env var is unset (real portal path runs).
"""
name = (os.getenv("HERMES_DEV_CREDITS_FIXTURE") or "").strip().lower()
if not name:
return None
if name == "free":
return UsageModel(available=True, status="free", plan_name=None)
if name in ("healthy", "mid"):
return UsageModel(
available=True,
status="healthy",
plan_name="Plus",
renews_at="2026-07-01",
subscription_remaining_usd=14.0,
total_spendable_usd=14.0,
plan_bar=UsageBar(kind="plan", remaining_usd=14.0, total_usd=20.0, spent_usd=6.0),
)
if name in ("topup", "top-up"):
return UsageModel(
available=True,
status="healthy",
plan_name="Plus",
renews_at="2026-07-01",
subscription_remaining_usd=14.0,
topup_remaining_usd=12.0,
total_spendable_usd=26.0,
plan_bar=UsageBar(kind="plan", remaining_usd=14.0, total_usd=20.0, spent_usd=6.0),
topup_bar=UsageBar(kind="topup", remaining_usd=12.0, total_usd=12.0, spent_usd=0.0),
)
if name == "low":
return UsageModel(
available=True,
status="low",
plan_name="Plus",
renews_at="2026-07-01",
subscription_remaining_usd=3.4,
total_spendable_usd=3.4,
plan_bar=UsageBar(kind="plan", remaining_usd=3.4, total_usd=20.0, spent_usd=16.6),
)
if name == "depleted":
return UsageModel(
available=True,
status="depleted",
plan_name="Plus",
renews_at="2026-07-01",
subscription_remaining_usd=0.0,
total_spendable_usd=0.0,
plan_bar=UsageBar(kind="plan", remaining_usd=0.0, total_usd=20.0, spent_usd=20.0),
)
return None
+171 -8
View File
@@ -15,6 +15,7 @@ We keep them as :class:`decimal.Decimal` end-to-end and only format for display.
from __future__ import annotations
import logging
import os
import uuid
from dataclasses import dataclass, field
from decimal import Decimal, InvalidOperation
@@ -64,15 +65,47 @@ def format_money(value: Optional[Decimal]) -> str:
# =============================================================================
# resolvedVia → the human answer to "why THIS card?". Keys are the server's card
# resolution rungs (NAS card-on-file ladder); absent/unknown rungs render no label
# so the display degrades cleanly on servers that don't send resolvedVia yet.
_CARD_PROVENANCE_LABELS = {
"subPin": "the card on your subscription",
"customerDefault": "your default card saved on the portal",
"autoRefill": "your auto-reload card",
}
@dataclass(frozen=True)
class CardInfo:
brand: str
last4: str
# NAS card-on-file field (post card-resolver): which ladder rung found the
# card. Defaults off so pre-resolver payloads parse unchanged.
resolved_via: Optional[str] = None
@property
def masked(self) -> str:
# A Link payment method has no card number (last4 = "") — render the
# brand alone, not "Link ····".
if not self.last4:
return self.brand
return f"{self.brand} ····{self.last4}"
@property
def provenance(self) -> Optional[str]:
"""Human label for why this card was picked, or None (unknown rung /
server too old to say)."""
if self.resolved_via is None:
return None
return _CARD_PROVENANCE_LABELS.get(self.resolved_via)
@property
def display(self) -> str:
"""The one-line card display: ``Visa ····4242 — the card on your
subscription`` (or just the masked card when provenance is unknown)."""
label = self.provenance
return f"{self.masked}{label}" if label else self.masked
@dataclass(frozen=True)
class MonthlyCap:
@@ -81,11 +114,20 @@ class MonthlyCap:
is_default_ceiling: bool = False
@dataclass(frozen=True)
class AutoReloadCard:
kind: str # "canonical" | "distinct" | "none"
payment_method_id: Optional[str] = None
brand: Optional[str] = None
last4: Optional[str] = None
@dataclass(frozen=True)
class AutoReload:
enabled: bool = False
threshold_usd: Optional[Decimal] = None
reload_to_usd: Optional[Decimal] = None
card: Optional[AutoReloadCard] = None
@dataclass(frozen=True)
@@ -100,7 +142,8 @@ class BillingState:
org_id: Optional[str] = None
org_slug: Optional[str] = None
org_name: Optional[str] = None
role: Optional[str] = None # "OWNER" | "ADMIN" | "MEMBER"
role: Optional[str] = None # "OWNER" | "ADMIN" | "FINANCE_ADMIN" | "SECURITY_ADMIN" | "MEMBER"
can_change_plan_raw: Optional[bool] = None
balance_usd: Optional[Decimal] = None
cli_billing_enabled: bool = False
charge_presets: tuple[Decimal, ...] = ()
@@ -115,17 +158,33 @@ class BillingState:
@property
def is_admin(self) -> bool:
"""True for OWNER/ADMIN — the roles that can manage billing."""
"""Deprecated/display only — a legacy OWNER/ADMIN check.
NOT a capability check; use :attr:`can_change_plan` for gating billing
plan-change actions.
"""
return (self.role or "").upper() in ("OWNER", "ADMIN")
@property
def can_change_plan(self) -> bool:
"""Server capability when supplied; otherwise the legacy role fallback."""
if self.can_change_plan_raw is not None:
return self.can_change_plan_raw
return self.is_admin
@property
def can_charge(self) -> bool:
"""True when the UI should offer charge/auto-reload actions.
Admin role AND the per-org kill-switch on. (The server still enforces;
this is just for graying out actions the user can't take.)
Uses the server-granted plan-change capability (``can_change_plan``,
which itself falls back to the legacy OWNER/ADMIN role check when the
server omits ``canChangePlan``) AND the per-org kill-switch. This lets
the server grant charge capability to non-OWNER/ADMIN roles (e.g.
FINANCE_ADMIN) via ``canChangePlan``, instead of hard-coding the
deprecated 3-role admin check. (The server still enforces; this is
just for graying out actions the user can't take.)
"""
return self.is_admin and self.cli_billing_enabled
return self.can_change_plan and self.cli_billing_enabled
def _parse_card(raw: Any) -> Optional[CardInfo]:
@@ -133,9 +192,13 @@ def _parse_card(raw: Any) -> Optional[CardInfo]:
return None
brand = raw.get("brand")
last4 = raw.get("last4")
if isinstance(brand, str) and isinstance(last4, str):
return CardInfo(brand=brand, last4=last4)
return None
if not (isinstance(brand, str) and isinstance(last4, str)):
return None
# Post-resolver fields — all optional so both payload generations parse.
resolved_via = raw.get("resolvedVia")
if not isinstance(resolved_via, str):
resolved_via = None
return CardInfo(brand=brand, last4=last4, resolved_via=resolved_via)
def _parse_monthly_cap(raw: Any) -> Optional[MonthlyCap]:
@@ -155,6 +218,27 @@ def _parse_auto_reload(raw: Any) -> Optional[AutoReload]:
enabled=bool(raw.get("enabled")),
threshold_usd=parse_money(raw.get("thresholdUsd")),
reload_to_usd=parse_money(raw.get("reloadToUsd")),
card=_parse_auto_reload_card(raw.get("card")),
)
def _parse_auto_reload_card(raw: Any) -> Optional[AutoReloadCard]:
if not isinstance(raw, dict):
return None
kind = raw.get("kind")
if kind not in ("canonical", "distinct", "none"):
return None
if kind in ("canonical", "none"):
return AutoReloadCard(kind=kind)
payment_method_id = raw.get("paymentMethodId")
brand = raw.get("brand")
last4 = raw.get("last4")
return AutoReloadCard(
kind=kind,
payment_method_id=payment_method_id if isinstance(payment_method_id, str) else None,
brand=brand if isinstance(brand, str) else None,
last4=last4 if isinstance(last4, str) else None,
)
@@ -179,6 +263,11 @@ def billing_state_from_payload(
org_slug=org.get("slug"),
org_name=org.get("name"),
role=org.get("role"),
can_change_plan_raw=(
payload.get("canChangePlan")
if isinstance(payload.get("canChangePlan"), bool)
else None
),
balance_usd=parse_money(payload.get("balanceUsd")),
cli_billing_enabled=bool(payload.get("cliBillingEnabled")),
charge_presets=tuple(presets),
@@ -202,7 +291,15 @@ def build_billing_state(*, timeout: float = 15.0) -> BillingState:
Returns ``BillingState(logged_in=False)`` when not logged in. On a portal/HTTP
failure, returns ``logged_in=False`` with ``error`` set so the surface can show
a clear message rather than crashing.
Dev override: ``HERMES_DEV_BILLING_FIXTURE`` short-circuits to a fixture so the
card-on-file / admin / scope states are testable offline (mirrors
``HERMES_DEV_CREDITS_FIXTURE`` for the usage model).
"""
fixture = _dev_fixture_billing_state()
if fixture is not None:
return fixture
try:
from hermes_cli.nous_billing import (
BillingAuthError,
@@ -243,6 +340,72 @@ def _fallback_portal_url(base: str) -> str:
return f"{base.rstrip('/')}/billing?topup=open"
# =============================================================================
# Dev fixtures (throwaway scaffolding — env-var driven, no live portal)
# =============================================================================
def _dev_fixture_billing_state() -> Optional[BillingState]:
"""Map ``HERMES_DEV_BILLING_FIXTURE`` to a :class:`BillingState` for offline UX.
Recognized names::
nocard logged in · billing on · admin · NO card on file
card card on file · auto-reload off
card-autoreload card on file · auto-reload on
notadmin logged in · MEMBER role (billing actions disabled)
billing-off logged in · admin · per-org kill-switch OFF
logged-out not logged in
Returns ``None`` when the env var is unset (the real portal path runs).
Mirrors ``HERMES_DEV_CREDITS_FIXTURE``; the usage *bar* still comes from
``HERMES_DEV_CREDITS_FIXTURE`` (set both to pair a bar with a billing state).
"""
name = (os.getenv("HERMES_DEV_BILLING_FIXTURE") or "").strip().lower()
if not name:
return None
# Shared fixture portal host (matches subscription_view._DEV_FIXTURE_PORTAL —
# prod host, not staging; the ?topup=open suffix is the /topup deep-link).
portal = "https://portal.nousresearch.com/billing?topup=open"
common: dict[str, Any] = dict(
org_id="org_acme",
org_slug="acme",
org_name="Acme Inc",
role="OWNER",
balance_usd=Decimal("3.40"),
cli_billing_enabled=True,
charge_presets=(Decimal("10"), Decimal("25"), Decimal("50")),
min_usd=Decimal("5"),
max_usd=Decimal("500"),
portal_url=portal,
)
card = CardInfo(brand="Visa", last4="4242")
autoreload_on = AutoReload(enabled=True, threshold_usd=Decimal("5"), reload_to_usd=Decimal("25"))
if name in ("logged-out", "logged_out", "loggedout"):
return BillingState(logged_in=False)
if name == "nocard":
return BillingState(logged_in=True, card=None, **common)
if name == "card":
return BillingState(logged_in=True, card=card, **common)
if name in ("card-sub", "card_sub"):
# Post-resolver: the card came from the subscription (provenance label).
_sub_card = CardInfo(brand="Visa", last4="4242", resolved_via="subPin")
return BillingState(logged_in=True, card=_sub_card, **common)
if name in ("card-autoreload", "card_autoreload", "autoreload"):
return BillingState(logged_in=True, card=card, auto_reload=autoreload_on, **common)
if name in ("notadmin", "not-admin", "member"):
opts = {**common, "role": "MEMBER"}
return BillingState(logged_in=True, card=card, **opts)
if name in ("billing-off", "billing_off", "off"):
opts = {**common, "cli_billing_enabled": False}
return BillingState(logged_in=True, card=None, **opts)
# Unknown name → logged-out so the misconfiguration is visible.
return BillingState(logged_in=False, error=f"unknown HERMES_DEV_BILLING_FIXTURE: {name}")
# =============================================================================
# Idempotency
# =============================================================================
+425 -75
View File
@@ -30,12 +30,15 @@ from hermes_cli.timeouts import get_provider_request_timeout, get_provider_stale
from hermes_constants import PARTIAL_STREAM_STUB_ID, FINISH_REASON_LENGTH
from agent.error_classifier import FailoverReason
from agent.errors import EmptyStreamError
from agent.turn_context import substitute_api_content
from agent.gemini_native_adapter import is_native_gemini_base_url
from agent.model_metadata import is_local_endpoint
from agent.message_content import flatten_message_text
from agent.message_sanitization import (
_sanitize_surrogates,
_repair_tool_call_arguments,
)
from agent.stream_single_writer import claim_stream_writer, stream_writer_is_current
from tools.terminal_tool import is_persistent_env
from utils import base_url_host_matches, base_url_hostname, env_float, env_int
@@ -263,6 +266,106 @@ def _check_stale_giveup(agent) -> None:
)
def _derive_stream_stale_timeout(agent, api_kwargs: dict) -> float:
"""Stale-stream patience for a provider that is never a local endpoint.
Mirrors the main streaming path's derivation — provider config → env base
context-size scaling reasoning-model floor minus the local-endpoint
``float('inf')``/900s disable branch, which cannot apply to Bedrock (its
endpoint is always the AWS cloud). Factored so the Bedrock streaming
watchdog shares the exact same patience budget as the OpenAI/Anthropic
stale-stream detector below.
"""
_cfg_stale = get_provider_stale_timeout(agent.provider, agent.model)
if _cfg_stale is not None:
_base = _cfg_stale
else:
_base = env_float("HERMES_STREAM_STALE_TIMEOUT", 180.0)
_est_tokens = estimate_request_context_tokens(api_kwargs)
if _est_tokens > 100_000:
_timeout = max(_base, 300.0)
elif _est_tokens > 50_000:
_timeout = max(_base, 240.0)
else:
_timeout = _base
from agent.reasoning_timeouts import get_reasoning_stale_timeout_floor
# Resolve the model id from BOTH the OpenAI/Anthropic key (``model``) and
# the Bedrock key (``modelId``). OpenAI/Anthropic wins first via the ``or``
# chain, so those paths are unchanged. Bedrock carries the model as a
# dotted, region-prefixed inference-profile id (e.g.
# ``us.anthropic.claude-opus-4-6-v1:0``) that the floor's start-of-slug
# regex cannot match directly — normalize it to a canonical slug first.
_model_id = api_kwargs.get("model") or api_kwargs.get("modelId") or ""
_reasoning_floor = get_reasoning_stale_timeout_floor(_model_id)
if _reasoning_floor is None and api_kwargs.get("modelId"):
_reasoning_floor = _bedrock_reasoning_stale_floor(api_kwargs["modelId"])
if _reasoning_floor is not None:
_timeout = max(_timeout, _reasoning_floor)
return _timeout
def _bedrock_reasoning_stale_floor(model_id: object) -> "float | None":
"""Map a Bedrock inference-profile id to its reasoning stale-timeout floor.
Bedrock carries the model as a dotted, region-prefixed id such as
``us.anthropic.claude-opus-4-6-v1:0``, whereas
:func:`get_reasoning_stale_timeout_floor` anchors its slug patterns at the
start of a bare slug (``claude-opus-4``). Strip the region prefix
(``us.``/``eu.``/``apac.``/...) and try two candidate slugs against the
floor:
* the segment after the provider namespace (``claude-opus-4-6-v1:0``)
matches Anthropic-style slugs whose floor key excludes the provider
(``claude-opus-4``); and
* the region-stripped id with the provider dot rewritten to a dash
(``deepseek-r1-v1:0``) matches provider-qualified floor keys
(``deepseek-r1``).
The floor's right-anchor (``$`` or ``-``/``.``/``_``) tolerates the
trailing date-stamp / ``-v1:0`` version suffix, so no suffix stripping is
needed. First non-None wins; returns None for unknown models.
The floor table mixes version-separator conventions: some keys are
keyed with a dashed version (``claude-opus-4``) while others embed a
dotted version (``claude-sonnet-4.5``, ``claude-sonnet-4.6``). Bedrock
always dashes the version (``claude-sonnet-4-5-v1:0``), so for every
candidate slug we also try the alternate version-separator form
digit-dash-digit rewritten to digit-dot-digit and vice-versa so a
dashed Bedrock id matches a dotted floor key (and the reverse). The
rewrite only touches version-number separators (a dash/dot flanked by
digits), never other dashes in the slug, so ``claude-sonnet`` is left
intact while ``4-5`` becomes ``4.5``.
"""
from agent.reasoning_timeouts import get_reasoning_stale_timeout_floor
if not model_id or not isinstance(model_id, str):
return None
name = model_id.strip().lower()
for prefix in ("us.", "eu.", "apac.", "ap.", "global.", "jp."):
if name.startswith(prefix):
name = name[len(prefix):]
break
base_candidates = [name]
if "." in name:
base_candidates.append(name.rsplit(".", 1)[1]) # claude-opus-4-6-v1:0
base_candidates.append(name.replace(".", "-", 1)) # deepseek-r1-v1:0
candidates: list[str] = []
for cand in base_candidates:
# Try the slug as-is plus both alternate version-separator forms.
# ``4-5`` <-> ``4.5`` only; a dash/dot not flanked by digits is
# left alone (e.g. ``claude-sonnet`` stays dashed).
dashed_to_dotted = re.sub(r"(?<=\d)-(?=\d)", ".", cand)
dotted_to_dashed = re.sub(r"(?<=\d)\.(?=\d)", "-", cand)
for form in (cand, dashed_to_dotted, dotted_to_dashed):
if form not in candidates:
candidates.append(form)
for cand in candidates:
floor = get_reasoning_stale_timeout_floor(cand)
if floor is not None:
return floor
return None
def _dispatch_nonstreaming_api_request(agent, api_kwargs: dict, *, make_client):
"""Run one non-streaming LLM request for the active api_mode and return it.
@@ -270,13 +373,14 @@ def _dispatch_nonstreaming_api_request(agent, api_kwargs: dict, *, make_client):
inline path (``direct_api_call``) so the per-api_mode dispatch codex /
anthropic / bedrock / MoA / OpenAI-compatible lives in exactly one place.
``make_client(reason)`` builds the per-request OpenAI client for the codex
and OpenAI-compatible branches; the worker path uses it to register the
client with its stranger-thread abort machinery, the inline path uses it to
capture the client for its own ``finally`` close. The anthropic / bedrock /
MoA branches manage their own clients and never call it. All interrupt,
abort, cancellation, and close semantics stay in the callers this helper
only issues the request.
``make_client(reason, kind=...)`` builds the per-request client for the
codex / OpenAI-compatible (``kind="openai"``) and anthropic
(``kind="anthropic_messages"``) branches; the worker path uses it to
register the client with its stranger-thread abort machinery, the inline
path uses it to capture the client for its own ``finally`` close. The
bedrock / MoA branches manage their own clients and never call it. All
interrupt, abort, cancellation, and close semantics stay in the callers
this helper only issues the request.
"""
if agent.api_mode == "codex_responses":
request_client = make_client("codex_stream_request")
@@ -286,7 +390,13 @@ def _dispatch_nonstreaming_api_request(agent, api_kwargs: dict, *, make_client):
on_first_delta=getattr(agent, "_codex_on_first_delta", None),
)
if agent.api_mode == "anthropic_messages":
return agent._anthropic_messages_create(api_kwargs)
# #67142: use a request-local Anthropic client so the stale/interrupt
# watchdog aborts sockets from the stranger thread while the worker
# owns the SDK close — never closing the shared client mid-flight.
request_client = make_client(
"anthropic_messages_request", kind="anthropic_messages"
)
return agent._anthropic_messages_create(api_kwargs, client=request_client)
if agent.api_mode == "bedrock_converse":
# Bedrock uses boto3 directly — no OpenAI client needed.
# normalize_converse_response produces an OpenAI-compatible
@@ -356,7 +466,11 @@ def direct_api_call(agent, api_kwargs: dict):
if request_client is not None:
agent._abort_request_openai_client(request_client, reason=reason)
def _make_client(reason: str):
def _make_client(reason: str, kind: str = "openai"):
# direct_api_call only runs for OpenAI-wire chat_completions cron
# requests (see should_use_direct_api_call), so the anthropic branch of
# the dispatch — the only caller that passes kind — is never reached
# here; the ``kind`` parameter exists purely for signature parity.
client = agent._create_request_openai_client(reason=reason, api_kwargs=api_kwargs)
with request_client_lock:
request_client_holder["client"] = client
@@ -415,6 +529,10 @@ def interruptible_api_call(agent, api_kwargs: dict):
_check_stale_giveup(agent)
request_client_holder = {"client": None, "owner_tid": None}
# Transport kind of the registered request client ("openai" or
# "anthropic_messages") so _close_request_client_once routes to the right
# abort/close helpers (#67142).
request_client_kind = {"value": "openai"}
request_client_lock = threading.Lock()
# Request-local cancellation flag. Distinct from agent._interrupt_requested
# because that flag is cleared at run_conversation() turn boundaries, but
@@ -426,9 +544,10 @@ def interruptible_api_call(agent, api_kwargs: dict):
# hang.)
_request_cancelled = {"value": False}
def _set_request_client(client):
def _set_request_client(client, *, kind: str = "openai"):
with request_client_lock:
request_client_holder["client"] = client
request_client_kind["value"] = kind
# #29507: stamp the owning thread so a stranger-thread interrupt
# only shuts the connection down rather than racing the worker
# for FD ownership during ``client.close()``.
@@ -462,24 +581,34 @@ def interruptible_api_call(agent, api_kwargs: dict):
request_client_holder["owner_tid"] = None
if request_client is None:
return
if stranger_thread:
kind = request_client_kind.get("value", "openai")
if kind == "anthropic_messages":
if stranger_thread:
agent._abort_request_anthropic_client(request_client, reason=reason)
else:
agent._close_request_anthropic_client(request_client, reason=reason)
elif stranger_thread:
agent._abort_request_openai_client(request_client, reason=reason)
else:
agent._close_request_openai_client(request_client, reason=reason)
def _call():
try:
# _set_request_client registers each per-request OpenAI client with
# the stranger-thread abort machinery above; the shared dispatch
# helper builds it via this callback so the interrupt / stale-call
# detectors can force-close the worker's connection.
# _set_request_client registers each per-request client with the
# stranger-thread abort machinery above; the shared dispatch helper
# builds it via this callback (openai- or anthropic-kind) so the
# interrupt / stale-call detectors can force-close the worker's
# connection without touching the shared client (#67142).
result["response"] = _dispatch_nonstreaming_api_request(
agent,
api_kwargs,
make_client=lambda reason: _set_request_client(
agent._create_request_openai_client(
make_client=lambda reason, kind="openai": _set_request_client(
agent._create_request_anthropic_client(reason=reason)
if kind == "anthropic_messages"
else agent._create_request_openai_client(
reason=reason, api_kwargs=api_kwargs
)
),
kind=kind,
),
)
except Exception as e:
@@ -791,11 +920,10 @@ def interruptible_api_call(agent, api_kwargs: dict):
f"Aborting call."
)
try:
if agent.api_mode == "anthropic_messages":
agent._anthropic_client.close()
agent._rebuild_anthropic_client()
else:
_close_request_client_once("stale_call_kill")
# #67142: routes by client kind — anthropic now aborts the
# request-local client's sockets from this poll (stranger)
# thread instead of closing the shared _anthropic_client.
_close_request_client_once("stale_call_kill")
except Exception:
pass
# Circuit breaker (#58962): count the stale kill. See the
@@ -831,13 +959,12 @@ def interruptible_api_call(agent, api_kwargs: dict):
)
# Force-close the in-flight worker-local HTTP connection to stop
# token generation without poisoning the shared client used to
# seed future retries.
# seed future retries. #67142: for anthropic this aborts the
# request-local client's sockets from this poll (stranger) thread
# rather than closing the shared _anthropic_client, which could
# release a TLS FD mid-SSL-BIO and corrupt an unrelated SQLite DB.
try:
if agent.api_mode == "anthropic_messages":
agent._anthropic_client.close()
agent._rebuild_anthropic_client()
else:
_close_request_client_once("interrupt_abort")
_close_request_client_once("interrupt_abort")
except Exception:
pass
raise InterruptedError("Agent interrupted during API call")
@@ -1125,7 +1252,7 @@ def build_assistant_message(agent, assistant_message, finish_reason: str) -> dic
# reasoning fields are present (some models/providers embed thinking
# directly in the content rather than returning separate API fields).
if not reasoning_text:
content = assistant_message.content or ""
content = flatten_message_text(getattr(assistant_message, "content", None))
think_blocks = re.findall(r'<think>(.*?)</think>', content, flags=re.DOTALL)
if think_blocks:
combined = "\n\n".join(b.strip() for b in think_blocks if b.strip())
@@ -1151,7 +1278,7 @@ def build_assistant_message(agent, assistant_message, finish_reason: str) -> dic
# Sanitize surrogates from API response — some models (e.g. Kimi/GLM via Ollama)
# can return invalid surrogate code points that crash json.dumps() on persist.
_raw_content = assistant_message.content or ""
_raw_content = flatten_message_text(getattr(assistant_message, "content", None))
_san_content = _sanitize_surrogates(_raw_content)
if reasoning_text:
reasoning_text = _sanitize_surrogates(reasoning_text)
@@ -1803,6 +1930,15 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str:
# and every Hermes-internal underscore-prefixed scaffolding key.
for schema_foreign in ("tool_name", "codex_reasoning_items", "codex_message_items", "timestamp"):
api_msg.pop(schema_foreign, None)
# api_content (the persist-what-you-send sidecar) carries the
# exact bytes every main-loop call sent for this message —
# substitute it before dropping the key (Hermes bookkeeping,
# never a provider field), mirroring the loop's api_messages
# build. Popping without substituting would send CLEAN content
# here, diverging the summary request's prefix at the EARLIEST
# sidecar-carrying message and re-prefilling the whole transcript
# at exactly the moment the context is largest.
substitute_api_content(api_msg)
for internal_key in [k for k in api_msg if isinstance(k, str) and k.startswith("_")]:
api_msg.pop(internal_key, None)
if _needs_sanitize:
@@ -2023,6 +2159,11 @@ def cleanup_task_resources(agent, task_id: str) -> None:
``terminal.lifetime_seconds`` is exceeded. Non-persistent backends are
torn down per-turn as before to prevent resource leakage (the original
intent of this hook for the Morph backend, see commit fbd3a2fd).
Skips ``cleanup_browser`` in headed mode so the browser window stays
visible between turns. The inactivity reaper in
``browser_tool._cleanup_inactive_browser_sessions`` still handles
idle sessions.
"""
try:
if is_persistent_env(task_id):
@@ -2037,7 +2178,20 @@ def cleanup_task_resources(agent, task_id: str) -> None:
if agent.verbose_logging:
logger.warning(f"Failed to cleanup VM for task {task_id}: {e}")
try:
_ra().cleanup_browser(task_id)
headed = False
try:
from tools.browser_tool import _is_headed_mode
headed = _is_headed_mode()
except Exception:
headed = bool(os.environ.get("AGENT_BROWSER_HEADED"))
if headed:
if agent.verbose_logging:
logging.debug(
f"Skipping per-turn cleanup_browser for headed session {task_id}; "
f"idle reaper will handle it."
)
else:
_ra().cleanup_browser(task_id)
except Exception as e:
if agent.verbose_logging:
logger.warning(f"Failed to cleanup browser for task {task_id}: {e}")
@@ -2090,6 +2244,24 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
result = {"response": None, "error": None}
first_delta_fired = {"done": False}
deltas_were_sent = {"yes": False}
# Wire-level liveness for the boto3 converse_stream worker: the worker
# thread blocks inside ``for event in event_stream`` with NO read
# timeout, so a provider that opens the stream then stops yielding
# events wedges the thread forever. on_event stamps this on EVERY
# yielded Bedrock event (text/tool/metadata) — the poll loop below
# trips a watchdog when the gap exceeds the stale timeout.
_bedrock_last_event = {"t": time.time()}
# Region captured for the poll-loop client eviction below. Read
# (not popped) here so the worker's own pop inside _bedrock_call still
# resolves the same value.
_bedrock_region = api_kwargs.get("__bedrock_region__", "us-east-1")
# Same patience budget as the OpenAI/Anthropic stale detector.
_bedrock_stale_timeout = _derive_stream_stale_timeout(agent, api_kwargs)
# Cross-turn stale-stream circuit breaker (#58962): a pre-elevated
# streak from prior wedged turns aborts before we even start — mirrors
# the entry check on the OpenAI/Anthropic path below.
_check_stale_giveup(agent)
def _fire_first():
if not first_delta_fired["done"] and on_first_delta:
@@ -2146,7 +2318,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
# Claim the delta sink for this bedrock stream (#65991) so a
# superseded attempt's callbacks are fenced by the sink guard.
agent._claim_stream_writer()
claim_stream_writer(agent)
def _on_text(text):
_fire_first()
@@ -2167,6 +2339,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
on_tool_start=_on_tool,
on_reasoning_delta=_on_reasoning if agent.reasoning_callback or agent.stream_delta_callback else None,
on_interrupt_check=lambda: agent._interrupt_requested,
on_event=lambda: _bedrock_last_event.__setitem__("t", time.time()),
)
except Exception as e:
result["error"] = e
@@ -2177,6 +2350,56 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
t.join(timeout=0.3)
if agent._interrupt_requested:
raise InterruptedError("Agent interrupted during Bedrock API call")
# Liveness watchdog: no Bedrock event for longer than the stale
# timeout means the stream has wedged (open socket, keep-alives but
# no data, or a silently hung provider). Without this the worker
# blocks in ``for event in event_stream`` indefinitely.
_stale_elapsed = time.time() - _bedrock_last_event["t"]
if _stale_elapsed > _bedrock_stale_timeout:
logger.warning(
"Bedrock stream stale for %.0fs (threshold %.0fs) — no events "
"received. region=%s model=%s. Aborting call.",
_stale_elapsed, _bedrock_stale_timeout,
_bedrock_region, api_kwargs.get("modelId", "unknown"),
)
agent._buffer_status(
f"⚠️ No events from Bedrock for {int(_stale_elapsed)}s "
f"(model: {api_kwargs.get('modelId', 'unknown')}). Aborting..."
)
# Count the stale kill in the SAME cross-turn breaker as the
# OpenAI/Anthropic path (#58962).
_bump_stale_streak(agent)
# Best-effort: evict the region's cached bedrock-runtime client
# so the NEXT call reconnects with a fresh pool. NOTE: this does
# NOT abort the in-flight botocore EventStream the worker thread
# is blocked on — botocore exposes no external cancellation for
# it — so the daemon worker keeps reading until its socket read
# ultimately errors. We therefore end THIS call by raising
# below and let the streak+give-up breaker escalate across turns.
try:
from agent.bedrock_adapter import invalidate_runtime_client
invalidate_runtime_client(_bedrock_region)
except Exception as _inval_exc:
logger.debug(
"bedrock: stale client eviction failed: %s", _inval_exc
)
# Reset the timer so a repeated trip (should the worker somehow
# survive) waits a fresh interval rather than re-firing instantly.
_bedrock_last_event["t"] = time.time()
# Escalate across turns: raises RuntimeError once the streak
# crosses HERMES_STREAM_STALE_GIVEUP, so a persistently wedged
# Bedrock provider aborts fast instead of re-waiting the timeout.
_check_stale_giveup(agent)
# Streak still under the give-up threshold: end THIS call with a
# TimeoutError so the outer retry loop / next turn re-evaluates
# and the streak carries forward. Break rather than keep polling
# a worker we cannot abort.
result["error"] = TimeoutError(
f"Bedrock stream produced no events for {int(_stale_elapsed)}s "
f"(threshold {int(_bedrock_stale_timeout)}s) — aborting stalled "
f"stream so the retry/fallback path can recover."
)
break
# Worker exited before the poll loop observed the interrupt flag. The
# Bedrock stream callback breaks out and returns a PARTIAL response
# without raising on interrupt (see bedrock_adapter.py
@@ -2189,6 +2412,11 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
raise InterruptedError("Agent interrupted during Bedrock API call (post-worker)")
if result["error"] is not None:
raise result["error"]
# Success — clear the cross-turn breaker (#58962): Bedrock proved
# responsive. Mirrors the OpenAI/Anthropic success reset below so a
# recovered provider doesn't carry a stale streak into later turns.
if result["response"] is not None:
_reset_stale_streak(agent)
return result["response"]
result = {"response": None, "error": None, "partial_tool_names": []}
@@ -2199,6 +2427,10 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
_check_stale_giveup(agent)
request_client_holder = {"client": None, "diag": None, "owner_tid": None}
# Transport kind of the registered request client — see the non-streaming
# variant. Routes _close_request_client_once to anthropic vs openai abort/
# close helpers (#67142).
request_client_kind = {"value": "openai"}
request_client_lock = threading.Lock()
# Request-local cancellation flag — see interruptible_api_call for the full
# rationale. The streaming retry loop is where the 7-minute cascading-
@@ -2209,9 +2441,10 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
# exit immediately instead of retrying. (PR #6600.)
_request_cancelled = {"value": False}
def _set_request_client(client):
def _set_request_client(client, *, kind: str = "openai"):
with request_client_lock:
request_client_holder["client"] = client
request_client_kind["value"] = kind
# See #29507 explanation in the non-streaming variant above.
request_client_holder["owner_tid"] = threading.get_ident()
return client
@@ -2234,7 +2467,13 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
request_client_holder["owner_tid"] = None
if request_client is None:
return
if stranger_thread:
kind = request_client_kind.get("value", "openai")
if kind == "anthropic_messages":
if stranger_thread:
agent._abort_request_anthropic_client(request_client, reason=reason)
else:
agent._close_request_anthropic_client(request_client, reason=reason)
elif stranger_thread:
agent._abort_request_openai_client(request_client, reason=reason)
else:
agent._close_request_openai_client(request_client, reason=reason)
@@ -2253,6 +2492,68 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
# resolved, so the builder degrades to its plain default if it ever runs
# first.
_stream_stale_timeout = None
stream_attempt_lock = threading.Lock()
stream_attempt_state = {
"current": 0,
"cancelled": set(),
"discarded_chunks": 0,
"discarded_bytes": 0,
}
def _start_stream_attempt() -> int:
with stream_attempt_lock:
stream_attempt_state["current"] += 1
return int(stream_attempt_state["current"])
def _cancel_current_stream_attempt(reason: str) -> None:
with stream_attempt_lock:
current = int(stream_attempt_state.get("current") or 0)
if current:
stream_attempt_state["cancelled"].add(current)
if current:
logger.debug(
"Marked stream attempt %s cancelled: %s",
current,
reason,
)
def _stream_attempt_is_active(stream_attempt_id: int) -> bool:
with stream_attempt_lock:
return (
stream_attempt_id == int(stream_attempt_state.get("current") or 0)
and stream_attempt_id not in stream_attempt_state["cancelled"]
)
def _stream_attempt_was_cancelled(stream_attempt_id: int) -> bool:
with stream_attempt_lock:
return stream_attempt_id in stream_attempt_state["cancelled"]
def _discard_stale_stream_chunk(stream_attempt_id: int, chunk) -> None:
try:
chunk_bytes = len(repr(chunk))
except Exception:
chunk_bytes = 0
with stream_attempt_lock:
stream_attempt_state["discarded_chunks"] += 1
stream_attempt_state["discarded_bytes"] += chunk_bytes
discarded_chunks = stream_attempt_state["discarded_chunks"]
discarded_bytes = stream_attempt_state["discarded_bytes"]
if discarded_chunks == 1:
logger.warning(
"Discarding chunk from superseded stream attempt %s "
"(discarded_chunks=%s discarded_bytes=%s)",
stream_attempt_id,
discarded_chunks,
discarded_bytes,
)
else:
logger.debug(
"Discarded stale stream chunk from attempt %s "
"(discarded_chunks=%s discarded_bytes=%s)",
stream_attempt_id,
discarded_chunks,
discarded_bytes,
)
def _fire_first_delta():
if not first_delta_fired["done"] and on_first_delta:
@@ -2262,7 +2563,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
except Exception:
pass
def _call_chat_completions():
def _call_chat_completions(stream_attempt_id: int):
"""Stream a chat completions response."""
import httpx as _httpx
# Per-provider / per-model request_timeout_seconds (from config.yaml)
@@ -2350,7 +2651,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
# stream is somehow still alive (a stale-stream reconnect whose socket
# abort raced), this claim supersedes it so its late chunks are fenced
# out of the turn instead of interleaving with ours.
_writer_token = agent._claim_stream_writer()
_writer_token = claim_stream_writer(agent)
# Some OpenAI-compatible adapters (for example copilot-acp, and the MoA
# openai-codex aggregator) accept stream=True but still return a
@@ -2427,7 +2728,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
# (#65991): this attempt has been superseded, so it must neither
# fire deltas (incl. the tool-suppressed raw-callback path below)
# nor keep consuming a stream that would interleave into the turn.
if not agent._stream_writer_is_current(_writer_token):
if not stream_writer_is_current(agent, _writer_token):
logger.warning(
"Streaming attempt superseded by a newer stream; stopping "
"consumption to preserve the single-writer invariant "
@@ -2459,6 +2760,10 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
if agent._interrupt_requested:
break
if not _stream_attempt_is_active(stream_attempt_id):
_discard_stale_stream_chunk(stream_attempt_id, chunk)
continue
if not chunk.choices:
if hasattr(chunk, "model") and chunk.model:
model_name = chunk.model
@@ -2584,6 +2889,11 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
if hasattr(chunk, "usage") and chunk.usage:
usage_obj = chunk.usage
if _stream_attempt_was_cancelled(stream_attempt_id):
raise _httpx.RemoteProtocolError(
f"stream attempt {stream_attempt_id} was superseded"
)
# Build mock response matching non-streaming shape
full_content = "".join(content_parts) or None
mock_tool_calls = None
@@ -2711,13 +3021,18 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
usage=usage_obj,
)
def _call_anthropic():
def _call_anthropic(request_client):
"""Stream an Anthropic Messages API response.
Fires delta callbacks for real-time token delivery, but returns
the native Anthropic Message object from get_final_message() so
the rest of the agent loop (validation, tool extraction, etc.)
works unchanged.
Uses ``request_client`` (a per-request Anthropic client registered with
the stranger-thread abort machinery) rather than the shared
``_anthropic_client``, so the stale/interrupt watchdog can abort this
stream's socket without closing the shared client mid-flight (#67142).
"""
has_tool_use = False
# Zero-event guard parity with the chat_completions path: track
@@ -2746,7 +3061,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
api_kwargs, log_prefix=getattr(agent, "log_prefix", "")
)
# Use the Anthropic SDK's streaming context manager
with agent._anthropic_client.messages.stream(**api_kwargs) as stream:
with request_client.messages.stream(**api_kwargs) as stream:
# The Anthropic SDK exposes the raw httpx response on
# ``stream.response``. Snapshot diagnostic headers
# immediately so they survive a stream that dies before the
@@ -2759,11 +3074,11 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
pass
# Claim the delta sink for THIS attempt (#65991) — parity with the
# chat_completions path so a superseded anthropic stream is fenced.
_writer_token = agent._claim_stream_writer()
_writer_token = claim_stream_writer(agent)
for event in stream:
# Bail the instant a newer attempt supersedes this one so a
# stale stream can't interleave tokens into the turn.
if not agent._stream_writer_is_current(_writer_token):
if not stream_writer_is_current(agent, _writer_token):
logger.warning(
"Anthropic streaming attempt superseded by a newer "
"stream; stopping consumption to preserve the "
@@ -2872,6 +3187,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
try:
for _stream_attempt in range(_max_stream_retries + 1):
stream_attempt_id = _start_stream_attempt()
# Check for interrupt before each retry attempt. Without
# this, /stop closes the HTTP connection (outer poll loop),
# but the retry loop opens a FRESH connection — negating the
@@ -2879,13 +3195,22 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
# retry can block for the full stream-read timeout (120s+),
# causing multi-minute delays between /stop and response.
if agent._interrupt_requested:
_cancel_current_stream_attempt("interrupt_before_stream_retry")
raise InterruptedError("Agent interrupted before stream retry")
try:
if agent.api_mode == "anthropic_messages":
agent._try_refresh_anthropic_client_credentials()
result["response"] = _call_anthropic()
# #67142: per-request client (credential refresh happens
# inside _create_request_anthropic_client) registered so
# the watchdog aborts its socket, not the shared client.
request_client = _set_request_client(
agent._create_request_anthropic_client(
reason="anthropic_stream_request"
),
kind="anthropic_messages",
)
result["response"] = _call_anthropic(request_client)
else:
result["response"] = _call_chat_completions()
result["response"] = _call_chat_completions(stream_attempt_id)
return # success
except Exception as e:
# If the main poll loop force-closed this request because
@@ -3007,14 +3332,13 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
mid_tool_call=True,
diag=request_client_holder.get("diag"),
)
_cancel_current_stream_attempt("stream_mid_tool_retry_cleanup")
_close_request_client_once("stream_mid_tool_retry_cleanup")
if agent.api_mode == "anthropic_messages":
try:
agent._anthropic_client.close()
agent._rebuild_anthropic_client()
except Exception:
pass
else:
# #67142: anthropic streams on a request-local client,
# already worker-owned-closed by _close_request_client_once
# above; the next attempt builds a fresh one. The shared
# _anthropic_client is never closed from inside a request.
if agent.api_mode != "anthropic_messages":
try:
agent._replace_primary_openai_client(
reason="stream_mid_tool_retry_pool_cleanup"
@@ -3071,16 +3395,15 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
diag=request_client_holder.get("diag"),
)
# Close the stale request client before retry
_cancel_current_stream_attempt("stream_retry_cleanup")
_close_request_client_once("stream_retry_cleanup")
# Also rebuild the primary client to purge
# any dead connections from the pool.
if agent.api_mode == "anthropic_messages":
try:
agent._anthropic_client.close()
agent._rebuild_anthropic_client()
except Exception:
pass
else:
# Also rebuild the primary client to purge any dead
# connections from the pool. #67142: anthropic uses a
# request-local client (already worker-owned-closed
# above; next attempt builds fresh), so the shared
# _anthropic_client is never closed from inside a
# request — only the OpenAI-wire primary is refreshed.
if agent.api_mode != "anthropic_messages":
try:
agent._replace_primary_openai_client(
reason="stream_retry_pool_cleanup"
@@ -3195,11 +3518,34 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
else:
_stream_stale_timeout_base = env_float("HERMES_STREAM_STALE_TIMEOUT", 180.0)
# Local providers (Ollama, oMLX, llama-cpp) can take 300+ seconds
# for prefill on large contexts. Disable the stale detector unless
# the user explicitly set HERMES_STREAM_STALE_TIMEOUT.
# for prefill on large contexts, so tolerate far longer silence than
# the cloud default — but a wedged local server must EVENTUALLY trip the
# detector rather than hang forever (an infinite timeout meant a crashed
# or deadlocked local endpoint stalled the session indefinitely). 900s
# tolerates slow prefill while still bounding a hung endpoint. Applies
# unless the user explicitly set HERMES_STREAM_STALE_TIMEOUT; override the
# local ceiling with HERMES_LOCAL_STREAM_STALE_TIMEOUT (documented in
# website/docs/reference/environment-variables.md).
if _stream_stale_timeout_base == 180.0 and agent.base_url and is_local_endpoint(agent.base_url):
_stream_stale_timeout = float("inf")
logger.debug("Local provider detected (%s) — stale stream timeout disabled", agent.base_url)
# Read config.yaml ``agent.local_stream_stale_timeout`` (default 900),
# env var ``HERMES_LOCAL_STREAM_STALE_TIMEOUT`` overrides for escape-hatch.
_local_default = 900.0
try:
from hermes_cli.config import load_config
_cfg = load_config()
_agent_cfg = _cfg.get("agent") if isinstance(_cfg, dict) else None
if isinstance(_agent_cfg, dict):
_v = _agent_cfg.get("local_stream_stale_timeout")
if isinstance(_v, (int, float)):
_local_default = float(_v)
except Exception:
pass
_stream_stale_timeout = env_float("HERMES_LOCAL_STREAM_STALE_TIMEOUT", _local_default)
logger.debug(
"Local provider detected (%s) — stale stream timeout set to %.0fs",
agent.base_url, _stream_stale_timeout,
)
else:
# Scale the stale timeout for large contexts: slow models (like Opus)
# can legitimately think for minutes before producing the first token
@@ -3287,6 +3633,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
f"Reconnecting..."
)
try:
_cancel_current_stream_attempt("stale_stream_kill")
_close_request_client_once("stale_stream_kill")
except Exception:
pass
@@ -3296,11 +3643,14 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
# Rebuild the primary client too — its connection pool
# may hold dead sockets from the same provider outage.
if agent.api_mode == "anthropic_messages":
try:
agent._anthropic_client.close()
agent._rebuild_anthropic_client()
except Exception:
pass
# #67142: the stale stream ran on a request-local anthropic
# client, already socket-aborted above via
# _close_request_client_once (which unblocks the worker and
# preserves the #28161 no-hang guarantee). The shared
# _anthropic_client is NOT the in-flight transport, so we must
# not close it from this poll (stranger) thread — that was the
# FD-recycle corruption vector. Nothing further is needed.
pass
else:
try:
agent._replace_primary_openai_client(reason="stale_stream_pool_cleanup")
@@ -3328,11 +3678,11 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
"(not a network error)."
)
try:
if agent.api_mode == "anthropic_messages":
agent._anthropic_client.close()
agent._rebuild_anthropic_client()
else:
_close_request_client_once("stream_interrupt_abort")
_cancel_current_stream_attempt("stream_interrupt_abort")
# #67142: kind-aware — anthropic aborts the request-local
# client's socket from this poll thread; the shared
# _anthropic_client is never closed here.
_close_request_client_once("stream_interrupt_abort")
except Exception:
pass
raise InterruptedError("Agent interrupted during streaming API call")
+64 -22
View File
@@ -23,6 +23,8 @@ import time
from types import SimpleNamespace
from typing import Any, Callable, Dict, List
from agent.stream_single_writer import claim_stream_writer, stream_writer_is_current
logger = logging.getLogger(__name__)
@@ -454,6 +456,27 @@ def make_codex_app_server_event_bridge(agent) -> Callable[[dict], None]:
# even when codex doesn't report durationMs.
started: dict[str, tuple[str, dict, float]] = {}
def _stable_call_id(item: dict, name: str) -> str:
"""Deterministic tool_call id mirroring CodexEventProjector, so a
live TUI tool card correlates with the same tool call after the
session is resumed and history is projected."""
from agent.transports.codex_event_projector import _deterministic_call_id
item_id = item.get("id") or ""
item_type = item.get("type") or ""
if item_type == "commandExecution":
return _deterministic_call_id("exec", item_id)
if item_type == "fileChange":
return _deterministic_call_id("apply_patch", item_id)
if item_type == "mcpToolCall":
server = item.get("server") or "mcp"
tool = item.get("tool") or "unknown"
return _deterministic_call_id(f"mcp__{server}__{tool}", item_id)
if item_type == "dynamicToolCall":
tool = item.get("tool") or "unknown"
return _deterministic_call_id(f"dyn_{tool}", item_id)
return _deterministic_call_id(name, item_id)
def _fire_tool_started(item: dict) -> None:
item_id = item.get("id") or ""
name = _codex_item_to_tool_name(item)
@@ -461,15 +484,26 @@ def make_codex_app_server_event_bridge(agent) -> Callable[[dict], None]:
if item_id:
started[item_id] = (name, args, time.monotonic())
cb = getattr(agent, "tool_progress_callback", None)
if cb is None:
return
try:
cb("tool.started", name, _codex_item_to_preview(item), args)
except Exception:
logger.debug(
"tool_progress_callback raised on tool.started for %s",
name, exc_info=True,
)
if cb is not None:
try:
cb("tool.started", name, _codex_item_to_preview(item), args)
except Exception:
logger.debug(
"tool_progress_callback raised on tool.started for %s",
name, exc_info=True,
)
# Authoritative stable-ID tool card (TUI / desktop). Fires
# alongside tool_progress so surfaces that render structured tool
# cards (not just progress bubbles) stay correlated with the
# projected history entry after a resume.
start_cb = getattr(agent, "tool_start_callback", None)
if start_cb is not None:
try:
start_cb(_stable_call_id(item, name), name, args)
except Exception:
logger.debug(
"tool_start_callback raised for %s", name, exc_info=True,
)
def _fire_tool_completed(item: dict) -> None:
item_id = item.get("id") or ""
@@ -487,16 +521,24 @@ def make_codex_app_server_event_bridge(agent) -> Callable[[dict], None]:
duration = time.monotonic() - prior[2]
result, is_error = _codex_item_completion_payload(item)
cb = getattr(agent, "tool_progress_callback", None)
if cb is None:
return
try:
cb("tool.completed", name, None, None,
duration=duration, is_error=is_error, result=result)
except Exception:
logger.debug(
"tool_progress_callback raised on tool.completed for %s",
name, exc_info=True,
)
if cb is not None:
try:
cb("tool.completed", name, None, None,
duration=duration, is_error=is_error, result=result)
except Exception:
logger.debug(
"tool_progress_callback raised on tool.completed for %s",
name, exc_info=True,
)
complete_cb = getattr(agent, "tool_complete_callback", None)
if complete_cb is not None:
args = prior[1] if prior is not None else _codex_item_to_args(item)
try:
complete_cb(_stable_call_id(item, name), name, args, result)
except Exception:
logger.debug(
"tool_complete_callback raised for %s", name, exc_info=True,
)
def _fire_text_delta(params: dict) -> None:
text = params.get("delta") or params.get("text") or ""
@@ -551,7 +593,7 @@ def make_codex_app_server_event_bridge(agent) -> Callable[[dict], None]:
if method == "item/agentMessage/delta":
_fire_text_delta(params)
return
if method == "item/reasoning/delta":
if method in {"item/reasoning/delta", "item/reasoning/summaryDelta"}:
_fire_reasoning_delta(params)
return
item = params.get("item")
@@ -1190,12 +1232,12 @@ def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta
# late deltas are fenced out of the turn; conversely, a newer
# attempt supersedes us and the interrupt_check below stops our
# consumption immediately.
_writer_token = agent._claim_stream_writer()
_writer_token = claim_stream_writer(agent)
def _interrupt_or_superseded(_tok=_writer_token) -> bool:
if agent._interrupt_requested:
return True
if not agent._stream_writer_is_current(_tok):
if not stream_writer_is_current(agent, _tok):
logger.warning(
"Codex streaming attempt superseded by a newer stream; "
"stopping consumption to preserve the single-writer "
+85 -9
View File
@@ -33,6 +33,7 @@ from agent.model_metadata import (
estimate_messages_tokens_rough,
)
from agent.redact import redact_sensitive_text
from agent.turn_context import drop_stale_api_content
logger = logging.getLogger(__name__)
@@ -298,6 +299,7 @@ _FALLBACK_TURN_MAX_CHARS = 700
_AUTO_FOCUS_MAX_TURNS = 3
_AUTO_FOCUS_TURN_MAX_CHARS = 260
_AUTO_FOCUS_MAX_CHARS = 700
_ACTIVE_TASK_MAX_CHARS = 1400
# Keep a short run of recent messages verbatim even when the token budget is
# already exhausted. The public ``protect_last_n`` default is intentionally
# high for small/light tails, but using all 20 as a hard floor here would bring
@@ -321,6 +323,9 @@ _PATH_MENTION_RE = re.compile(r"(?:/|~/?|[A-Za-z]:\\)[^\s`'\")\]}<>]+")
# the summary, the downstream model may re-emit it as an active directive on
# the next turn, triggering bogus attachment sends (#14665).
_MEDIA_DIRECTIVE_RE = re.compile(r"MEDIA:\S+")
_HISTORICAL_TASK_SECTION_RE = re.compile(
rf"(?ms)^{re.escape(HISTORICAL_TASK_HEADING)}\s*\n.*?(?=^## |\Z)"
)
def _dedupe_append(items: list[str], value: str, *, limit: int) -> None:
@@ -655,6 +660,9 @@ def _strip_historical_media(messages: List[Dict[str, Any]]) -> List[Dict[str, An
continue
new_msg = msg.copy()
new_msg["content"] = _strip_images_from_content(content)
# Content rewritten → the api_content sidecar (exact bytes previously
# sent) is stale; drop it so replay can't resend the pre-rewrite bytes.
drop_stale_api_content(new_msg)
result.append(new_msg)
changed = True
@@ -2142,9 +2150,9 @@ Summary generation was unavailable, so this is a best-effort deterministic fallb
_template_sections = f"""{HISTORICAL_TASK_HEADING}
[THE SINGLE MOST IMPORTANT FIELD. Capture the user's most recent unfulfilled
input verbatim the exact words they used. This includes:
- Explicit task assignments ("refactor the auth module")
- Questions awaiting an answer ("waarom staat X op Y?", "wat zijn de volgende stappen?")
- Decisions awaiting input ("optie A of B?")
- Explicit task assignments ("<specific user task>")
- Questions awaiting an answer ("<specific user question>")
- Decisions awaiting input ("<option A or B?>")
- Ongoing discussions where the assistant owes the next substantive reply
A conversation where the user just asked a question IS an active task the
task is "answer that question with full context". Do NOT write "None" merely
@@ -2152,15 +2160,15 @@ because the user did not issue an imperative command; reserve "None" for the
rare case where the last exchange was fully resolved and the user said
something like "thanks, that's all".
If multiple items are outstanding, list only the ones NOT yet completed.
Continuation should pick up exactly here. Examples:
"User asked: 'Now refactor the auth module to use JWT instead of sessions'"
"User asked: 'Waarom stond provider ineens op openrouter?' — needs investigation + answer"
"User chose option A; awaiting implementation of step 2"
This historical snapshot must identify the latest unresolved user input precisely. Examples:
"User asked: '<exact latest user request>'"
"User asked: '<exact latest user question>' — needs investigation + answer"
"User chose <option>; awaiting implementation of <specific next step>"
If the user's most recent message was a reverse signal (stop, undo, roll
back, never mind, just verify, change of topic) that supersedes earlier
work, write the reverse signal verbatim and DO NOT carry forward the
cancelled task. Example: "User asked: 'Stop the i18n refactor and just
verify the current diff' — earlier i18n in-flight work is cancelled."
cancelled task. Example: "User asked: '<exact reverse signal>' — earlier
in-flight work is cancelled."
If no outstanding task exists, write "None."]
## Goal
@@ -2322,6 +2330,7 @@ This compaction should PRIORITISE preserving all information related to the focu
# Redact the summary output as well — the summarizer LLM may
# ignore prompt instructions and echo back secrets verbatim.
summary = redact_sensitive_text(content.strip())
summary = self._ground_historical_task_snapshot(summary, turns_to_summarize)
# Store for iterative updates on next compaction
self._previous_summary = summary
self._clear_compression_failure_cooldown()
@@ -2593,6 +2602,69 @@ This compaction should PRIORITISE preserving all information related to the focu
focus = focus[: _AUTO_FOCUS_MAX_CHARS - 1].rstrip() + ""
return focus
@classmethod
def _latest_user_task_snapshot(
cls,
messages: List[Dict[str, Any]],
) -> Optional[str]:
"""Return a deterministic task-snapshot line from the newest real user turn.
The LLM summarizer is allowed to compress prose, but it must not invent
the "what is the active task?" anchor from a prompt example or stale
prior summary. This helper extracts the anchor locally from the exact
compacted turns so the summary can be grounded before it becomes live
context.
"""
# Reuse the runtime's real-user predicate so the deterministic
# snapshot can never anchor on user-role scaffolding (todo
# snapshots, truncation notices, background-process reports) —
# the exact class of turn this grounding exists to bypass.
from agent.conversation_compression import _is_real_user_message
for msg in reversed(messages):
if msg.get("role") != "user":
continue
if not _is_real_user_message(msg):
continue
content = msg.get("content")
text = redact_sensitive_text(_content_text_for_contains(content).strip())
if not text:
continue
text = re.sub(r"\s+", " ", text)
if len(text) > _ACTIVE_TASK_MAX_CHARS:
text = text[: _ACTIVE_TASK_MAX_CHARS - 15].rstrip() + " ...[truncated]"
return (
f"User asked (deterministic, from compacted turns): {text!r}\n"
"Historical only; newer protected-tail messages after this summary win."
)
return None
@classmethod
def _ground_historical_task_snapshot(
cls,
summary: str,
messages: List[Dict[str, Any]],
) -> str:
"""Force the task snapshot section to match a real user turn when possible."""
snapshot = cls._latest_user_task_snapshot(messages)
if not snapshot:
return summary
body = cls._strip_summary_prefix(summary)
# Keep the section terminated with a blank line: re.sub consumes the
# section's trailing newlines, and without restoring them the next
# "## " heading is glued onto the snapshot line — corrupting the
# markdown and making the heading invisible to this same regex on the
# next iterative compaction (which would then delete every following
# section via the \Z branch).
replacement = f"{HISTORICAL_TASK_HEADING}\n{snapshot}\n\n"
if _HISTORICAL_TASK_SECTION_RE.search(body):
grounded = _HISTORICAL_TASK_SECTION_RE.sub(
lambda _m: replacement, body, count=1
)
return grounded.strip()
return f"{replacement}{body}".strip()
@classmethod
def _find_latest_context_summary(
cls,
@@ -3464,6 +3536,10 @@ This compaction should PRIORITISE preserving all information related to the focu
# Mark the merged message so frontends can identify it as
# containing a compression summary prefix.
msg[COMPRESSED_SUMMARY_METADATA_KEY] = True
# Content rewritten → the api_content sidecar (exact bytes
# previously sent) is stale; drop it so replay can't resend
# the pre-merge bytes without the summary.
drop_stale_api_content(msg)
_merge_summary_into_tail = False
compressed.append(msg)
+168 -28
View File
@@ -415,43 +415,147 @@ def conversation_history_after_compression(agent: Any, messages: list) -> Option
return None
def _ensure_compressed_has_user_turn(original_messages: list, compressed: list) -> None:
"""Preserve a real user turn when a compressor returns assistant/tool-only context.
_SYNTHETIC_USER_PREFIXES = (
"[System: Your previous response was truncated",
"[System: The previous response was cut off",
"[System: Your previous tool call",
"[Your active task list was preserved across context compression]",
"[IMPORTANT: Background process ",
)
On repeated compaction the protected head decays to the system prompt only,
the middle summary can land as ``role="assistant"``, and a tool-heavy tail
can be all assistant/tool so the compacted transcript can legitimately
contain zero user messages. Strict chat templates (LM Studio / llama.cpp
Jinja) then fail with "No user query found in messages" (#55677).
The restored turn is appended at the END: the guard only runs when
``compressed`` currently ends with an assistant/tool message (any existing
user turn including a todo-snapshot append short-circuits the
``any()`` check), so appending a user message never creates consecutive
same-role messages. ``_fresh_compaction_message_copy`` copies the message
and strips the ``_db_persisted`` marker so the rotation/in-place flush
still persists the restored row to the new session (#57491).
def _message_text(message: Any) -> str:
content = message.get("content") if isinstance(message, dict) else None
if isinstance(content, str):
return content
if isinstance(content, list):
return "\n".join(
str(part.get("text") or part.get("content") or "")
for part in content
if isinstance(part, dict)
)
return ""
If the pre-compression transcript itself carried no user turn at all
(near-impossible every real conversation opens with a user request
but kept as a defensive backstop), a minimal continuation marker is
appended instead so strict templates still see a user message.
_SYNTHETIC_USER_FLAGS = (
"_todo_snapshot_synthetic",
"_empty_recovery_synthetic",
"_verification_stop_synthetic",
"_pre_verify_synthetic",
)
def _is_real_user_message(message: Any) -> bool:
"""Distinguish human intent from user-role runtime scaffolding.
A compaction summary pinned to ``role="user"`` (the compressor flips the
summary role to preserve alternation when the tail starts with an
assistant message) is scaffolding too: treating it as human intent would
short-circuit anchor restoration with a message the model is explicitly
told NOT to act on.
"""
if any(isinstance(msg, dict) and msg.get("role") == "user" for msg in compressed):
if not isinstance(message, dict) or message.get("role") != "user":
return False
if any(message.get(flag) for flag in _SYNTHETIC_USER_FLAGS):
return False
text = _message_text(message).strip()
if not text:
return False
if text.startswith(_SYNTHETIC_USER_PREFIXES):
return False
from agent.context_compressor import ContextCompressor
return not ContextCompressor._is_context_summary_content(text)
def _merge_anchor_into_user_message(target: dict, anchor: dict) -> None:
"""Fold the human anchor into an existing user-role scaffolding turn.
Used only when every insertion slot would create two consecutive
user-role messages. The anchor text leads (it is the active task), the
scaffolding content is preserved after it, and the synthetic flags are
cleared because the merged turn now carries real human intent.
"""
anchor_content = anchor.get("content")
target_content = target.get("content")
if isinstance(anchor_content, list) or isinstance(target_content, list):
anchor_parts = (
list(anchor_content)
if isinstance(anchor_content, list)
else [{"type": "text", "text": str(anchor_content or "")}]
)
target_parts = (
list(target_content)
if isinstance(target_content, list)
else [{"type": "text", "text": str(target_content or "")}]
)
target["content"] = anchor_parts + target_parts
else:
merged = f"{anchor_content or ''}\n\n{target_content or ''}".strip()
target["content"] = merged
for flag in _SYNTHETIC_USER_FLAGS:
target.pop(flag, None)
def _insert_real_user_anchor(messages: list, anchor: dict) -> None:
"""Insert the latest human turn without breaking role alternation."""
def _role(msg: Any) -> Optional[str]:
return msg.get("role") if isinstance(msg, dict) else None
# Preferred: the summary boundary — before the first assistant message
# not already preceded by a user turn. The left neighbour is then
# non-user by construction and the right neighbour is an assistant.
for index, message in enumerate(messages):
if _role(message) != "assistant":
continue
previous_role = _role(messages[index - 1]) if index > 0 else None
if previous_role != "user":
messages.insert(index, anchor)
return
# Every assistant is user-preceded (or there are none). Appending is
# safe whenever the transcript does not already end with a user turn.
if not messages or _role(messages[-1]) != "user":
messages.append(anchor)
return
# The transcript ends with a user-role message and no slot avoids
# user/user adjacency.
from agent.context_compressor import ContextCompressor
if ContextCompressor._is_context_summary_content(
_message_text(messages[-1])
):
# Never merge into a compaction summary: the summary prefix must
# stay at the start of its message for downstream summary detection.
# Appending after it makes the anchor "the latest user message after
# the summary" — exactly what the handoff prefix instructs — and the
# adjacent user turns are merged summary-first by
# repair_message_sequence before the next API call.
messages.append(anchor)
return
# Trailing user-role scaffolding (e.g. the todo snapshot): merge instead
# of inserting a consecutive same-role message (#55677 strict templates).
_merge_anchor_into_user_message(messages[-1], anchor)
def _ensure_compressed_has_user_turn(original_messages: list, compressed: list) -> None:
"""Preserve human intent, not merely a synthetic user-role placeholder."""
if any(_is_real_user_message(message) for message in compressed):
return
from agent.context_compressor import _fresh_compaction_message_copy
for msg in reversed(original_messages):
if not isinstance(msg, dict) or msg.get("role") != "user":
continue
compressed.append(_fresh_compaction_message_copy(msg))
return
for message in reversed(original_messages):
if _is_real_user_message(message):
_insert_real_user_anchor(
compressed,
_fresh_compaction_message_copy(message),
)
return
compressed.append({
"role": "user",
"content": (
"Continue from the compressed conversation context above. "
"This marker exists because the compacted transcript contained "
"no preserved user turn."
"This marker exists because no human user turn was available."
),
})
@@ -780,6 +884,25 @@ def compress_context(
_release_lock()
return messages, _existing_sp
if not compressed:
logger.error(
"context compression returned an empty transcript; refusing to "
"rotate session=%s so the parent remains resumable",
agent.session_id or "none",
)
try:
agent._emit_warning(
"⚠ Compression returned an empty transcript. "
"No session split was performed; conversation continues unchanged."
)
except Exception:
pass
_existing_sp = getattr(agent, "_cached_system_prompt", None)
if not _existing_sp:
_existing_sp = agent._build_system_prompt(system_message)
_release_lock()
return messages, _existing_sp
try:
summary_error = getattr(agent.context_compressor, "_last_summary_error", None)
if summary_error:
@@ -809,7 +932,11 @@ def compress_context(
todo_snapshot = agent._todo_store.format_for_injection()
if todo_snapshot:
compressed.append({"role": "user", "content": todo_snapshot})
compressed.append({
"role": "user",
"content": todo_snapshot,
"_todo_snapshot_synthetic": True,
})
_ensure_compressed_has_user_turn(messages, compressed)
agent._invalidate_system_prompt()
@@ -961,7 +1088,20 @@ def compress_context(
# refresh the stored system prompt and reset the flush cursor so the
# next turn re-bases its append diff.
agent._session_db.update_system_prompt(agent.session_id, new_system_prompt)
agent._last_flushed_db_idx = 0
if in_place:
agent._last_flushed_db_idx = 0
else:
# A headless turn can be killed before its finalizer. Persist
# the rotated child's compacted handoff at the boundary so
# the new session is immediately resumable.
agent._session_db.replace_messages(agent.session_id, compressed)
agent._last_flushed_db_idx = len(compressed)
agent._flushed_db_message_session_id = agent.session_id
agent._flushed_db_message_ids = {
id(message)
for message in compressed
if isinstance(message, dict)
}
except Exception as e:
# If the rotation rolled back to the parent (orphan-avoidance
# above), agent.session_id is the still-indexed parent and
+122 -21
View File
@@ -32,9 +32,12 @@ from agent.conversation_compression import conversation_history_after_compressio
from agent.display import KawaiiSpinner
from agent.error_classifier import FailoverReason, classify_api_error
from agent.iteration_budget import IterationBudget
from agent.turn_context import build_turn_context
from agent.turn_context import (
build_turn_context,
compose_user_api_content,
reanchor_current_turn_user_idx,
)
from agent.turn_retry_state import TurnRetryState
from agent.memory_manager import build_memory_context_block
from agent.message_sanitization import (
close_interrupted_tool_sequence,
_repair_tool_call_arguments,
@@ -78,6 +81,25 @@ logger = logging.getLogger(__name__)
# to treat it as cancellation metadata rather than assistant prose.
INTERRUPT_WAITING_FOR_MODEL_PREFIX = "Operation interrupted: waiting for model response ("
# Modules that indicate a deterministic local processing error when they
# appear in an exception traceback WITHOUT any API-call module. Used by the
# outer-loop error classifier to avoid retrying bugs that will fail
# identically every time (e.g. TypeError from passing list content into a
# regex helper). IMPORTANT: do NOT include "conversation_loop" or
# "run_agent" here — those are the container modules for the try/except
# itself, so every exception passes through them, which would make
# _hit_local always True and misclassify transient API/network errors as
# non-retryable local bugs. (#66267)
_LOCAL_PROCESSING_MODULES = frozenset({
"agent_runtime_helpers",
"message_content",
"message_sanitization",
"chat_completion_helpers", # only local when NOT also an API-call module
})
_API_CALL_MODULES = frozenset({
"chat_completion_helpers",
})
def _image_error_max_dimension(error: Exception) -> Optional[int]:
"""Extract a provider-reported image dimension ceiling, if present."""
@@ -610,8 +632,8 @@ def run_conversation(
# ── Per-turn setup (the prologue) ──
# All once-per-turn setup — stdio guarding, retry-counter resets, user
# message sanitization, todo/nudge hydration, system-prompt restore-or-
# build, crash-resilience persistence, preflight compression, the
# ``pre_llm_call`` plugin hook, and external-memory prefetch — lives in
# build, preflight compression, the ``pre_llm_call`` plugin hook,
# external-memory prefetch, and crash-resilience persistence — lives in
# ``build_turn_context``. It mutates ``agent`` exactly as the inline code
# did and returns the locals the loop below reads back. See
# ``agent/turn_context.py``.
@@ -631,6 +653,9 @@ def run_conversation(
set_session_context=set_session_context,
set_current_write_origin=set_current_write_origin,
ra=_ra,
# MoA turns append per-call aggregated context to the API copy of the
# user message, so no byte-stable api_content sidecar can be stamped.
moa_active=bool(moa_config),
)
user_message = _ctx.user_message
original_user_message = _ctx.original_user_message
@@ -839,23 +864,51 @@ def run_conversation(
for idx, msg in enumerate(messages):
api_msg = msg.copy()
# api_content is the persistence sidecar carrying the exact bytes
# sent to the API for this message when they differ from the clean
# stored content (see compose_user_api_content in turn_context).
# It is bookkeeping, never a provider field — pop it from EVERY
# outgoing copy.
_api_content = api_msg.pop("api_content", None)
# Inject ephemeral context into the current turn's user message.
# Sources: memory manager prefetch + plugin pre_llm_call hooks
# with target="user_message" (the default). Both are
# API-call-time only — the original message in `messages` is
# never mutated, so nothing leaks into session persistence.
# never mutated beyond the api_content stamp, so nothing leaks
# into the clean transcript content.
if idx == current_turn_user_idx and msg.get("role") == "user":
_injections = []
if _ext_prefetch_cache:
_fenced = build_memory_context_block(_ext_prefetch_cache)
if _fenced:
_injections.append(_fenced)
if _plugin_user_context:
_injections.append(_plugin_user_context)
if _injections:
_base = api_msg.get("content", "")
if isinstance(_base, str):
api_msg["content"] = _base + "\n\n" + "\n\n".join(_injections)
if isinstance(_api_content, str) and _api_content:
# Stamped by the prologue from the same composition —
# reuse it so the persisted sidecar and the wire cannot
# drift, and so every pass this turn sends identical
# bytes (composed from msg["content"], never from a
# previously-injected copy).
api_msg["content"] = _api_content
else:
# Callers that bypass the prologue stamping: compose live.
_composed = compose_user_api_content(
api_msg.get("content", ""),
_ext_prefetch_cache,
_plugin_user_context,
)
if _composed is not None:
api_msg["content"] = _composed
elif (
isinstance(_api_content, str)
and _api_content
and msg.get("role") in ("user", "assistant")
):
# Historical message: replay the exact bytes sent when it was
# live, so the provider prompt-cache prefix stays byte-stable
# instead of diverging at the injection point and
# re-prefilling everything after it. User rows carry the
# prefetch/plugin injection sidecar; user AND assistant rows
# can carry a sanitize-divergence sidecar (content that
# ``get_messages_as_conversation``'s sanitize_context/strip
# would rewrite on reload — see the capture in
# ``_flush_messages_to_session_db``).
api_msg["content"] = _api_content
# For ALL assistant messages, pass reasoning back to the API
# This ensures multi-turn reasoning context is preserved
@@ -4333,6 +4386,16 @@ def run_conversation(
# to fit the context window.
retry_count += 1
_retry.restart_with_compressed_messages = False
# In-loop compression rebuilt `messages` with fresh compaction
# copies, so the pre-compression current-turn index is stale.
# Re-anchor exactly like the prologue does: a stale index that
# lands on a historical user message would make the live-compose
# fallback inject this turn's prefetch into that message on the
# wire only, diverging the next turn's replayed prefix there.
current_turn_user_idx = reanchor_current_turn_user_idx(
messages, user_message
)
agent._persist_user_message_idx = current_turn_user_idx
continue
if _retry.restart_with_rebuilt_messages:
@@ -5597,7 +5660,36 @@ def run_conversation(
break
except Exception as e:
error_msg = f"Error during OpenAI-compatible API call #{api_call_count}: {str(e)}"
# Phase-aware error classification. The huge outer try/except spans
# both the actual API request and all local post-processing of the
# returned assistant message. Deterministic local bugs (e.g.
# passing a multimodal content list into a regex helper after a
# vision turn or context compaction) should not be retried: they
# will fail identically on every iteration and only burn the
# iteration budget. We classify an error as local by inspecting the
# traceback: if the exception propagated through any of the known
# local post-processing helpers and never entered the interruptible
# API-call helpers, it is almost certainly a local processing bug.
# (#66267)
tb_module_names: set[str] = set()
_tb = e.__traceback__
while _tb is not None:
_fname = os.path.splitext(os.path.basename(_tb.tb_frame.f_code.co_filename))[0]
tb_module_names.add(_fname)
_tb = _tb.tb_next
_hit_local = bool(tb_module_names & _LOCAL_PROCESSING_MODULES)
_hit_api = bool(tb_module_names & _API_CALL_MODULES)
_is_local_processing_error = _hit_local and not _hit_api
if _is_local_processing_error:
error_msg = (
f"Error during local message processing after "
f"OpenAI-compatible API call #{api_call_count}: {str(e)}"
)
else:
error_msg = f"Error during OpenAI-compatible API call #{api_call_count}: {str(e)}"
try:
print(f"{error_msg}")
except (OSError, ValueError):
@@ -5644,10 +5736,19 @@ def run_conversation(
# message pollutes history, burns tokens, and risks violating
# role-alternation invariants.
# If we're near the limit, break to avoid infinite loops
if api_call_count >= agent.max_iterations - 1:
_turn_exit_reason = f"error_near_max_iterations({error_msg[:80]})"
final_response = f"I apologize, but I encountered repeated errors: {error_msg}"
# If we're near the limit, break to avoid infinite loops.
# Local processing errors are deterministic — stop immediately
# rather than retrying until the budget is exhausted.
if (
_is_local_processing_error
or api_call_count >= agent.max_iterations - 1
):
if _is_local_processing_error:
_turn_exit_reason = f"local_processing_error({error_msg[:80]})"
final_response = f"I apologize, but I encountered an error while processing the model response: {error_msg}"
else:
_turn_exit_reason = f"error_near_max_iterations({error_msg[:80]})"
final_response = f"I apologize, but I encountered repeated errors: {error_msg}"
# Append as assistant so the history stays valid for
# session resume (avoids consecutive user messages).
messages.append({"role": "assistant", "content": final_response})
+12 -4
View File
@@ -43,11 +43,19 @@ logger = logging.getLogger(__name__)
def _load_config_safe() -> Optional[dict]:
"""Load config.yaml, returning None on any error."""
try:
from hermes_cli.config import load_config
"""Load config.yaml read-only, returning None on any error.
return load_config()
Uses ``load_config_readonly()``: every consumer in this module only reads
(``get_pool_strategy``, ``_iter_custom_providers``, the model-config seed),
and the deepcopy that ``load_config()`` pays per call is what made
credential-pool checks the dominant cost of ``model.options`` the picker
calls ``load_pool()`` once per provider row, each of which loaded (and
deep-copied) the full config again.
"""
try:
from hermes_cli.config import load_config_readonly
return load_config_readonly()
except Exception:
return None
+1 -1
View File
@@ -355,7 +355,7 @@ def evaluate_credits_notices(
if show_depleted and "credits.depleted" not in active:
to_show.append(
AgentNotice(
text="✕ Credit access paused · run /credits to top up",
text="✕ Credit access paused · run /topup to top up",
level="error",
kind=CREDITS_NOTICE_KIND,
key="credits.depleted",
+6 -1
View File
@@ -98,7 +98,12 @@ def _backup_cron_jobs_into(dest: Path) -> Dict[str, Any]:
info["reason"] = "no cron/jobs.json present"
return info
try:
raw = src.read_text(encoding="utf-8")
# utf-8-sig: same dialect as cron/jobs.load_jobs — a UTF-8 BOM left
# by Windows editors otherwise survives decoding as U+FEFF, breaks
# json.loads below, and misreports jobs_count as 0 with a spurious
# parse warning. The BOM-less text is also what gets written to the
# backup, so a later rollback restores a loadable file.
raw = src.read_text(encoding="utf-8-sig")
except OSError as e:
logger.debug("Failed to read cron/jobs.json for backup: %s", e)
info["reason"] = f"read error: {e}"
+46
View File
@@ -645,6 +645,52 @@ def verb_drops_preview(tool_name: str) -> bool:
return tool_name in _TOOL_VERBS_NO_PREVIEW
def build_status_phrase(tool_name: str, args: dict | None, max_len: int = 49) -> str | None:
"""Build a short present-tense status phrase for platform status surfaces.
Used by text-rendering "typing" indicators (Slack's
``assistant.threads.setStatus`` line) to show what the agent is doing
right now: ``is running scripts/run_tests.sh`` instead of a static
``is thinking...``. The phrase is phrased to follow the bot's display
name ("Hermes is running …"), so it starts lowercase with "is".
Pass ``args=None`` for a verb-only phrase (``is running``) used when
``display.live_status`` is ``verb`` to keep argument previews out of
shared channels.
Returns None for the ``_thinking`` pseudo-tool and when friendly labels
are disabled (callers fall back to their static default). ``max_len``
caps the total phrase length; Slack truncates its status line around 50
characters, so the default stays just under that.
"""
if not tool_name or tool_name == "_thinking":
return None
if not _friendly_tool_labels:
return None
verb = _TOOL_VERBS.get(tool_name)
if verb:
head = f"is {verb[0].lower()}{verb[1:]}"
else:
# Custom / plugin / MCP tools: generic but still informative.
head = f"is using {tool_name}"
phrase = head
if args and verb and tool_name not in _TOOL_VERBS_NO_PREVIEW:
preview = build_tool_preview(tool_name, args, max_len=None)
if preview:
# Previews can contain newlines (terminal commands); keep the
# status to the first line.
preview = preview.splitlines()[0].strip()
phrase = f"{head}{tool_verb_connector(tool_name)}{preview}"
if len(phrase) > max_len - 1:
phrase = phrase[: max_len - 2].rstrip() + ""
else:
phrase = phrase + ""
return phrase
def build_tool_label(tool_name: str, args: dict, max_len: int | None = None) -> str | None:
"""Build a human-phrased status label for a tool call.
+62 -2
View File
@@ -269,6 +269,11 @@ _CONTEXT_OVERFLOW_PATTERNS = [
"context window",
"prompt is too long",
"prompt exceeds max length",
# NOTE: bare "max_tokens" is load-bearing — the output-cap-retry path keys
# off it (e.g. "max_tokens: 65536 > context_window: 200000 ..."). Do NOT
# remove it. Provider empty-response advisories also contain "very low
# max_tokens", but those are intercepted by _EMPTY_PROVIDER_RESPONSE_PATTERNS
# BEFORE this list is consulted, so they never mis-route into compression.
"max_tokens",
"maximum number of tokens",
# vLLM / local inference server patterns
@@ -426,6 +431,19 @@ _THINKING_SIG_PATTERNS = [
# the exception type is generic (e.g. RuntimeError from a local shim that
# wraps a subprocess timeout). Checked before the type-based transport
# heuristics so custom-provider "timed out" errors don't fall through to
# Provider empty-response advisories (OpenRouter / nano-gpt / similar).
# Checked before context-overflow matching because the advisory text often
# mentions "max_tokens" as a possible cause, which historically sat in
# _CONTEXT_OVERFLOW_PATTERNS and sent healthy sessions into a compression
# death spiral ending in "Cannot compress further".
_EMPTY_PROVIDER_RESPONSE_PATTERNS = [
"returned an empty response",
"empty response despite retries",
"provider returned an empty response",
"model returning empty responses",
"empty response stream",
]
# the unknown bucket and get misreported as empty responses.
_TIMEOUT_MESSAGE_PATTERNS = [
"timed out",
@@ -775,6 +793,14 @@ def classify_api_error(
if classified is not None:
return classified
# Local MoA config drift is deterministic: a persisted session can retain
# a preset name that was later renamed/deleted. Retrying the same lookup
# cannot recover and makes a clear config error look like an API outage.
from agent.errors import MoAPresetNotFoundError
if isinstance(error, MoAPresetNotFoundError):
return _result(FailoverReason.model_not_found, retryable=False)
# ── 3. Error code classification ────────────────────────────────
if error_code:
@@ -1069,6 +1095,14 @@ def _classify_by_status(
# remaining explicit context-overflow signal routes into the
# compression-and-retry path (mirroring _classify_400) instead of
# blind server_error retries that exhaust and drop the turn.
# Empty-response advisories that mention "max_tokens" must not enter
# that compression path.
if any(p in error_msg for p in _EMPTY_PROVIDER_RESPONSE_PATTERNS):
return result_fn(
FailoverReason.server_error,
retryable=True,
should_compress=False,
)
if any(p in error_msg for p in _CONTEXT_OVERFLOW_PATTERNS):
return result_fn(
FailoverReason.context_overflow,
@@ -1082,6 +1116,12 @@ def _classify_by_status(
# Cloudflare/Tailscale hop relabeling the status). Route explicit
# overflow bodies into compression; otherwise treat as transient
# overload and retry.
if any(p in error_msg for p in _EMPTY_PROVIDER_RESPONSE_PATTERNS):
return result_fn(
FailoverReason.server_error,
retryable=True,
should_compress=False,
)
if any(p in error_msg for p in _CONTEXT_OVERFLOW_PATTERNS):
return result_fn(
FailoverReason.context_overflow,
@@ -1207,8 +1247,8 @@ def _classify_400(
# returns:
# "Unsupported parameter: 'max_tokens' is not supported with this model.
# Use 'max_completion_tokens' instead."
# That string contains the literal substring "max_tokens", which is one of
# the _CONTEXT_OVERFLOW_PATTERNS — so without this guard the 400 is
# That string contains the literal substring "max_tokens", which historically
# sat in _CONTEXT_OVERFLOW_PATTERNS — so without this guard the 400 is
# misclassified as context_overflow, routed into the compression loop,
# re-sent with the same bad parameter, and ends in "Cannot compress
# further". These errors are deterministic (every retry gets the identical
@@ -1230,6 +1270,17 @@ def _classify_400(
should_fallback=True,
)
# Empty-provider-response advisories must not enter compression. They
# often mention "max_tokens" as a possible cause and used to match the
# bare overflow pattern, then thrash compress until "Cannot compress
# further" on an otherwise healthy session (custom endpoints / nano-gpt).
if any(p in error_msg for p in _EMPTY_PROVIDER_RESPONSE_PATTERNS):
return result_fn(
FailoverReason.server_error,
retryable=True,
should_compress=False,
)
# Context overflow from 400
if any(p in error_msg for p in _CONTEXT_OVERFLOW_PATTERNS):
return result_fn(
@@ -1433,6 +1484,15 @@ def _classify_by_message(
should_fallback=True,
)
# Empty-provider-response advisories (often mention "max_tokens") must
# retry without compression — see the matching 400-path guard above.
if any(p in error_msg for p in _EMPTY_PROVIDER_RESPONSE_PATTERNS):
return result_fn(
FailoverReason.server_error,
retryable=True,
should_compress=False,
)
# Context overflow patterns
if any(p in error_msg for p in _CONTEXT_OVERFLOW_PATTERNS):
return result_fn(
+4
View File
@@ -7,3 +7,7 @@ class EmptyStreamError(RuntimeError):
"""Raised when a provider closes a stream without yielding a response."""
pass
class MoAPresetNotFoundError(ValueError):
"""Raised when a persisted MoA preset no longer exists in config."""
+31
View File
@@ -58,6 +58,14 @@ def _scan_context_content(content: str, filename: str) -> str:
BLOCKED at this layer because the file would otherwise enter the
system prompt verbatim and the user has no chance to intervene.
"""
# Editors (Windows Notepad, PowerShell Out-File without -Encoding
# utf8NoBOM, some VS Code profiles) prefix a UTF-8 BOM as an encoding
# artifact, not a prompt injection. Strip a leading U+FEFF silently so a
# context file (SOUL.md, AGENTS.md, ...) is not blocked wholesale; BOMs
# elsewhere in the content remain subject to the threat scan below.
if content.startswith("\ufeff"):
content = content[1:]
findings = _scan_for_threats(content, scope="context")
if findings:
logger.warning("Context file %s blocked: %s", filename, ", ".join(findings))
@@ -549,6 +557,29 @@ def computer_use_guidance(platform_name: Optional[str] = None) -> str:
"4. After any state-changing action, re-capture to verify. You can "
"pass `capture_after=true` to get the follow-up screenshot in one "
"round-trip.\n\n"
"## Verify → escalate ladder (background-first, NOT background-only)\n"
"Background delivery is the DEFAULT and the co-work path, but it is "
"the first rung, not the only one. Read each action's structured "
"result and climb only when the driver tells you to:\n"
"- `effect: 'confirmed'` + `verified: true` — the driver read the "
"result back. Done.\n"
"- `effect: 'unverifiable'` — the input was delivered but the driver "
"can't confirm it. Re-capture and check the screenshot/tree yourself "
"before deciding it worked.\n"
"- `effect: 'suspected_noop'`, `code: 'background_unavailable'`, or an "
"`escalation.recommended` field — the action did NOT land. Follow "
"`escalation.recommended`:\n"
" - `'px'` → re-issue addressing the target by `coordinate=[x,y]` "
"read off the screenshot instead of `element`.\n"
" - `'foreground'` (or a pixel click still didn't land) → re-issue "
"the SAME action with `delivery_mode='foreground'`. This briefly "
"raises the window; it needs its own approval and is only appropriate "
"when the user isn't actively working. Common for Electron/Chromium "
"consent dialogs, DirectInput games, and raw-input canvases.\n"
"- Escalate to foreground as a REACTION to a returned signal, never "
"as a prediction from the app being Electron/Chromium/GTK. Do not "
"silently retry the same rung expecting a different result, and do "
"not conclude 'cua-driver can't drive this app' — climb the ladder.\n\n"
"## Background mode rules\n"
"- Do NOT use `raise_window=true` on `focus_app` unless the user "
"explicitly asked you to bring a window to front. Input routing to "
+6
View File
@@ -22,6 +22,7 @@ from typing import Any, Dict, List
from agent.tool_dispatch_helpers import make_tool_result_message
from agent.tool_result_classification import tool_may_have_side_effect
from agent.turn_context import drop_stale_api_content
logger = logging.getLogger(__name__)
@@ -311,6 +312,11 @@ def strip_stale_dangerous_confirmations(
)
redacted = dict(msg)
redacted["content"] = _EXPIRED_CONFIRMATION_SENTINEL
# Drop the api_content sidecar: it carries the exact bytes
# previously sent — i.e. the dangerous confirmation this
# redaction exists to expire. Replaying it verbatim would
# undo the redaction on the wire.
drop_stale_api_content(redacted)
cleaned.append(redacted)
continue
cleaned.append(msg)
+70
View File
@@ -0,0 +1,70 @@
"""Best-effort accessors for the single-writer stream fence (#65991).
The fence itself lives on ``AIAgent`` (``_claim_stream_writer`` /
``_stream_writer_is_current`` in ``run_agent.py``), but the streaming code paths
that use it live in *other* modules ``chat_completion_helpers`` (chat /
anthropic / bedrock) and ``codex_runtime`` (codex responses). Calling the fence
directly as ``agent._claim_stream_writer()`` from those modules makes them
hard-depend on the method being present on whatever object is passed in as
``agent``.
That coupling is a latent crash: a partially-updated checkout (the streaming
helper module newer than ``run_agent``), a hot-reloaded gateway, a duck-typed
agent, or a test double without the method turns an *additive* safety net into a
fatal ``AttributeError`` that aborts the whole turn. A cron job died exactly
this way with ``'AIAgent' object has no attribute '_claim_stream_writer'``.
The fence is only ever allowed to drop a *provably* superseded stream never
the sole legitimate writer. So when the guard is unavailable (or raises), the
correct degradation is "no fence": keep streaming. These helpers make the
claim/check best-effort to guarantee that.
"""
from __future__ import annotations
import logging
from typing import Any
logger = logging.getLogger(__name__)
def claim_stream_writer(agent: Any) -> int:
"""Claim the delta sink for the calling stream attempt, best-effort.
Returns the agent's monotonic writer token when the fence is available, or
``0`` when the agent doesn't expose it (or the claim raised). A ``0`` token
pairs with :func:`stream_writer_is_current` always returning ``True``, so a
guard-less agent is simply never fenced instead of crashing the turn.
"""
claim = getattr(agent, "_claim_stream_writer", None)
if callable(claim):
try:
return int(claim())
except Exception:
logger.debug(
"stream single-writer: claim failed; proceeding unfenced",
exc_info=True,
)
return 0
def stream_writer_is_current(agent: Any, token: int) -> bool:
"""True when ``token`` is still the active writer, best-effort.
A falsy token (from a claim that no-oped) or an agent without the fence
means we cannot prove supersession, so the stream is treated as current and
never fenced. This preserves the single-writer invariant's one-way promise:
only a demonstrably stale writer is ever stopped.
"""
if not token:
return True
is_current = getattr(agent, "_stream_writer_is_current", None)
if callable(is_current):
try:
return bool(is_current(token))
except Exception:
logger.debug(
"stream single-writer: is_current check failed; treating as current",
exc_info=True,
)
return True
+421
View File
@@ -0,0 +1,421 @@
"""Surface-agnostic core for the ``/subscription`` TUI screen.
Companion to :mod:`agent.billing_view` same fail-open philosophy: when not
logged in or the portal is unreachable, return a struct with ``logged_in=False``
and let the surface degrade gracefully (never crash). Money is decimal end-to-end
(server emits decimal strings); we only format for display.
The TUI ``SubscriptionOverlay`` drives the plan change in-terminal (V3): it
previews the effect, then schedules a downgrade / cancellation / resume
(chargeless) or applies an upgrade (charges the card on the subscription). The
portal deep-link (built locally from ``portal_url`` + ``org_id``) remains the
fallback for an upgrade that needs 3DS / was declined.
WS1 dependency: ``GET /api/billing/subscription`` is a NAS endpoint (WS1 Phase A).
Until it ships, the fail-open contract handles 404s the builder returns
``logged_in=False`` and the surface degrades gracefully.
"""
from __future__ import annotations
import logging
import os
from dataclasses import dataclass
from decimal import Decimal
from typing import Any, Optional
from agent.billing_view import parse_money
logger = logging.getLogger(__name__)
# =============================================================================
# Parsed sub-structures
# =============================================================================
@dataclass(frozen=True)
class CurrentSubscription:
"""The user's active subscription. ``None`` (not this object) = no plan.
When present, ``tier_id`` / ``tier_name`` / ``monthly_credits`` /
``cycle_ends_at`` are always set (NAS guarantees a present ``current`` is a
fully-populated plan). Only ``credits_remaining`` and the cancel/downgrade
fields are optional.
"""
tier_id: Optional[str] = None
tier_name: Optional[str] = None
monthly_credits: Optional[Decimal] = None
credits_remaining: Optional[Decimal] = None
cycle_ends_at: Optional[str] = None # ISO
pending_downgrade_tier_name: Optional[str] = None
pending_downgrade_at: Optional[str] = None # ISO
cancel_at_period_end: bool = False
cancellation_effective_at: Optional[str] = None # ISO
@dataclass(frozen=True)
class SubscriptionTier:
"""A selectable plan in the catalog — one row of the in-terminal tier picker.
Mirrors NAS's ``SubscriptionTierOption``. ``is_current`` marks the active plan
(shown but not selectable); ``is_enabled=False`` is a grandfathered tier the
user is on but that can no longer be selected. ``tier_order`` sorts the picker
and drives the upgrade-vs-downgrade direction hint.
"""
tier_id: str
name: str
tier_order: int = 0
dollars_per_month: Optional[Decimal] = None
monthly_credits: Optional[Decimal] = None
is_current: bool = False
is_enabled: bool = True
@dataclass(frozen=True)
class SubscriptionChangePreview:
"""Parsed ``POST /api/billing/subscription/preview`` — what a change would do.
``effect`` is the disposition the commit would take:
- ``charge_now`` an upgrade; ``amount_due_now_cents`` is the prorated charge.
- ``scheduled`` a downgrade / same-price change at ``effective_at`` (period end).
- ``no_op`` already on the target tier.
- ``blocked`` the commit would be refused; ``reason`` says why.
"""
effect: str
reason: Optional[str] = None
current_tier_id: Optional[str] = None
current_tier_name: Optional[str] = None
target_tier_id: Optional[str] = None
target_tier_name: Optional[str] = None
monthly_credits_delta: Optional[Decimal] = None
amount_due_now_cents: Optional[int] = None
effective_at: Optional[str] = None # ISO
@dataclass(frozen=True)
class SubscriptionState:
"""Parsed ``GET /api/billing/subscription`` — the overview screen's data.
Fail-open: ``logged_in=False`` (and empty fields) when not logged in or the
portal is unreachable.
"""
logged_in: bool
org_name: Optional[str] = None
org_id: Optional[str] = None # org.id from the NAS response
role: Optional[str] = None # "OWNER" | "ADMIN" | "FINANCE_ADMIN" | "SECURITY_ADMIN" | "MEMBER"
can_change_plan_raw: Optional[bool] = None
context: str = "personal" # "personal" | "team"
current: Optional[CurrentSubscription] = None
tiers: tuple[SubscriptionTier, ...] = () # selectable catalog (picker)
portal_url: Optional[str] = None
# When the fetch failed (vs cleanly not-logged-in), the message for the surface.
error: Optional[str] = None
@property
def is_admin(self) -> bool:
"""Deprecated/display only — a legacy OWNER/ADMIN check.
NOT a capability check; use :attr:`can_change_plan` for gating billing
plan-change actions.
"""
return (self.role or "").upper() in ("OWNER", "ADMIN")
@property
def can_change_plan(self) -> bool:
"""Server capability when supplied; otherwise the legacy role fallback."""
if self.can_change_plan_raw is not None:
return self.can_change_plan_raw
return self.is_admin
# =============================================================================
# Payload parsing
# =============================================================================
def _parse_current(raw: Any) -> Optional[CurrentSubscription]:
# "No plan" is wire-represented as current:null (free personal OR team) —
# the old all-null-object shape is gone. A present current is a real plan,
# so guard on a real tier id and return None otherwise.
if not isinstance(raw, dict):
return None
tier_id = raw.get("tierId") or raw.get("id")
if not tier_id:
return None
return CurrentSubscription(
tier_id=tier_id,
tier_name=raw.get("tierName") or raw.get("name"),
monthly_credits=parse_money(raw.get("monthlyCredits")),
credits_remaining=parse_money(raw.get("creditsRemaining")),
cycle_ends_at=raw.get("cycleEndsAt"),
pending_downgrade_tier_name=raw.get("pendingDowngradeTierName"),
pending_downgrade_at=raw.get("pendingDowngradeAt"),
cancel_at_period_end=bool(raw.get("cancelAtPeriodEnd")),
cancellation_effective_at=raw.get("cancellationEffectiveAt") or None,
)
def _coalesce(*vals: Any) -> Any:
"""First non-``None`` value (preserves a legit ``0``/``0.0``, unlike ``or``).
NAS sends ``0`` for the free tier's ``tierOrder`` / ``dollarsPerMonth``; a plain
``x or default`` would drop those, so coalesce on ``None`` specifically.
"""
for v in vals:
if v is not None:
return v
return None
def _parse_tier(raw: Any) -> Optional[SubscriptionTier]:
"""Map one NAS ``SubscriptionTierOption`` dict into a :class:`SubscriptionTier`."""
if not isinstance(raw, dict):
return None
tier_id = raw.get("tierId") or raw.get("id")
if not tier_id:
return None
return SubscriptionTier(
tier_id=tier_id,
name=raw.get("name") or "",
tier_order=int(_coalesce(raw.get("tierOrder"), 0)),
dollars_per_month=parse_money(raw.get("dollarsPerMonthDisplay")),
monthly_credits=parse_money(raw.get("monthlyCredits")),
is_current=bool(raw.get("isCurrent")),
is_enabled=bool(_coalesce(raw.get("isEnabled"), True)),
)
def subscription_change_preview_from_payload(
payload: dict[str, Any],
) -> SubscriptionChangePreview:
"""Map a raw ``/subscription/preview`` JSON dict into :class:`SubscriptionChangePreview`."""
effect = payload.get("effect")
cents = payload.get("amountDueNowCents")
return SubscriptionChangePreview(
# An unrecognized/missing effect is treated as ``blocked`` — fail safe, never
# charge on a malformed quote.
effect=effect if isinstance(effect, str) else "blocked",
reason=payload.get("reason") or None,
current_tier_id=payload.get("currentTierId"),
current_tier_name=payload.get("currentTierName"),
target_tier_id=payload.get("targetTierId"),
target_tier_name=payload.get("targetTierName"),
monthly_credits_delta=parse_money(payload.get("monthlyCreditsDelta")),
amount_due_now_cents=int(cents) if isinstance(cents, (int, float)) else None,
effective_at=payload.get("effectiveAt") or None,
)
def subscription_state_from_payload(
payload: dict[str, Any], *, portal_url: Optional[str] = None
) -> SubscriptionState:
"""Map a raw ``/api/billing/subscription`` JSON dict into :class:`SubscriptionState`."""
raw_org = payload.get("org")
org: dict[str, Any] = raw_org if isinstance(raw_org, dict) else {}
raw_context = payload.get("context")
context = raw_context if raw_context in ("personal", "team") else "personal"
raw_tiers = payload.get("tiers")
tiers = (
tuple(t for t in (_parse_tier(x) for x in raw_tiers) if t is not None)
if isinstance(raw_tiers, list)
else ()
)
return SubscriptionState(
logged_in=True,
org_name=org.get("name"),
org_id=org.get("id") or None,
role=org.get("role"),
can_change_plan_raw=(
payload.get("canChangePlan")
if isinstance(payload.get("canChangePlan"), bool)
else None
),
context=context,
current=_parse_current(payload.get("current")),
tiers=tiers,
portal_url=portal_url,
)
# =============================================================================
# Fail-open builders (the surface front doors)
# =============================================================================
def build_subscription_state(*, timeout: float = 15.0) -> SubscriptionState:
"""Fetch + parse ``GET /api/billing/subscription``. Fail-open.
Returns ``SubscriptionState(logged_in=False)`` when not logged in. On a
portal/HTTP failure, returns ``logged_in=False`` with ``error`` set so the
surface can show a clear message rather than crashing.
Dev override: when ``HERMES_DEV_SUBSCRIPTION_FIXTURE`` names a fixture state,
``/subscription`` renders from that fixture instead of the real portal so
every plan/cancel/downgrade/team/not-admin state is testable on both
the CLI and TUI without a live account. Throwaway scaffolding; see
:func:`dev_fixture_subscription_state`.
"""
fixture = dev_fixture_subscription_state()
if fixture is not None:
return fixture
try:
from hermes_cli.nous_billing import (
BillingAuthError,
BillingError,
_absolutize_portal_url,
get_subscription_state,
resolve_portal_base_url,
)
except Exception:
return SubscriptionState(logged_in=False, error="billing client unavailable")
try:
payload = get_subscription_state(timeout=timeout)
except BillingAuthError:
return SubscriptionState(logged_in=False)
except BillingError as exc:
logger.debug("subscription ▸ /state fetch failed (fail-open)", exc_info=True)
return SubscriptionState(logged_in=False, error=str(exc))
except Exception:
logger.debug("subscription ▸ /state unexpected error (fail-open)", exc_info=True)
return SubscriptionState(logged_in=False, error="could not load subscription state")
raw_portal = payload.get("portalUrl") if isinstance(payload, dict) else None
portal_url = _absolutize_portal_url(raw_portal) if raw_portal else None
if not portal_url:
try:
portal_url = resolve_portal_base_url()
except Exception:
portal_url = None
return subscription_state_from_payload(payload, portal_url=portal_url)
def subscription_manage_url(state: SubscriptionState) -> Optional[str]:
"""Build ``{portal_origin}/manage-subscription?org_id=<id>`` from a state.
Mirrors the TUI's ``buildManageUrl`` (``subscription.ts``): the deep-link
target is NAS's OWN ``/manage-subscription`` page (NOT the Stripe Billing
Portal decided Jun 23), which routes upgradeCheckout / downgradescheduled
internally. ``org_id`` pins the page to the right account in multi-org
situations. Returns ``None`` when no portal URL is resolvable.
"""
from urllib.parse import urlencode, urlsplit, urlunsplit
if not state.portal_url:
return None
try:
parts = urlsplit(state.portal_url)
except Exception:
return None
if not parts.scheme or not parts.netloc:
return None
query = urlencode({"org_id": state.org_id}) if state.org_id else ""
return urlunsplit((parts.scheme, parts.netloc, "/manage-subscription", query, ""))
# =============================================================================
# Dev fixtures (throwaway scaffolding — env-var driven, no live portal)
# =============================================================================
_DEV_FIXTURE_PORTAL = "https://portal.nousresearch.com/billing"
def _dev_current(**over: Any) -> CurrentSubscription:
base: dict[str, Any] = dict(
tier_id="plus",
tier_name="Plus",
monthly_credits=Decimal("1000"),
credits_remaining=Decimal("420"),
cycle_ends_at="2026-07-01",
)
base.update(over)
return CurrentSubscription(**base)
def _dev_tiers(current_id: Optional[str]) -> tuple[SubscriptionTier, ...]:
"""A sample plan catalog for fixtures (marks ``current_id`` as the active tier)."""
specs = (
("free", "Free", 0, "0", "0"),
("plus", "Plus", 1, "20", "1000"),
("super", "Super", 2, "40", "3000"),
("ultra", "Ultra", 3, "80", "7000"),
)
return tuple(
SubscriptionTier(
tier_id=tid,
name=name,
tier_order=order,
dollars_per_month=parse_money(dpm),
monthly_credits=parse_money(mc),
is_current=(tid == current_id),
is_enabled=True,
)
for tid, name, order, dpm, mc in specs
)
def dev_fixture_subscription_state() -> Optional[SubscriptionState]:
"""Return a fixture :class:`SubscriptionState` for ``HERMES_DEV_SUBSCRIPTION_FIXTURE``.
Lets every CLI/TUI subscription state be exercised without a live portal:
free | mid | top | not-admin | downgrade | cancel | team |
logged-out
Returns ``None`` when the env var is unset/empty (the real portal path runs).
Throwaway scaffolding mirrors ``HERMES_DEV_CREDITS_FIXTURE``.
"""
name = (os.getenv("HERMES_DEV_SUBSCRIPTION_FIXTURE") or "").strip().lower()
if not name:
return None
common = dict(org_name="Acme Inc", org_id="org_acme", role="OWNER", portal_url=_DEV_FIXTURE_PORTAL)
if name in ("logged-out", "logged_out", "loggedout"):
return SubscriptionState(logged_in=False)
if name == "free":
return SubscriptionState(logged_in=True, current=None, tiers=_dev_tiers(None), **common)
if name in ("mid", "mid-tier"):
return SubscriptionState(logged_in=True, current=_dev_current(), tiers=_dev_tiers("plus"), **common)
if name in ("top", "top-tier"):
return SubscriptionState(
logged_in=True,
current=_dev_current(tier_id="ultra", tier_name="Ultra", monthly_credits=Decimal("7000"), credits_remaining=Decimal("5000")),
tiers=_dev_tiers("ultra"),
**common,
)
if name in ("not-admin", "member"):
return SubscriptionState(logged_in=True, current=_dev_current(), tiers=_dev_tiers("plus"), **{**common, "role": "MEMBER"})
if name == "downgrade":
return SubscriptionState(
logged_in=True,
current=_dev_current(tier_id="super", tier_name="Super", monthly_credits=Decimal("3000"), credits_remaining=Decimal("1500"), pending_downgrade_tier_name="Plus", pending_downgrade_at="2026-07-15"),
tiers=_dev_tiers("super"),
**common,
)
if name == "cancel":
return SubscriptionState(
logged_in=True,
current=_dev_current(cancel_at_period_end=True, cancellation_effective_at="2026-07-01"),
tiers=_dev_tiers("plus"),
**common,
)
if name == "team":
return SubscriptionState(logged_in=True, context="team", current=None, org_name="Acme Engineering", org_id="org_eng", role="OWNER", portal_url=_DEV_FIXTURE_PORTAL)
# Unknown name → behave as logged-out so the misconfiguration is visible.
return SubscriptionState(logged_in=False, error=f"unknown HERMES_DEV_SUBSCRIPTION_FIXTURE: {name}")
+5 -4
View File
@@ -46,6 +46,7 @@ from agent.prompt_builder import (
drain_truncation_warnings,
)
from agent.runtime_cwd import resolve_context_cwd
from hermes_constants import get_hermes_home
from utils import is_truthy_value
@@ -395,7 +396,7 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
if active_profile == "default":
stable_parts.append(
"Active Hermes profile: default. Other profiles (if any) live "
"under ~/.hermes/profiles/<name>/. Each profile has its own "
"under " + str(get_hermes_home()) + "/profiles/<name>/. Each profile has its own "
"skills/, plugins/, cron/, and memories/ that affect a different "
"session than this one. Do not modify another profile's "
"skills/plugins/cron/memories unless the user explicitly directs "
@@ -404,9 +405,9 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
else:
stable_parts.append(
f"Active Hermes profile: {active_profile}. This session reads "
f"and writes ~/.hermes/profiles/{active_profile}/. The default "
f"profile's data lives at ~/.hermes/skills/, ~/.hermes/plugins/, "
f"~/.hermes/cron/, ~/.hermes/memories/ — those belong to a "
f"and writes {get_hermes_home()}/profiles/{active_profile}/. The default "
f"profile's data lives at {get_hermes_home()}/skills/, {get_hermes_home()}/plugins/, "
f"{get_hermes_home()}/cron/, {get_hermes_home()}/memories/ — those belong to a "
f"different session run from a different shell. Do NOT modify "
f"another profile's skills/plugins/cron/memories unless the user "
f"explicitly directs you to. The cross-profile write guard will "
+5 -1
View File
@@ -472,4 +472,8 @@ def _positive_int(value: Any, default: int) -> int:
def _sha256(value: str) -> str:
return hashlib.sha256(value.encode("utf-8")).hexdigest()
# surrogatepass: tool results scraped from the web can carry unpaired
# UTF-16 surrogates (e.g. half of a mathematical-bold pair); a strict
# encode raises and takes down the whole conversation loop. The hash only
# needs deterministic bytes, not valid UTF-8.
return hashlib.sha256(value.encode("utf-8", "surrogatepass")).hexdigest()
+3
View File
@@ -187,6 +187,7 @@ class ChatCompletionsTransport(ProviderTransport):
or "tool_name" in msg
or "effect_disposition" in msg
or "timestamp" in msg # #47868 — strict providers reject this
or "api_content" in msg # persist-what-you-send sidecar
):
needs_sanitize = True
break
@@ -229,6 +230,7 @@ class ChatCompletionsTransport(ProviderTransport):
or "tool_name" in msg
or "effect_disposition" in msg
or "timestamp" in msg # #47868 — leak into strict providers
or "api_content" in msg # persist-what-you-send sidecar
):
out_msg = mutable_msg()
out_msg.pop("codex_reasoning_items", None)
@@ -236,6 +238,7 @@ class ChatCompletionsTransport(ProviderTransport):
out_msg.pop("tool_name", None)
out_msg.pop("effect_disposition", None)
out_msg.pop("timestamp", None) # #47868 — leak into strict providers
out_msg.pop("api_content", None) # persist-what-you-send sidecar
# Drop all Hermes-internal scaffolding markers (``_``-prefixed).
+46 -2
View File
@@ -13,6 +13,20 @@ from agent.transports.base import ProviderTransport
from agent.transports.types import NormalizedResponse, ToolCall
def _bounded_prompt_cache_key(value: Any) -> Optional[str]:
"""Return a provider-safe cache key without changing session identity."""
if value is None:
return None
key = str(value).strip()
if not key:
return None
if len(key) <= 64:
return key
# Match _content_cache_key's compact, collision-resistant routing-key shape.
digest = hashlib.sha256(key.encode("utf-8", errors="replace")).hexdigest()[:24]
return f"pck_{digest}"
def _content_cache_key(instructions: str, tools: Optional[List[Dict[str, Any]]]) -> Optional[str]:
"""Content-address the prompt cache key from the static request prefix.
@@ -304,6 +318,13 @@ class ResponsesApiTransport(ProviderTransport):
if request_overrides:
kwargs.update(request_overrides)
if "prompt_cache_key" in kwargs:
bounded_cache_key = _bounded_prompt_cache_key(kwargs["prompt_cache_key"])
if bounded_cache_key:
kwargs["prompt_cache_key"] = bounded_cache_key
else:
kwargs.pop("prompt_cache_key", None)
# xAI Responses API rejects ``service_tier`` (HTTP 400 "Argument not
# supported: service_tier") — hit when ``/fast`` priority-processing
# mode lingers from a prior model in the same session, or when a
@@ -337,7 +358,7 @@ class ResponsesApiTransport(ProviderTransport):
# remain high. Send session_id / x-client-request-id as HTTP
# headers while keeping ``prompt_cache_key`` in the body for
# standard OpenAI routing as a belt-and-braces fallback.
cache_scope_id = str(session_id or "").strip()
cache_scope_id = _bounded_prompt_cache_key(session_id)
if cache_scope_id:
existing_extra_headers = kwargs.get("extra_headers")
merged_extra_headers: Dict[str, str] = {}
@@ -382,6 +403,14 @@ class ResponsesApiTransport(ProviderTransport):
merged_extra_body.setdefault("prompt_cache_key", cache_key)
kwargs["extra_body"] = merged_extra_body
extra_body = kwargs.get("extra_body")
if isinstance(extra_body, dict) and "prompt_cache_key" in extra_body:
bounded_cache_key = _bounded_prompt_cache_key(extra_body["prompt_cache_key"])
if bounded_cache_key:
extra_body["prompt_cache_key"] = bounded_cache_key
else:
extra_body.pop("prompt_cache_key", None)
return kwargs
def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse:
@@ -470,11 +499,26 @@ class ResponsesApiTransport(ProviderTransport):
Normalizes input items, strips unsupported fields, validates structure.
"""
from agent.codex_responses_adapter import _preflight_codex_api_kwargs
return _preflight_codex_api_kwargs(
normalized = _preflight_codex_api_kwargs(
api_kwargs,
allow_stream=allow_stream,
is_github_responses=is_github_responses,
)
if "prompt_cache_key" in normalized:
bounded = _bounded_prompt_cache_key(normalized["prompt_cache_key"])
if bounded:
normalized["prompt_cache_key"] = bounded
else:
normalized.pop("prompt_cache_key", None)
extra_body = normalized.get("extra_body")
if isinstance(extra_body, dict) and "prompt_cache_key" in extra_body:
bounded = _bounded_prompt_cache_key(extra_body["prompt_cache_key"])
if bounded:
extra_body["prompt_cache_key"] = bounded
else:
extra_body.pop("prompt_cache_key", None)
return normalized
def map_finish_reason(self, raw_reason: str) -> str:
"""Map Codex response.status to OpenAI finish_reason.
+231 -17
View File
@@ -3,8 +3,10 @@
``run_conversation`` opened with ~470 lines of straight-line setup before the
tool-calling loop ever started: stdio guarding, runtime-main wiring, retry-counter
resets, user-message sanitization, todo/nudge-counter hydration, system-prompt
restore-or-build, crash-resilience persistence, preflight context compression, the
``pre_llm_call`` plugin hook, and external-memory prefetch.
restore-or-build, session-row creation (before compression, whose DB writes
reference the row), preflight context compression, the ``pre_llm_call`` plugin
hook, external-memory prefetch, and crash-resilience persistence (last, so the
user row is written once with its final ``api_content`` sidecar).
All of that is *prologue* it runs once per turn, has no back-references into the
loop, and produces a fixed set of values the loop then consumes. ``TurnContext``
@@ -26,10 +28,11 @@ import logging
import threading
import uuid
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Mapping, Optional
from agent.conversation_compression import conversation_history_after_compression
from agent.iteration_budget import IterationBudget
from agent.memory_manager import build_memory_context_block
from agent.model_metadata import (
estimate_messages_tokens_rough,
estimate_request_tokens_rough,
@@ -38,6 +41,112 @@ from agent.model_metadata import (
logger = logging.getLogger(__name__)
def compose_user_api_content(
content: Any,
ext_prefetch_cache: str,
plugin_user_context: str,
) -> Optional[str]:
"""Compose the API-bound content of the current turn's user message.
Sources: memory-manager prefetch + ``pre_llm_call`` plugin context with
target="user_message" (the default). Both are appended to the *API copy*
of the user message only the stored content stays clean.
This is the single source of that composition. The prologue stamps the
result onto the live message as ``api_content`` (persisted alongside the
clean content) and the ``api_messages`` build in ``conversation_loop``
sends the same helper's output, so the persisted sidecar can never drift
from the bytes on the wire which is the whole prompt-cache invariant:
what turn N sends must be what turn N+1 replays.
Returns ``None`` when nothing is injected (multimodal/non-string content,
or no ephemeral context), meaning the message is sent as-is.
"""
if not isinstance(content, str):
return None
injections = []
if ext_prefetch_cache:
fenced = build_memory_context_block(ext_prefetch_cache)
if fenced:
injections.append(fenced)
if plugin_user_context:
injections.append(plugin_user_context)
if not injections:
return None
return content + "\n\n" + "\n\n".join(injections)
def substitute_api_content(api_msg: Dict[str, Any]) -> Optional[str]:
"""Pop the ``api_content`` sidecar and substitute it into ``content``.
Used at every API-bound message-build site (the ``api_messages`` build in
``conversation_loop``, the max-iterations summary in
``chat_completion_helpers``, the chat-completions transport). The sidecar
carries the exact bytes previously sent to the API for this message when
they differ from the clean stored content; substituting it here keeps the
provider prompt-cache prefix byte-stable across turns.
Returns the popped sidecar string (for callers that need the value for
current-turn composition logic) or ``None`` when absent.
"""
sidecar = api_msg.pop("api_content", None)
if (
isinstance(sidecar, str)
and sidecar
and api_msg.get("role") in ("user", "assistant")
):
api_msg["content"] = sidecar
return sidecar
def drop_stale_api_content(msg: Dict[str, Any]) -> None:
"""Drop the ``api_content`` sidecar from a message whose content was rewritten.
Called from every content-rewrite path (historical image strip,
merge-summary-into-tail, consecutive-user repair merge, stale-confirmation
redaction). Replaying the pre-rewrite sidecar would resend exactly what
the rewrite removed, so it must be dropped the cost is one cache
boundary miss, never wrong content.
"""
msg.pop("api_content", None)
def extract_api_content_sidecar(msg: Mapping[str, Any]) -> Optional[str]:
"""Extract the ``api_content`` sidecar from a message dict for persistence.
Shared by the gateway/branch forwarding sites that copy the sidecar into a
new row. Returns the string sidecar or ``None`` when absent/non-string.
"""
v = msg.get("api_content")
return v if isinstance(v, str) else None
def reanchor_current_turn_user_idx(messages: List[Any], user_message: Any) -> int:
"""Locate this turn's user message after compaction rebuilt ``messages``.
Compression replaces list entries with fresh copies (and may append a
todo-snapshot user message or a restored user turn AFTER the surviving
copy of the current turn's message), so a pre-compression index is
meaningless. Prefer the LAST user message whose content exactly matches
this turn's text — the surviving copy in the common case — so the
injection stamp and the #48677 persist override can't land on a
todo-snapshot or historical row. Fall back to the last user message when
no exact match survives (merge-summary-into-tail rewrites the content but
the trackers still need a live anchor). Returns -1 when the list has no
user message at all.
"""
fallback = -1
for i in range(len(messages) - 1, -1, -1):
msg = messages[i]
if not (isinstance(msg, dict) and msg.get("role") == "user"):
continue
if fallback < 0:
fallback = i
if msg.get("content") == user_message:
return i
return fallback
def _compression_made_progress(
orig_len: int, new_len: int, orig_tokens: int, new_tokens: int
) -> bool:
@@ -133,6 +242,7 @@ def build_turn_context(
set_session_context,
set_current_write_origin,
ra,
moa_active: bool = False,
) -> TurnContext:
"""Run the once-per-turn setup and return the loop's input context.
@@ -379,31 +489,34 @@ def build_turn_context(
# Create the DB session row now that _cached_system_prompt is populated, so
# the persisted snapshot is written non-NULL on the first turn (Issue
# #45499). Keep row creation and the marker-based append in the same
# per-agent critical section as CLI close persistence.
# #45499). Idempotent: _ensure_db_session() no-ops once the row exists.
# Must run BEFORE preflight compression: in-place compaction inserts
# message rows referencing this session (archive_and_compact), and
# rotation creates a child with parent_session_id pointing at it — with
# PRAGMA foreign_keys=ON, a missing parent row fails both INSERTs on a
# fresh oversized first turn. The user-turn crash persist itself runs
# LATER (after memory prefetch / pre_llm_call), so the row is written
# once with its final api_content — both steps take the same per-agent
# persist lock as CLI close persistence.
persist_lock = getattr(agent, "_session_persist_lock", None)
def _ensure_and_persist() -> None:
agent._ensure_db_session()
agent._persist_session(messages, conversation_history)
# Crash-resilience: persist the inbound user turn as soon as the session row exists.
try:
if persist_lock is None:
_ensure_and_persist()
agent._ensure_db_session()
else:
with persist_lock:
_ensure_and_persist()
agent._ensure_db_session()
except Exception:
logger.warning(
"Early turn-start session persistence failed for session=%s",
"Turn-start session row creation failed for session=%s",
agent.session_id or "none",
exc_info=True,
)
finally:
# Keep an unmarked staged input available to a later close retry if the
# normal persistence attempt failed. Once the marker is present, the
# close path must no longer treat it as a pre-worker UI input.
# Clear the staged CLI input eagerly (as the pre-refactor code did)
# so a crash in preflight compression — which runs between this row
# create and the late crash-persist below — doesn't leave a stale
# _pending_cli_user_message that the next turn would mistake for a
# fresh staged input.
if not isinstance(pending_cli_message, dict) or pending_cli_message.get("_db_persisted"):
agent._pending_cli_user_message = None
@@ -411,6 +524,7 @@ def build_turn_context(
# Gate the (expensive) full token estimate behind a cheap pre-check.
# See ``_should_run_preflight_estimate`` for the OR semantics that fix
# issue #27405 (a few very large messages slipping past the count gate).
_preflight_compressed = False
if agent.compression_enabled and _should_run_preflight_estimate(
messages,
agent.context_compressor.protect_first_n,
@@ -478,6 +592,7 @@ def build_turn_context(
getattr(agent, "codex_app_server_auto_compaction", "native"),
)
elif _compressor.should_compress(_preflight_tokens):
_preflight_compressed = True
logger.info(
"Preflight compression: ~%s tokens >= %s threshold (model %s, ctx %s)",
f"{_preflight_tokens:,}",
@@ -521,6 +636,19 @@ def build_turn_context(
if not _compressor.should_compress(_preflight_tokens):
break
if _preflight_compressed:
# Compression rebuilt the list (tail messages are fresh compaction
# copies), so the pre-compression index of this turn's user message
# is stale. Re-anchor both index trackers: the api_content stamp
# below, the loop's injection site, and the flush's persist-override
# row (#48677) must all target the surviving dict, not a stale
# position. Exact-content match first so a todo-snapshot user message
# appended after the tail can't steal the anchor.
current_turn_user_idx = reanchor_current_turn_user_idx(
messages, user_message
)
agent._persist_user_message_idx = current_turn_user_idx
# Plugin hook: pre_llm_call (context injected into user message, not system prompt).
plugin_user_context = ""
try:
@@ -610,6 +738,92 @@ def build_turn_context(
except Exception:
pass
# ── api_content sidecar: persist what you send ──
# The prefetch/plugin context above is injected into the API copy of this
# turn's user message, never into the stored content — so on the next
# turn the message would replay WITHOUT the injection, diverging the
# request prefix at this point and re-prefilling everything after it
# (the whole previous turn's assistant/tool chain). Stamp the exact
# API-bound bytes on the live dict, only when they differ from the clean
# content, so the crash persist below writes both in the same row and
# replay can reproduce the sent prefix byte-for-byte. Guarded by the
# same predicate the api_messages build uses, so the stamped bytes are
# exactly the bytes the loop sends. codex_app_server turns bypass the
# api_messages build entirely (the codex thread gets the plain user
# message), so stamping there would persist bytes that were never sent.
# MoA turns append per-call aggregated reference context to the same API
# copy AFTER this composition, so the stamped bytes would never match the
# wire either — skip the stamp rather than persist provably wrong "exact
# sent bytes" (MoA keeps its pre-sidecar cache behavior).
if (
not moa_active
and getattr(agent, "api_mode", None) != "codex_app_server"
and 0 <= current_turn_user_idx < len(messages)
and messages[current_turn_user_idx].get("role") == "user"
):
_turn_user_msg = messages[current_turn_user_idx]
_api_content = compose_user_api_content(
_turn_user_msg.get("content", ""), ext_prefetch_cache, plugin_user_context
)
if _api_content is not None and _api_content != _turn_user_msg.get("content"):
_turn_user_msg["api_content"] = _api_content
# In-place preflight compaction has ALREADY inserted this turn's
# user row (archive_and_compact runs before prefetch/pre_llm_call
# can compose the sidecar), and the crash persist below identity-
# skips every compacted dict (they are all in the rebound
# conversation_history) — so the stamp would never reach the DB.
# Backfill it onto the freshly-inserted row directly. Rotation
# mode needs nothing here: its compacted copies flush to the
# child session after this stamp.
if _preflight_compressed and bool(
getattr(agent, "_last_compaction_in_place", False)
):
_db = getattr(agent, "_session_db", None)
if _db is not None:
try:
_db.set_latest_user_api_content(
agent.session_id,
_turn_user_msg.get("content"),
_api_content,
)
except Exception:
logger.warning(
"in-place compaction api_content backfill failed "
"for session=%s",
agent.session_id or "none",
exc_info=True,
)
# Crash-resilience: persist the inbound user turn before the first LLM
# call. Runs after preflight compression (which rewrites history anyway)
# and after prefetch/pre_llm_call, so the user row is written once with
# its final api_content instead of being re-written mid-turn.
# Keep row creation and the marker-based append in the same per-agent
# critical section as CLI close persistence, and retry the row create if
# the pre-compression attempt above failed transiently.
def _ensure_and_persist() -> None:
agent._ensure_db_session()
agent._persist_session(messages, conversation_history)
try:
if persist_lock is None:
_ensure_and_persist()
else:
with persist_lock:
_ensure_and_persist()
except Exception:
logger.warning(
"Early turn-start session persistence failed for session=%s",
agent.session_id or "none",
exc_info=True,
)
finally:
# Keep an unmarked staged input available to a later close retry if the
# normal persistence attempt failed. Once the marker is present, the
# close path must no longer treat it as a pre-worker UI input.
if not isinstance(pending_cli_message, dict) or pending_cli_message.get("_db_persisted"):
agent._pending_cli_user_message = None
return TurnContext(
user_message=user_message,
original_user_message=original_user_message,
+37 -2
View File
@@ -25,6 +25,21 @@ from __future__ import annotations
import os
from agent.codex_responses_adapter import _summarize_user_message_for_log
from agent.message_content import flatten_message_text
def _is_pure_tool_call_tail(msg: dict) -> bool:
"""An assistant row with ``tool_calls`` but no visible text content of its own.
Such a row satisfies the role check (``tail role == "assistant"``) while
carrying none of the delivered answer see the #43849/#44100 invariant
block in :func:`finalize_turn`. Uses :func:`flatten_message_text` so that
multimodal (list-type) content is evaluated by its text parts, not just
its type.
"""
if not msg.get("tool_calls"):
return False
return not flatten_message_text(msg.get("content")).strip()
def finalize_turn(
@@ -222,11 +237,31 @@ def finalize_turn(
# holds regardless of which path produced it. (#43849 / #44100)
if final_response and not interrupted:
try:
_tail_role = messages[-1].get("role") if messages else None
_tail = messages[-1] if messages else None
except Exception:
_tail_role = None
_tail = None
_tail_role = _tail.get("role") if isinstance(_tail, dict) else None
if _tail_role != "assistant":
messages.append({"role": "assistant", "content": final_response})
elif isinstance(_tail, dict) and _is_pure_tool_call_tail(_tail):
# The tail IS an assistant row, but a *pure tool-call turn*:
# tool_calls with no text of its own. The role check alone
# leaves the #43849/#44100 invariant unmet — the user saw a
# response that never reached the transcript, and the next turn
# replays the user backlog and re-answers it (the very symptom
# this block was added for). Fill that row's empty content
# instead of appending, so the durable turn ends with the answer
# without disturbing the tool-call structure or creating an
# assistant→assistant pair.
_tail["content"] = final_response
# The row may have already been flushed to SQLite by the
# incremental tool-call persist (conversation_loop.py:4990),
# which stamps ``_DB_PERSISTED_MARKER`` so subsequent flushes
# skip it. Pop the marker so the next ``_persist_session``
# re-writes the filled content to the durable store —
# otherwise ``/resume`` reloads ``content=""`` and the bug
# resurfaces cross-session.
_tail.pop("_db_persisted", None)
# The model has completed its request, so replace API-local
# voice/model/skill guidance with the clean user input before writing the
@@ -733,6 +733,13 @@ fn update_child_env(install_root: &Path) -> Vec<(String, OsString)> {
"HERMES_HOME".to_string(),
hermes_home.as_os_str().to_os_string(),
)];
// `hermes update` is a Python CLI writing to a pipe here, so CPython
// block-buffers its stdout: nothing reaches run_streamed (and the live
// log UI) until 8 KB accumulate or the process exits. Long quiet steps —
// the pre-update backup can zip multi-GB archives for minutes — render as
// a frozen stage, and users cancel a healthy update. Force line-by-line
// output instead.
envs.push(("PYTHONUNBUFFERED".to_string(), OsString::from("1")));
if let Some(path) = path_with_prepended_entries(&[
hermes_home.join("node").join("bin"),
venv_bin_dir(install_root),
@@ -1046,6 +1053,16 @@ mod tests {
assert!(!is_locked(Path::new("/nonexistent/does/not/exist/xyz")));
}
#[test]
fn update_child_env_forces_unbuffered_python() {
let envs = update_child_env(Path::new("/x/hermes-agent"));
assert!(
envs.iter()
.any(|(k, v)| k == "PYTHONUNBUFFERED" && v.to_str() == Some("1")),
"update children must run unbuffered so long steps stream to the live log"
);
}
#[test]
fn lock_probe_paths_include_desktop_app_payload() {
let root = Path::new("/x/hermes-agent");
@@ -1056,7 +1073,12 @@ mod tests {
"venv shim remains part of the update lock probe"
);
assert!(
probes.iter().any(|p| p.ends_with(Path::new("resources/app.asar"))),
// Windows/Linux payloads live under `resources/`, the macOS bundle
// under `Contents/Resources/` — Path::ends_with is case-sensitive.
probes.iter().any(|p| {
p.ends_with(Path::new("resources/app.asar"))
|| p.ends_with(Path::new("Resources/app.asar"))
}),
"packaged app.asar must be probed so repair/re-clone waits for the old desktop to exit"
);
}
+411 -7
View File
@@ -5,6 +5,7 @@ import http from 'node:http'
import https from 'node:https'
import os from 'node:os'
import path from 'node:path'
import tls from 'node:tls'
import { pathToFileURL } from 'node:url'
import {
@@ -112,6 +113,7 @@ import {
SESSION_WINDOW_MIN_HEIGHT,
SESSION_WINDOW_MIN_WIDTH
} from './session-windows'
import { ensureSpawnHelperExecutable } from './spawn-helper-perms'
import { nativeOverlayWidth as computeNativeOverlayWidth, macTitleBarOverlayHeight } from './titlebar-overlay-width'
import { resolveBehindCount, shouldCountCommits } from './update-count'
import { readLiveUpdateMarker, writeUpdateMarker } from './update-marker'
@@ -142,6 +144,21 @@ import {
getVenvSitePackagesEntries,
resolveVenvHermesCommand
} from './windows-hermes-path'
import {
alreadyHasNoSandbox,
buildNoSandboxRelaunchArgs,
decideWindowsSandboxLaunch,
fallbackMarker,
grantAllApplicationPackagesAcl,
markerAfterSuccessfulBoot,
readSandboxMarker,
type SandboxFallbackReason,
shouldAttemptAclRepair,
shouldRelaunchForGpuSandboxCrash,
shouldRelaunchForRendererSandboxCrashLoop,
writeSandboxMarker
} from './windows-sandbox-fallback'
import { installWindowsSystemCaTrust } from './windows-system-ca'
import { readWindowsUserEnvVar } from './windows-user-env'
import { isPackagedInstallPath as isPackagedInstallPathUnderRoots } from './workspace-cwd'
import { readWslWindowsClipboardImage } from './wsl-clipboard-image'
@@ -201,6 +218,107 @@ if (IS_WSL && !REMOTE_DISPLAY_REASON && fs.existsSync('/dev/dxg')) {
console.log('[hermes] WSL GPU passthrough (/dev/dxg) detected; enabling GPU acceleration')
}
// Windows sandbox / GPU breakpoint crash recovery (#38216).
//
// Some hosts (AMD RX 6000 drivers, orphan AppContainer SIDs under %LOCALAPPDATA%,
// missing S-1-15-2-2 ACEs) kill Chromium's sandboxed GPU/renderer children with
// 0x80000003. After enough GPU deaths the browser process FATAL-exits before the
// UI is usable. Must run before app `ready` so `--no-sandbox` applies to child
// processes. The sticky marker recovers Start Menu / shortcut launches that
// never go through `hermes desktop`; it is version-scoped so an app update
// re-probes the sandbox instead of degrading forever.
//
// `windowsSandboxFallbackActive` = this process runs without the Chromium
// sandbox (any cause, including a manual --no-sandbox flag) — guards the
// relaunch handlers. `windowsSandboxFallbackSticky` = the fallback machinery
// engaged and the marker must stay `fallback` after a successful boot; a
// manual flag alone is honored but never made sticky.
let windowsSandboxFallbackActive = false
let windowsSandboxFallbackSticky = false
let windowsSandboxFallbackReason: SandboxFallbackReason = 'boot-loop'
let windowsNoSandboxRelaunchAttempted = false
if (IS_WINDOWS) {
const windowsUserData = app.getPath('userData')
const priorMarker = readSandboxMarker(windowsUserData)
// Best-effort ACL repair, only when the last boot aborted or the fallback is
// engaged — icacls /T recurses the whole install tree, so healthy launches
// skip it (the installer already granted the ACE at install time). Repair
// targets the install dir only: granting AppContainer read on userData would
// expose Hermes sessions/config to every packaged app on the machine.
if (shouldAttemptAclRepair(priorMarker)) {
const exeDir = path.dirname(process.execPath)
const acl = grantAllApplicationPackagesAcl(exeDir, { execFileSync })
if (acl.ok) {
console.log(`[hermes] granted ALL APPLICATION PACKAGES RX on ${exeDir} (#38216)`)
} else if (acl.error && acl.error !== 'missing-target-or-exec') {
console.warn(`[hermes] AppContainer ACL grant failed on ${exeDir}: ${acl.error}`)
}
}
const sandboxDecision = decideWindowsSandboxLaunch({
argv: process.argv,
env: process.env,
marker: priorMarker,
appVersion: app.getVersion()
})
windowsSandboxFallbackActive = sandboxDecision.enable
windowsSandboxFallbackSticky = sandboxDecision.nextMarker.state === 'fallback'
if (sandboxDecision.nextMarker.state === 'fallback' && sandboxDecision.nextMarker.reason) {
windowsSandboxFallbackReason = sandboxDecision.nextMarker.reason
}
if (sandboxDecision.enable && sandboxDecision.reason !== 'already-enabled') {
app.commandLine.appendSwitch('no-sandbox')
process.env.ELECTRON_DISABLE_SANDBOX = '1'
console.log(
`[hermes] Windows sandbox fallback enabled (${sandboxDecision.reason}); launching with --no-sandbox (#38216)`
)
}
writeSandboxMarker(windowsUserData, sandboxDecision.nextMarker)
// Catch the first GPU breakpoint death and relaunch before Chromium's
// "GPU process isn't usable" FATAL abort ends the process with no recovery.
app.on('child-process-gone', (_event, details) => {
if (
!shouldRelaunchForGpuSandboxCrash({
details,
alreadyNoSandbox: windowsSandboxFallbackActive || alreadyHasNoSandbox(process.argv, process.env),
relaunchAttempted: windowsNoSandboxRelaunchAttempted
})
) {
return
}
windowsNoSandboxRelaunchAttempted = true
windowsSandboxFallbackActive = true
windowsSandboxFallbackSticky = true
windowsSandboxFallbackReason = 'gpu-breakpoint'
try {
writeSandboxMarker(app.getPath('userData'), fallbackMarker('gpu-breakpoint', app.getVersion()))
} catch {
void 0
}
console.warn(
`[hermes] Windows GPU sandbox crashed (exit=${details?.exitCode}); relaunching once with --no-sandbox (#38216)`
)
try {
app.relaunch({ args: buildNoSandboxRelaunchArgs(process.argv.slice(1)) })
app.exit(0)
} catch (error) {
console.error(`[hermes] --no-sandbox relaunch failed: ${error?.message || error}`)
}
})
}
ipcMain.handle('hermes:get-remote-display-reason', () => REMOTE_DISPLAY_REASON)
// Keep the renderer running at full speed while the window is in the background
@@ -1959,6 +2077,33 @@ function persistWindowState() {
// resized/moved fire many times mid-drag on Linux; debounce to one write.
const schedulePersistWindowState = debounce(persistWindowState, 250)
// Zoom's primary store is a main-process JSON file. The renderer localStorage
// mirror lives under Electron's cache/storage folders, which crash recovery
// can move or recreate — wiping the zoom setting exactly when the user just
// recovered from a crash (#56726). JSON survives; localStorage is kept as a
// secondary mirror so pre-JSON installs migrate transparently on first read.
const DESKTOP_ZOOM_STATE_PATH = path.join(app.getPath('userData'), 'zoom-state.json')
function readZoomState() {
try {
const raw = JSON.parse(fs.readFileSync(DESKTOP_ZOOM_STATE_PATH, 'utf8'))
const level = Number(raw?.zoomLevel)
return Number.isFinite(level) ? level : null
} catch {
return null
}
}
function writeZoomState(zoomLevel) {
try {
fs.mkdirSync(path.dirname(DESKTOP_ZOOM_STATE_PATH), { recursive: true })
writeFileAtomic(DESKTOP_ZOOM_STATE_PATH, JSON.stringify({ zoomLevel }, null, 2))
} catch (error) {
rememberLog(`[zoom] json persist failed: ${error?.message || error}`)
}
}
// Match the backend's source resolution but bias toward a real git checkout.
// Dev → SOURCE_REPO_ROOT. Packaged/CLI install → ACTIVE_HERMES_ROOT.
// HERMES_DESKTOP_HERMES_ROOT always wins so devs can pin a worktree.
@@ -2715,8 +2860,13 @@ async function applyUpdatesPosixInApp(opts: any) {
// Put the Hermes-managed Node and the venv on PATH so `hermes desktop`'s
// npm build can find them on a machine with no system Node. Windows portable
// Node lives directly under %LOCALAPPDATA%\hermes\node, not node\bin.
// PYTHONUNBUFFERED: `hermes update` writes to a pipe here, so CPython
// block-buffers stdout and long quiet steps (the pre-update backup can zip
// multi-GB archives for minutes) stream nothing to the progress UI — users
// read the silence as a hang and cancel a healthy update.
const env: Record<string, string> = {
HERMES_HOME,
PYTHONUNBUFFERED: '1',
PATH: pathWithHermesManagedNode(path.join(updateRoot, 'venv', 'bin'))
}
@@ -4761,6 +4911,11 @@ function setAndPersistZoomLevel(window, zoomLevel) {
// Apply + notify in one funnel so the settings UI stays in sync, including
// changes made via the keyboard shortcuts or the View menu.
const next = applyZoomLevel(window.webContents, zoomLevel)
// Primary store: main-process JSON (survives crash recovery — #56726).
writeZoomState(next)
// Secondary mirror: renderer localStorage (legacy store; kept in sync so a
// downgrade or JSON read failure still finds a sane value).
window.webContents
.executeJavaScript(
`try { localStorage.setItem(${JSON.stringify(ZOOM_STORAGE_KEY)}, ${JSON.stringify(String(next))}) } catch {}`
@@ -4773,6 +4928,19 @@ function restorePersistedZoomLevel(window) {
return
}
// Prefer the JSON file — it survives crash recovery wiping Electron's
// cache/storage folders (#56726). applyZoomLevel notifies the renderer so
// the Appearance UI Scale control stays in sync.
const saved = readZoomState()
if (saved != null) {
applyZoomLevel(window.webContents, saved)
return
}
// Fall back to localStorage for installs that predate zoom-state.json,
// migrating the value into the JSON store on first read.
window.webContents
.executeJavaScript(
`(() => { try { return localStorage.getItem(${JSON.stringify(ZOOM_STORAGE_KEY)}) } catch { return null } })()`
@@ -4784,7 +4952,8 @@ function restorePersistedZoomLevel(window) {
// Notify the renderer too — otherwise the Appearance UI Scale control
// can stay stuck at 100% even though the window zoom was restored.
applyZoomLevel(window.webContents, Number(stored))
const applied = applyZoomLevel(window.webContents, Number(stored))
writeZoomState(applied)
})
.catch(error => rememberLog(`[zoom] restore failed: ${error?.message || error}`))
}
@@ -4798,23 +4967,47 @@ function installZoomShortcuts(window) {
window.webContents.on('before-input-event', (event, input) => {
const mod = IS_MAC ? input.meta : input.control
if (!mod || input.alt || input.shift) {
if (!mod || input.alt) {
return
}
const key = input.key
if (key === '0') {
if (input.shift) {
return // Ctrl/Cmd+Shift+0 is not a zoom chord — leave it alone
}
event.preventDefault()
setAndPersistZoomLevel(window, 0)
} else if (key === '=' || key === '+') {
// Zoom-in must accept the shift modifier: on US layouts Plus is
// physically Shift+=, so Cmd+Plus arrives as Cmd+Shift+'+' (or '='
// depending on platform). The old blanket shift guard silently
// dropped keyboard zoom-in on macOS (#43517).
event.preventDefault()
setAndPersistZoomLevel(window, window.webContents.getZoomLevel() + ZOOM_STEP)
} else if (key === '-') {
if (input.shift) {
return // Shift+'-' is '_' territory on most layouts, not zoom-out
}
event.preventDefault()
setAndPersistZoomLevel(window, window.webContents.getZoomLevel() - ZOOM_STEP)
}
})
// Ctrl/Cmd + mouse wheel — the standard desktop/browser zoom gesture
// (#40295). Chromium surfaces it as the main-process 'zoom-changed' event
// (wheel events are DOM-side, so before-input-event never sees them).
// Route through the same persist+notify funnel as the keyboard shortcuts
// so wheel zoom survives restarts and the settings Scale control stays in
// sync, and use the same half step for consistency.
window.webContents.on('zoom-changed', (event, zoomDirection) => {
event.preventDefault()
const delta = zoomDirection === 'in' ? ZOOM_STEP : -ZOOM_STEP
setAndPersistZoomLevel(window, window.webContents.getZoomLevel() + delta)
})
}
function installContextMenu(window) {
@@ -7004,11 +7197,14 @@ function wireCommonWindowHandlers(win, { zoom = true }: { zoom?: boolean } = {})
if (zoom) {
installZoomShortcuts(win)
// Re-apply persisted zoom on show/restore/cross-display move (Windows can
// drop webContents zoom after minimize or a monitor-scale change) and on
// first load (reloads / crash recovery).
// Re-apply persisted zoom on show/restore/resize/cross-display move
// (Chromium can drop webContents zoom after these window transitions) and
// on EVERY full load — not once. The crash-recovery path calls
// webContents.reload(), which fires did-finish-load again after a `once`
// listener is spent, so zoom was silently lost on renderer crash
// recovery and any in-place reload/navigation (#46429).
installZoomReassertOnWindowEvents(win, () => restorePersistedZoomLevel(win))
win.webContents.once('did-finish-load', () => restorePersistedZoomLevel(win))
win.webContents.on('did-finish-load', () => restorePersistedZoomLevel(win))
}
installContextMenu(win)
@@ -7317,6 +7513,29 @@ function createWindow() {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.show()
}
// Persist geometry as soon as the window is visible so a crash before the
// first clean resize/move/close still captures the restored bounds (#56726).
schedulePersistWindowState()
// #38216: clear the mid-boot marker only after a window is actually usable.
// Keep sticky `fallback` when we launched with --no-sandbox so the next
// Start Menu click does not re-enter the GPU FATAL crash loop. The marker
// records the app version so the next update re-probes the sandbox.
if (IS_WINDOWS) {
try {
writeSandboxMarker(
app.getPath('userData'),
markerAfterSuccessfulBoot({
fallbackActive: windowsSandboxFallbackSticky,
reason: windowsSandboxFallbackReason,
appVersion: app.getVersion()
})
)
} catch (error) {
rememberLog(`[sandbox] marker update after ready-to-show failed: ${error?.message || error}`)
}
}
})
mainWindow.on('will-enter-full-screen', () => sendWindowStateChanged(true))
@@ -7358,6 +7577,40 @@ function createWindow() {
`[renderer] suppressing reload: ${rendererReloadTimes.length} crashes within ${RENDERER_RELOAD_WINDOW_MS}ms (likely a crash loop)`
)
// #38216 renderer flavor (same recovery as #56726, credit @Sahil-SS9):
// a deterministic Windows renderer crash loop with the sandbox
// breakpoint signature gets one --no-sandbox relaunch instead of a
// dead window. Gated on the exit code so unrelated crash loops don't
// silently drop the sandbox.
if (
shouldRelaunchForRendererSandboxCrashLoop({
reason: details?.reason,
exitCode: details?.exitCode,
alreadyNoSandbox: windowsSandboxFallbackActive || alreadyHasNoSandbox(process.argv, process.env),
relaunchAttempted: windowsNoSandboxRelaunchAttempted
})
) {
windowsNoSandboxRelaunchAttempted = true
windowsSandboxFallbackActive = true
windowsSandboxFallbackSticky = true
windowsSandboxFallbackReason = 'renderer-crash-loop'
try {
writeSandboxMarker(app.getPath('userData'), fallbackMarker('renderer-crash-loop', app.getVersion()))
} catch {
void 0
}
rememberLog('[renderer] Windows sandbox crash loop detected; relaunching once with --no-sandbox (#38216)')
try {
app.relaunch({ args: buildNoSandboxRelaunchArgs(process.argv.slice(1)) })
app.exit(0)
} catch (err) {
rememberLog(`[renderer] --no-sandbox relaunch failed: ${err?.message || err}`)
}
}
return
}
@@ -7402,12 +7655,20 @@ function createWindow() {
mainWindow.loadURL(pathToFileURL(resolveRendererIndex()).toString())
}
// Start the Python backend NOW, in parallel with the renderer load — not on
// did-finish-load. The backend cold boot (spawn → port announce → /api/status)
// is the dominant startup cost, and serializing it behind Chromium's load
// added the whole renderer load time to first-usable-composer. The promise is
// shared (backendConnectionState), so the renderer's getConnection() joins
// this in-flight boot instead of duplicating it; early boot-progress events
// the renderer misses are recovered by its getBootProgress() pull on mount.
startHermes().catch(error => rememberLog(error.stack || error.message))
mainWindow.webContents.once('did-finish-load', () => {
// Zoom restore is handled by wireCommonWindowHandlers (shared with session
// windows); no need to reapply it here.
broadcastBootProgress()
sendWindowStateChanged()
startHermes().catch(error => rememberLog(error.stack || error.message))
})
}
@@ -7828,6 +8089,71 @@ async function interceptSessionRequestForRemote(request) {
return mergeRemoteProfileSessions(searchParams, remoteProfiles)
}
// Batched sidebar slices. With no remote profiles the local batched endpoint
// (one DB open per profile) serves it directly — take the fast path. When
// remotes exist, fan the three slices back out to the per-slice
// /api/profiles/sessions path (which already merges remote rows correctly) and
// reassemble; local profiles fall back to three primary reads there, but
// remote correctness is preserved.
if (method === 'GET' && pathname === '/api/profiles/sessions/sidebar') {
const remoteProfiles = configuredRemoteProfileNames()
if (remoteProfiles.length === 0) {
return undefined // local fast path → batched endpoint's single DB open
}
const recentsProfile = (searchParams.get('recents_profile') || 'all').trim() || 'all'
const sliceParams = (limitKey, defaultLimit, extra) => {
const sp = new URLSearchParams({
limit: searchParams.get(limitKey) || defaultLimit,
offset: '0',
min_messages: '1',
archived: 'exclude',
order: 'recent',
...extra
})
return sp
}
const recentsSp = sliceParams('recents_limit', '20', { profile: recentsProfile })
const recentsExclude = searchParams.get('recents_exclude')
if (recentsExclude) {
recentsSp.set('exclude_sources', recentsExclude)
}
const cronSp = sliceParams('cron_limit', '50', { profile: 'all', source: 'cron' })
const messagingSp = sliceParams('messaging_limit', '100', { profile: 'all' })
const messagingExclude = searchParams.get('messaging_exclude')
if (messagingExclude) {
messagingSp.set('exclude_sources', messagingExclude)
}
const [recents, cron, messaging] = await Promise.all([
fetchProfilesSessionSlice(recentsSp, remoteProfiles),
fetchProfilesSessionSlice(cronSp, remoteProfiles),
fetchProfilesSessionSlice(messagingSp, remoteProfiles)
])
return {
recents: {
sessions: rowsOf(recents),
total: Number(recents?.total) || 0,
profile_totals: recents?.profile_totals || {}
},
cron: { sessions: rowsOf(cron) },
messaging: {
sessions: rowsOf(messaging),
total: Number(messaging?.total) || rowsOf(messaging).length
},
errors: []
}
}
// Per-session read/mutation. Owner is in ?profile= (reads) or request.profile
// (mutations). Two remote shapes:
// - per-profile override: route to that profile's own remote, sans profile
@@ -7892,6 +8218,30 @@ async function remoteSessionList(profile, searchParams) {
return { ...(data as any), sessions: rowsOf(data) }
}
// Resolve one /api/profiles/sessions slice with remote profiles spliced in —
// the same branch logic as the GET /api/profiles/sessions intercept, but always
// returns data (never `undefined`) so a batched caller can compose slices. A
// specific local profile reads from the local primary; a remote-override profile
// reads from its remote; 'all' merges every remote into the primary aggregate.
async function fetchProfilesSessionSlice(searchParams, remoteProfiles) {
const requested = (searchParams.get('profile') || 'all').trim() || 'all'
if (requested !== 'all') {
if (profileHasRemoteOverride(requested)) {
return remoteSessionList(requested, searchParams)
}
const primary = await ensureBackend(null)
return fetchJson(`${primary.baseUrl}/api/profiles/sessions?${searchParams}`, primary.token, {
method: 'GET',
timeoutMs: DEFAULT_FETCH_TIMEOUT_MS
}).catch(() => ({ sessions: [], total: 0, profile_totals: {} }))
}
return mergeRemoteProfileSessions(searchParams, remoteProfiles)
}
// Unified list: primary's local aggregate, with each remote profile's stale local
// rows/totals swapped for the remote's real ones, re-sorted by recency and
// re-windowed to the requested page. A dead remote contributes nothing rather
@@ -8656,7 +9006,39 @@ ipcMain.handle('hermes:git:scanRepos', async (_event, roots, options) => {
}
})
// node-pty's published tarball ships the POSIX `spawn-helper` without an exec
// bit; the dev flow resolves node-pty straight from node_modules (nothing
// chmods it there), so the first terminal spawn dies with `posix_spawnp
// failed`. Restore the bit once, lazily, right before the first spawn. Packaged
// builds already stage an executable copy, so this is a no-op there.
let _spawnHelperEnsured = false
function ensureNodePtySpawnHelper() {
if (_spawnHelperEnsured || IS_WINDOWS) {
return
}
_spawnHelperEnsured = true
try {
const nodePtyRoot = path.dirname(require.resolve('node-pty/package.json'))
const { fixed, errors } = ensureSpawnHelperExecutable(nodePtyRoot)
for (const helperPath of fixed) {
rememberLog(`[terminal] restored +x on node-pty spawn-helper: ${helperPath}`)
}
for (const failure of errors) {
rememberLog(`[terminal] could not chmod spawn-helper ${failure.path}: ${failure.error}`)
}
} catch (error) {
rememberLog(`[terminal] spawn-helper exec check skipped: ${error instanceof Error ? error.message : String(error)}`)
}
}
ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => {
ensureNodePtySpawnHelper()
const id = crypto.randomUUID()
const { args, command, name } = terminalShellCommand()
const cwd = safeTerminalCwd(payload?.cwd)
@@ -9149,6 +9531,16 @@ app.on('open-url', (event, url) => {
})
app.whenReady().then(() => {
const systemCa = installWindowsSystemCaTrust(tls)
if (systemCa.applied) {
rememberLog(
`[tls] trusting ${systemCa.systemCertificateCount} Windows system CA certificate(s) for backend connections`
)
} else if (systemCa.error) {
rememberLog(`[tls] could not load Windows system CA certificates: ${systemCa.error}`)
}
if (IS_MAC) {
Menu.setApplicationMenu(buildApplicationMenu())
} else {
@@ -9207,6 +9599,18 @@ function configureSpellChecker() {
}
app.on('before-quit', () => {
// Clean quit mid-boot should not trip next-launch --no-sandbox (#38216).
// FATAL GPU aborts skip before-quit, leaving the `booting` marker in place.
// Keyed on sticky (not active): a manual --no-sandbox run still records a
// clean quit, while an engaged fallback keeps its sticky marker.
if (IS_WINDOWS && !windowsSandboxFallbackSticky) {
try {
writeSandboxMarker(app.getPath('userData'), markerAfterSuccessfulBoot({ fallbackActive: false }))
} catch {
void 0
}
}
// The always-on-top overlay isn't a "real" app window; close it so a stray
// pet can't keep the process alive or float over a quit app.
closePetOverlay()
@@ -0,0 +1,134 @@
import assert from 'node:assert/strict'
import { join } from 'node:path'
import { test } from 'vitest'
import {
ensureSpawnHelperExecutable,
needsExecBit,
spawnHelperCandidates,
type SpawnHelperFs,
withExecBits
} from './spawn-helper-perms'
interface FakeFile {
mode: number
statThrows?: boolean
chmodThrows?: boolean
}
function fakeFs(
files: Record<string, FakeFile>,
dirs: Record<string, string[]> = {}
): SpawnHelperFs & { chmods: { path: string; mode: number }[] } {
const chmods: { path: string; mode: number }[] = []
return {
chmods,
existsSync(path) {
return path in files || path in dirs
},
readdirSync(path) {
return dirs[path] ?? []
},
statSync(path) {
const file = files[path]
if (!file || file.statThrows) {
throw new Error(`stat failed: ${path}`)
}
return { mode: file.mode }
},
chmodSync(path, mode) {
const file = files[path]
if (file?.chmodThrows) {
throw new Error(`chmod failed: ${path}`)
}
chmods.push({ path, mode })
if (file) {
file.mode = mode
}
}
}
}
test('needsExecBit / withExecBits treat any missing exec bit as non-executable', () => {
assert.equal(needsExecBit(0o644), true)
assert.equal(needsExecBit(0o755), false)
// Partial exec bits (owner only) still count as needing repair.
assert.equal(needsExecBit(0o744), true)
// Preserves read/write bits while adding exec for all three classes.
assert.equal(withExecBits(0o644), 0o755)
assert.equal(withExecBits(0o600), 0o711)
})
test('candidates cover every prebuild dir plus build/Release', () => {
const root = '/pkg/node-pty'
const fs = fakeFs({}, { [join(root, 'prebuilds')]: ['darwin-arm64', 'darwin-x64', 'linux-x64'] })
assert.deepEqual(spawnHelperCandidates(root, fs), [
join(root, 'prebuilds', 'darwin-arm64', 'spawn-helper'),
join(root, 'prebuilds', 'darwin-x64', 'spawn-helper'),
join(root, 'prebuilds', 'linux-x64', 'spawn-helper'),
join(root, 'build', 'Release', 'spawn-helper')
])
})
test('chmods only the non-executable spawn-helpers, leaving 0755 copies alone', () => {
const root = '/pkg/node-pty'
const arm = join(root, 'prebuilds', 'darwin-arm64', 'spawn-helper')
const x64 = join(root, 'prebuilds', 'darwin-x64', 'spawn-helper')
const fs = fakeFs(
{
[arm]: { mode: 0o644 },
[x64]: { mode: 0o755 }
},
{ [join(root, 'prebuilds')]: ['darwin-arm64', 'darwin-x64'] }
)
const result = ensureSpawnHelperExecutable(root, fs)
assert.deepEqual(result.fixed, [arm])
assert.deepEqual(result.errors, [])
assert.deepEqual(fs.chmods, [{ path: arm, mode: 0o755 }])
})
test('missing spawn-helpers are skipped without error', () => {
const root = '/pkg/node-pty'
const fs = fakeFs({}, { [join(root, 'prebuilds')]: ['darwin-arm64'] })
const result = ensureSpawnHelperExecutable(root, fs)
assert.deepEqual(result.fixed, [])
assert.deepEqual(result.errors, [])
assert.deepEqual(fs.chmods, [])
})
test('chmod failures are collected, not thrown', () => {
const root = '/pkg/node-pty'
const arm = join(root, 'prebuilds', 'darwin-arm64', 'spawn-helper')
const fs = fakeFs({ [arm]: { mode: 0o644, chmodThrows: true } }, { [join(root, 'prebuilds')]: ['darwin-arm64'] })
const result = ensureSpawnHelperExecutable(root, fs)
assert.deepEqual(result.fixed, [])
assert.equal(result.errors.length, 1)
assert.equal(result.errors[0].path, arm)
})
test('no prebuilds dir (Windows layout) is a clean no-op', () => {
const root = '/pkg/node-pty'
const fs = fakeFs({}, {})
const result = ensureSpawnHelperExecutable(root, fs)
assert.deepEqual(result.fixed, [])
assert.deepEqual(result.errors, [])
})
+113
View File
@@ -0,0 +1,113 @@
// node-pty ships its POSIX `spawn-helper` inside the published npm tarball with
// mode 0644 (no exec bit). node-pty `posix_spawnp`s that helper on macOS/Linux,
// so a non-executable helper fails every terminal spawn with
// `Error: posix_spawnp failed.`. Packaged builds are covered because
// stage-native-deps.mjs chmods the staged copy, but the dev flow
// (`npm run dev` → `electron .`) resolves node-pty straight from
// `node_modules/`, which nobody chmods. This restores the exec bits at runtime,
// best-effort, so both dev and any environment that stripped the bit keep
// working. Idempotent: files that are already executable are left untouched.
import {
chmodSync as realChmodSync,
existsSync as realExistsSync,
readdirSync as realReaddirSync,
statSync as realStatSync
} from 'node:fs'
import { join } from 'node:path'
const EXEC_BITS = 0o111
export interface SpawnHelperFs {
existsSync(path: string): boolean
readdirSync(path: string): string[]
statSync(path: string): { mode: number }
chmodSync(path: string, mode: number): void
}
export interface EnsureSpawnHelperResult {
fixed: string[]
errors: { path: string; error: string }[]
}
const defaultFs: SpawnHelperFs = {
existsSync: realExistsSync,
readdirSync: (path: string) => realReaddirSync(path),
statSync: (path: string) => realStatSync(path),
chmodSync: realChmodSync
}
// True when any of the owner/group/other execute bits are missing.
export function needsExecBit(mode: number): boolean {
return (mode & EXEC_BITS) !== EXEC_BITS
}
// Preserve existing permission bits, adding execute for owner/group/other.
export function withExecBits(mode: number): number {
return mode | EXEC_BITS
}
// Every place a `spawn-helper` can live under a node-pty package root: one per
// bundled prebuild (`prebuilds/<platform>-<arch>/`) plus a locally compiled
// `build/Release/` copy. Windows layouts have no spawn-helper, so the list is
// naturally empty there.
export function spawnHelperCandidates(
nodePtyRoot: string,
fs: Pick<SpawnHelperFs, 'existsSync' | 'readdirSync'> = defaultFs
): string[] {
const candidates: string[] = []
const prebuilds = join(nodePtyRoot, 'prebuilds')
if (fs.existsSync(prebuilds)) {
for (const entry of fs.readdirSync(prebuilds)) {
candidates.push(join(prebuilds, entry, 'spawn-helper'))
}
}
candidates.push(join(nodePtyRoot, 'build', 'Release', 'spawn-helper'))
return candidates
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
// Best-effort: ensure every existing spawn-helper under `nodePtyRoot` is
// executable. Never throws — missing files are skipped, and chmod/stat failures
// are collected so the caller can log them without breaking terminal startup.
export function ensureSpawnHelperExecutable(
nodePtyRoot: string,
fs: SpawnHelperFs = defaultFs
): EnsureSpawnHelperResult {
const result: EnsureSpawnHelperResult = { fixed: [], errors: [] }
for (const path of spawnHelperCandidates(nodePtyRoot, fs)) {
if (!fs.existsSync(path)) {
continue
}
let mode: number
try {
mode = fs.statSync(path).mode
} catch (error) {
result.errors.push({ path, error: errorMessage(error) })
continue
}
if (!needsExecBit(mode)) {
continue
}
try {
fs.chmodSync(path, withExecBits(mode))
result.fixed.push(path)
} catch (error) {
result.errors.push({ path, error: errorMessage(error) })
}
}
return result
}
@@ -0,0 +1,366 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { test } from 'vitest'
import {
ALL_APPLICATION_PACKAGES_SID,
alreadyHasNoSandbox,
BOOT_ABORTS_BEFORE_FALLBACK,
buildIcaclsGrantArgs,
buildNoSandboxRelaunchArgs,
decideWindowsSandboxLaunch,
fallbackMarker,
grantAllApplicationPackagesAcl,
isWindowsSandboxBreakpointExit,
markerAfterSuccessfulBoot,
parseSandboxMarker,
readSandboxMarker,
sandboxMarkerPath,
shouldAttemptAclRepair,
shouldRelaunchForGpuSandboxCrash,
shouldRelaunchForRendererSandboxCrashLoop,
WINDOWS_SANDBOX_BREAKPOINT_EXIT,
WINDOWS_SANDBOX_MARKER_FILENAME,
writeSandboxMarker
} from './windows-sandbox-fallback'
test('isWindowsSandboxBreakpointExit recognizes signed and unsigned STATUS_BREAKPOINT', () => {
assert.equal(isWindowsSandboxBreakpointExit(WINDOWS_SANDBOX_BREAKPOINT_EXIT), true)
assert.equal(isWindowsSandboxBreakpointExit(-2147483645), true)
assert.equal(isWindowsSandboxBreakpointExit(0x80000003), true)
assert.equal(isWindowsSandboxBreakpointExit(1), false)
assert.equal(isWindowsSandboxBreakpointExit('nope'), false)
})
test('alreadyHasNoSandbox honors argv and ELECTRON_DISABLE_SANDBOX', () => {
assert.equal(alreadyHasNoSandbox(['--foo', '--no-sandbox'], {}), true)
assert.equal(alreadyHasNoSandbox([], { ELECTRON_DISABLE_SANDBOX: '1' }), true)
assert.equal(alreadyHasNoSandbox([], { ELECTRON_DISABLE_SANDBOX: 'true' }), true)
assert.equal(alreadyHasNoSandbox(['--disable-gpu'], {}), false)
})
test('decideWindowsSandboxLaunch stays off outside Windows and on clean markers', () => {
assert.equal(decideWindowsSandboxLaunch({ platform: 'linux', marker: { state: 'booting' } }).enable, false)
const cleanOk = decideWindowsSandboxLaunch({
platform: 'win32',
marker: { state: 'ok' },
argv: [],
env: {}
})
assert.equal(cleanOk.enable, false)
assert.deepEqual(cleanOk.nextMarker, { state: 'booting' })
const noMarker = decideWindowsSandboxLaunch({ platform: 'win32', marker: null, argv: [], env: {} })
assert.equal(noMarker.enable, false)
assert.deepEqual(noMarker.nextMarker, { state: 'booting' })
})
test('a single mid-boot abort does NOT drop the sandbox (two-strike rule)', () => {
// First abort: prior launch left `booting` with no abort count. Could be a
// task-manager kill or power loss — sandbox stays ON, strike recorded.
const first = decideWindowsSandboxLaunch({
platform: 'win32',
marker: { state: 'booting' },
argv: [],
env: {}
})
assert.equal(first.enable, false)
assert.deepEqual(first.nextMarker, { state: 'booting', bootAborts: 1 })
// Second consecutive abort: deterministic crash loop → fallback engages.
const second = decideWindowsSandboxLaunch({
platform: 'win32',
marker: first.nextMarker,
argv: [],
env: {},
appVersion: '1.2.3'
})
assert.equal(second.enable, true)
assert.equal(second.reason, 'boot-loop')
assert.deepEqual(second.nextMarker, { state: 'fallback', reason: 'boot-loop', version: '1.2.3' })
assert.equal(BOOT_ABORTS_BEFORE_FALLBACK, 2)
})
test('sticky fallback persists within one app version', () => {
const decision = decideWindowsSandboxLaunch({
platform: 'win32',
marker: { state: 'fallback', reason: 'gpu-breakpoint', version: '1.2.3' },
argv: [],
env: {},
appVersion: '1.2.3'
})
assert.equal(decision.enable, true)
assert.equal(decision.reason, 'sticky-fallback')
assert.equal(decision.nextMarker.state, 'fallback')
assert.equal(decision.nextMarker.reason, 'gpu-breakpoint')
})
test('an app update re-probes the sandbox once instead of degrading forever', () => {
// Version changed since the fallback engaged → probe with sandbox ON.
const reprobe = decideWindowsSandboxLaunch({
platform: 'win32',
marker: { state: 'fallback', reason: 'boot-loop', version: '1.2.3' },
argv: [],
env: {},
appVersion: '1.3.0'
})
assert.equal(reprobe.enable, false)
assert.equal(reprobe.nextMarker.state, 'booting')
assert.equal(reprobe.nextMarker.reprobe, true)
// The re-probe boot aborted → straight back to fallback, no second strike.
const failedReprobe = decideWindowsSandboxLaunch({
platform: 'win32',
marker: reprobe.nextMarker,
argv: [],
env: {},
appVersion: '1.3.0'
})
assert.equal(failedReprobe.enable, true)
assert.equal(failedReprobe.reason, 'reprobe-failed')
assert.equal(failedReprobe.nextMarker.state, 'fallback')
assert.equal(failedReprobe.nextMarker.version, '1.3.0')
// A legacy fallback marker without a version stays sticky (no re-probe).
const legacy = decideWindowsSandboxLaunch({
platform: 'win32',
marker: { state: 'fallback' },
argv: [],
env: {},
appVersion: '1.3.0'
})
assert.equal(legacy.enable, true)
assert.equal(legacy.reason, 'sticky-fallback')
})
test('manual --no-sandbox is honored but never made sticky', () => {
const manual = decideWindowsSandboxLaunch({
platform: 'win32',
marker: { state: 'ok' },
argv: ['--no-sandbox'],
env: {}
})
assert.equal(manual.enable, true)
assert.equal(manual.reason, 'already-enabled')
assert.equal(manual.nextMarker.state, 'booting')
// But a relaunch-written fallback marker is preserved through the flagged boot.
const relaunched = decideWindowsSandboxLaunch({
platform: 'win32',
marker: { state: 'fallback', reason: 'gpu-breakpoint', version: '1.2.3' },
argv: ['--no-sandbox'],
env: {},
appVersion: '1.2.3'
})
assert.equal(relaunched.enable, true)
assert.equal(relaunched.nextMarker.state, 'fallback')
})
test('marker transitions after a successful boot', () => {
assert.deepEqual(markerAfterSuccessfulBoot({ fallbackActive: false }), { state: 'ok' })
assert.deepEqual(markerAfterSuccessfulBoot({ fallbackActive: true, reason: 'gpu-breakpoint', appVersion: '1.2.3' }), {
state: 'fallback',
reason: 'gpu-breakpoint',
version: '1.2.3'
})
})
test('shouldAttemptAclRepair only fires on evidence of trouble', () => {
assert.equal(shouldAttemptAclRepair(null), false)
assert.equal(shouldAttemptAclRepair({ state: 'ok' }), false)
assert.equal(shouldAttemptAclRepair({ state: 'booting' }), true)
assert.equal(shouldAttemptAclRepair({ state: 'fallback' }), true)
})
test('sandbox marker round-trips through the userData file', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-sandbox-marker-'))
try {
assert.equal(sandboxMarkerPath(dir), path.join(dir, WINDOWS_SANDBOX_MARKER_FILENAME))
assert.equal(readSandboxMarker(dir), null)
writeSandboxMarker(dir, { state: 'booting', bootAborts: 1 })
assert.deepEqual(readSandboxMarker(dir), { state: 'booting', bootAborts: 1 })
writeSandboxMarker(dir, fallbackMarker('renderer-crash-loop', '1.2.3'))
assert.deepEqual(readSandboxMarker(dir), {
state: 'fallback',
reason: 'renderer-crash-loop',
version: '1.2.3'
})
assert.equal(parseSandboxMarker({ state: 'fallback' })?.state, 'fallback')
assert.equal(parseSandboxMarker({ state: 'nope' }), null)
// Unknown reason strings and junk fields are dropped, not fatal.
assert.deepEqual(parseSandboxMarker({ state: 'fallback', reason: 'weird', bootAborts: -3 }), {
state: 'fallback'
})
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})
test('buildIcaclsGrantArgs targets ALL APPLICATION PACKAGES with inherited RX', () => {
assert.deepEqual(buildIcaclsGrantArgs('C:\\Hermes\\win-unpacked'), [
'C:\\Hermes\\win-unpacked',
'/grant',
`*${ALL_APPLICATION_PACKAGES_SID}:(OI)(CI)(RX)`,
'/T',
'/C',
'/Q'
])
})
test('grantAllApplicationPackagesAcl is a no-op off Windows and reports exec failures', () => {
assert.deepEqual(grantAllApplicationPackagesAcl('C:\\x', { platform: 'darwin' }), { ok: false })
const calls: Array<{ file: string; args: readonly string[] }> = []
const ok = grantAllApplicationPackagesAcl('C:\\Hermes', {
platform: 'win32',
execFileSync(file, args) {
calls.push({ file, args })
return Buffer.alloc(0)
}
})
assert.deepEqual(ok, { ok: true })
assert.equal(calls.length, 1)
assert.equal(calls[0]?.file, 'icacls')
assert.deepEqual(calls[0]?.args, buildIcaclsGrantArgs('C:\\Hermes'))
const failed = grantAllApplicationPackagesAcl('C:\\Hermes', {
platform: 'win32',
execFileSync() {
throw new Error('access denied')
}
})
assert.equal(failed.ok, false)
assert.match(String(failed.error), /access denied/)
})
test('shouldRelaunchForGpuSandboxCrash only fires once for GPU breakpoint deaths', () => {
assert.equal(
shouldRelaunchForGpuSandboxCrash({
platform: 'win32',
details: { type: 'GPU', exitCode: WINDOWS_SANDBOX_BREAKPOINT_EXIT },
alreadyNoSandbox: false,
relaunchAttempted: false
}),
true
)
assert.equal(
shouldRelaunchForGpuSandboxCrash({
platform: 'win32',
details: { type: 'GPU', exitCode: WINDOWS_SANDBOX_BREAKPOINT_EXIT },
alreadyNoSandbox: true,
relaunchAttempted: false
}),
false
)
assert.equal(
shouldRelaunchForGpuSandboxCrash({
platform: 'win32',
details: { type: 'GPU', exitCode: WINDOWS_SANDBOX_BREAKPOINT_EXIT },
alreadyNoSandbox: false,
relaunchAttempted: true
}),
false
)
assert.equal(
shouldRelaunchForGpuSandboxCrash({
platform: 'win32',
details: { type: 'renderer', exitCode: WINDOWS_SANDBOX_BREAKPOINT_EXIT },
alreadyNoSandbox: false,
relaunchAttempted: false
}),
false
)
assert.equal(
shouldRelaunchForGpuSandboxCrash({
platform: 'linux',
details: { type: 'GPU', exitCode: WINDOWS_SANDBOX_BREAKPOINT_EXIT },
alreadyNoSandbox: false,
relaunchAttempted: false
}),
false
)
})
test('renderer crash-loop relaunch requires the sandbox breakpoint signature', () => {
assert.equal(
shouldRelaunchForRendererSandboxCrashLoop({
platform: 'win32',
reason: 'crashed',
exitCode: WINDOWS_SANDBOX_BREAKPOINT_EXIT,
alreadyNoSandbox: false,
relaunchAttempted: false
}),
true
)
// Unrelated renderer crash loops (plain crash, OOM churn) keep the sandbox.
assert.equal(
shouldRelaunchForRendererSandboxCrashLoop({
platform: 'win32',
reason: 'crashed',
exitCode: 1,
alreadyNoSandbox: false,
relaunchAttempted: false
}),
false
)
assert.equal(
shouldRelaunchForRendererSandboxCrashLoop({
platform: 'win32',
reason: 'oom',
exitCode: WINDOWS_SANDBOX_BREAKPOINT_EXIT,
alreadyNoSandbox: false,
relaunchAttempted: false
}),
false
)
assert.equal(
shouldRelaunchForRendererSandboxCrashLoop({
platform: 'win32',
reason: 'crashed',
exitCode: WINDOWS_SANDBOX_BREAKPOINT_EXIT,
alreadyNoSandbox: true,
relaunchAttempted: false
}),
false
)
assert.equal(
shouldRelaunchForRendererSandboxCrashLoop({
platform: 'linux',
reason: 'crashed',
exitCode: WINDOWS_SANDBOX_BREAKPOINT_EXIT,
alreadyNoSandbox: false,
relaunchAttempted: false
}),
false
)
})
test('buildNoSandboxRelaunchArgs appends a single --no-sandbox flag', () => {
assert.deepEqual(buildNoSandboxRelaunchArgs(['--foo', '--no-sandbox', 'hermes://x']), [
'--foo',
'hermes://x',
'--no-sandbox'
])
})
@@ -0,0 +1,394 @@
/**
* Windows Chromium/Electron sandbox recovery for #38216.
*
* On some Windows hosts the GPU/renderer sandboxes die with STATUS_BREAKPOINT
* (`0x80000003` / exit `-2147483645`). Chromium then FATAL-exits
* ("GPU process isn't usable. Goodbye.") before the UI is usable.
*
* Recovery ladder, all scoped to win32:
*
* 1. ACL repair (first line): grant `S-1-15-2-2` (ALL APPLICATION PACKAGES)
* RX on the install tree. A missing ACE plus orphan AppContainer SIDs is a
* known Chromium CHECK failure (electron/electron#51761). Runs at install
* time, and again at launch ONLY when the marker shows a prior aborted
* boot never on healthy launches (icacls /T recursion is not free).
* 2. `--no-sandbox` (second line): enabled only on strong evidence
* a signature-confirmed GPU/renderer breakpoint death, or TWO consecutive
* mid-boot aborts (a single abort can be a task-manager kill or power
* loss; the reported failure mode is a deterministic 100% crash loop).
* 3. The fallback is sticky per app version, not forever: after an update
* the sandbox is re-probed once (a new Electron or an installer-applied
* ACL grant may have fixed the host). If the re-probe boot aborts, the
* next launch goes straight back to `--no-sandbox`.
*
* Pure helpers stay injectable so tests never boot Electron or touch real ACLs.
*/
import fs from 'node:fs'
import path from 'node:path'
export const WINDOWS_SANDBOX_MARKER_FILENAME = 'windows-sandbox-fallback.json'
/** Well-known SID for "ALL APPLICATION PACKAGES". */
export const ALL_APPLICATION_PACKAGES_SID = 'S-1-15-2-2'
/** STATUS_BREAKPOINT as a signed Win32 exit code (WER / Chromium). */
export const WINDOWS_SANDBOX_BREAKPOINT_EXIT = -2147483645
/** Consecutive mid-boot aborts required before enabling --no-sandbox. */
export const BOOT_ABORTS_BEFORE_FALLBACK = 2
export type SandboxMarkerState = 'booting' | 'fallback' | 'ok'
export type SandboxFallbackReason = 'gpu-breakpoint' | 'renderer-crash-loop' | 'boot-loop'
export interface SandboxMarker {
state: SandboxMarkerState
/** Why the fallback engaged (state === 'fallback'). */
reason?: SandboxFallbackReason
/** App version that entered fallback — a version change triggers a re-probe. */
version?: string
/** Consecutive aborted boots observed so far (state === 'booting'). */
bootAborts?: number
/** This boot is a sandbox re-probe after an app update; an abort returns
* straight to fallback instead of restarting the two-strike count. */
reprobe?: boolean
}
export function sandboxMarkerPath(userDataDir: string): string {
return path.join(String(userDataDir || ''), WINDOWS_SANDBOX_MARKER_FILENAME)
}
export function isWindowsSandboxBreakpointExit(exitCode: unknown): boolean {
const n = Number(exitCode)
if (!Number.isFinite(n)) {
return false
}
// Signed STATUS_BREAKPOINT, or the same 32-bit pattern as unsigned.
return n === WINDOWS_SANDBOX_BREAKPOINT_EXIT || n >>> 0 === 0x80000003
}
export function alreadyHasNoSandbox(argv: readonly string[] = [], env: NodeJS.ProcessEnv = process.env): boolean {
if (Array.isArray(argv) && argv.some(arg => arg === '--no-sandbox')) {
return true
}
const disable = String(env.ELECTRON_DISABLE_SANDBOX || '')
.trim()
.toLowerCase()
return disable === '1' || disable === 'true' || disable === 'yes' || disable === 'on'
}
const FALLBACK_REASONS: readonly string[] = ['gpu-breakpoint', 'renderer-crash-loop', 'boot-loop']
export function parseSandboxMarker(raw: unknown): SandboxMarker | null {
if (!raw || typeof raw !== 'object') {
return null
}
const record = raw as Record<string, unknown>
const state = record.state
if (state !== 'booting' && state !== 'fallback' && state !== 'ok') {
return null
}
const marker: SandboxMarker = { state }
if (typeof record.reason === 'string' && FALLBACK_REASONS.includes(record.reason)) {
marker.reason = record.reason as SandboxFallbackReason
}
if (typeof record.version === 'string' && record.version) {
marker.version = record.version
}
const aborts = Number(record.bootAborts)
if (Number.isInteger(aborts) && aborts > 0) {
marker.bootAborts = aborts
}
if (record.reprobe === true) {
marker.reprobe = true
}
return marker
}
export function readSandboxMarker(userDataDir: string, { readFileSync = fs.readFileSync } = {}): SandboxMarker | null {
try {
const raw = JSON.parse(readFileSync(sandboxMarkerPath(userDataDir), 'utf8'))
return parseSandboxMarker(raw)
} catch {
return null
}
}
export function writeSandboxMarker(
userDataDir: string,
marker: SandboxMarker,
{
mkdirSync = fs.mkdirSync,
writeFileSync = fs.writeFileSync
}: {
mkdirSync?: typeof fs.mkdirSync
writeFileSync?: typeof fs.writeFileSync
} = {}
): void {
const dir = String(userDataDir || '')
if (!dir) {
return
}
mkdirSync(dir, { recursive: true })
writeFileSync(sandboxMarkerPath(dir), `${JSON.stringify(marker)}\n`, 'utf8')
}
export interface SandboxLaunchDecision {
enable: boolean
reason: string | null
/** Marker to persist immediately, before GPU/sandbox children start. */
nextMarker: SandboxMarker
}
/**
* Single launch-time transition: decide whether this Windows launch disables
* the Chromium sandbox AND what the marker becomes for crash-detection on the
* next launch.
*
* - `booting` left behind the prior launch aborted mid-boot. One abort is
* tolerated (could be a kill/power loss); the SECOND consecutive abort or
* a single abort during a post-update re-probe engages the fallback.
* - `fallback` is sticky within one app version. A version change re-probes
* the sandbox once so a fixed host (new Electron, installer ACL repair)
* returns to full sandboxing instead of degrading forever.
* - A manual `--no-sandbox` / ELECTRON_DISABLE_SANDBOX launch is honored but
* NOT made sticky: the marker keeps its normal lifecycle so the flag's
* removal restores the sandbox.
*/
export function decideWindowsSandboxLaunch(
options: {
platform?: NodeJS.Platform | string
argv?: readonly string[]
env?: NodeJS.ProcessEnv
marker?: SandboxMarker | null
appVersion?: string
} = {}
): SandboxLaunchDecision {
const appVersion = String(options.appVersion || '')
if ((options.platform ?? process.platform) !== 'win32') {
return { enable: false, reason: null, nextMarker: { state: 'booting' } }
}
const argv = options.argv ?? process.argv
const env = options.env ?? process.env
const marker = options.marker ?? null
if (alreadyHasNoSandbox(argv, env)) {
// Honor the explicit flag; keep the marker lifecycle unchanged. When the
// relaunch path set the flag, the fallback marker it wrote is preserved.
const nextMarker: SandboxMarker = marker?.state === 'fallback' ? marker : { state: 'booting' }
return { enable: true, reason: 'already-enabled', nextMarker }
}
if (marker?.state === 'fallback') {
if (marker.version && appVersion && marker.version !== appVersion) {
// App updated since the fallback engaged — re-probe the sandbox once.
return {
enable: false,
reason: null,
nextMarker: { state: 'booting', reprobe: true, bootAborts: 0 }
}
}
return {
enable: true,
reason: 'sticky-fallback',
nextMarker: { ...marker, version: marker.version || appVersion || undefined }
}
}
if (marker?.state === 'booting') {
const abortsObserved = (marker.bootAborts ?? 0) + 1
if (marker.reprobe) {
// The one post-update sandboxed re-probe aborted → back to fallback.
return {
enable: true,
reason: 'reprobe-failed',
nextMarker: fallbackMarker('boot-loop', appVersion)
}
}
if (abortsObserved >= BOOT_ABORTS_BEFORE_FALLBACK) {
return {
enable: true,
reason: 'boot-loop',
nextMarker: fallbackMarker('boot-loop', appVersion)
}
}
return {
enable: false,
reason: null,
nextMarker: { state: 'booting', bootAborts: abortsObserved }
}
}
// No marker, or a clean `ok` from the previous run.
return { enable: false, reason: null, nextMarker: { state: 'booting' } }
}
export function fallbackMarker(reason: SandboxFallbackReason, appVersion?: string): SandboxMarker {
const marker: SandboxMarker = { state: 'fallback', reason }
if (appVersion) {
marker.version = appVersion
}
return marker
}
/**
* After the main window reaches ready-to-show: keep the sticky fallback when
* we launched with `--no-sandbox`, otherwise mark a clean boot so future
* launches trust the sandbox again.
*/
export function markerAfterSuccessfulBoot(options: {
fallbackActive: boolean
reason?: SandboxFallbackReason
appVersion?: string
}): SandboxMarker {
if (!options.fallbackActive) {
return { state: 'ok' }
}
return fallbackMarker(options.reason ?? 'boot-loop', options.appVersion)
}
/**
* ACL repair is not free (`icacls /T` recurses the whole install tree), so it
* only runs when there is evidence of trouble: a prior launch aborted
* mid-boot, or the fallback already engaged. Healthy hosts never pay for it
* the installer already granted the ACE at install time.
*/
export function shouldAttemptAclRepair(marker: SandboxMarker | null | undefined): boolean {
return marker?.state === 'booting' || marker?.state === 'fallback'
}
/**
* Build `icacls` argv that grants ALL APPLICATION PACKAGES RX with inheritance.
* `/T` applies to existing children (win-unpacked DLLs); `/C` continues on
* errors; `/Q` stays quiet for installer logs.
*/
export function buildIcaclsGrantArgs(targetDir: string): string[] {
return [String(targetDir), '/grant', `*${ALL_APPLICATION_PACKAGES_SID}:(OI)(CI)(RX)`, '/T', '/C', '/Q']
}
export function grantAllApplicationPackagesAcl(
targetDir: string,
{
platform = process.platform,
execFileSync
}: {
platform?: NodeJS.Platform | string
execFileSync?: (file: string, args: readonly string[], options?: object) => Buffer | string
} = {}
): { ok: boolean; error?: string } {
if (platform !== 'win32') {
return { ok: false }
}
const dir = String(targetDir || '').trim()
if (!dir || typeof execFileSync !== 'function') {
return { ok: false, error: 'missing-target-or-exec' }
}
try {
execFileSync('icacls', buildIcaclsGrantArgs(dir), {
windowsHide: true,
timeout: 30_000,
stdio: 'ignore'
})
return { ok: true }
} catch (error) {
return {
ok: false,
error: error instanceof Error ? error.message : String(error)
}
}
}
/**
* True when a GPU child died with the #38216 breakpoint signature and we
* should one-shot relaunch with `--no-sandbox` before Chromium FATAL-exits.
*/
export function shouldRelaunchForGpuSandboxCrash(options: {
platform?: NodeJS.Platform | string
details?: { type?: string; exitCode?: number | string } | null
alreadyNoSandbox?: boolean
relaunchAttempted?: boolean
}): boolean {
if ((options.platform ?? process.platform) !== 'win32') {
return false
}
if (options.alreadyNoSandbox || options.relaunchAttempted) {
return false
}
const type = String(options.details?.type || '').toLowerCase()
if (type !== 'gpu') {
return false
}
return isWindowsSandboxBreakpointExit(options.details?.exitCode)
}
/**
* True when a renderer crash loop carries the sandbox breakpoint signature
* and a one-shot `--no-sandbox` relaunch should replace the dead window
* (#38216 renderer flavor; same recovery as #56726). Gated on the breakpoint
* exit code so unrelated renderer crash loops (bad extension, OOM churn)
* don't silently drop the sandbox.
*/
export function shouldRelaunchForRendererSandboxCrashLoop(options: {
platform?: NodeJS.Platform | string
reason?: string
exitCode?: number | string
alreadyNoSandbox?: boolean
relaunchAttempted?: boolean
}): boolean {
if ((options.platform ?? process.platform) !== 'win32') {
return false
}
if (options.alreadyNoSandbox || options.relaunchAttempted) {
return false
}
if (String(options.reason || '') !== 'crashed') {
return false
}
return isWindowsSandboxBreakpointExit(options.exitCode)
}
export function buildNoSandboxRelaunchArgs(argv: readonly string[]): string[] {
const args = (Array.isArray(argv) ? argv : []).filter(arg => arg !== '--no-sandbox')
args.push('--no-sandbox')
return args
}
@@ -0,0 +1,96 @@
import assert from 'node:assert/strict'
import { test } from 'vitest'
import { installWindowsSystemCaTrust, type NodeTlsCaApi } from './windows-system-ca'
function fakeTlsApi(
defaults: string[] = ['bundled-ca', 'extra-ca'],
system: string[] = ['windows-root-ca']
): NodeTlsCaApi & { installed: string[][] } {
const installed: string[][] = []
return {
installed,
getCACertificates(type = 'default') {
return type === 'system' ? [...system] : [...defaults]
},
setDefaultCACertificates(certificates) {
installed.push([...certificates])
}
}
}
test('installs Windows system CAs without dropping existing defaults', () => {
const tlsApi = fakeTlsApi(['mozilla-root', 'extra-ca'], ['machine-root', 'user-root'])
const result = installWindowsSystemCaTrust(tlsApi, 'win32')
assert.deepEqual(tlsApi.installed, [['mozilla-root', 'extra-ca', 'machine-root', 'user-root']])
assert.deepEqual(result, {
applied: true,
systemCertificateCount: 2,
totalCertificateCount: 4
})
})
test('does not inspect or replace CAs outside Windows', () => {
let reads = 0
const tlsApi: NodeTlsCaApi = {
getCACertificates() {
reads += 1
return []
},
setDefaultCACertificates() {
throw new Error('should not install')
}
}
const result = installWindowsSystemCaTrust(tlsApi, 'darwin')
assert.equal(reads, 0)
assert.deepEqual(result, {
applied: false,
systemCertificateCount: 0,
totalCertificateCount: 0
})
})
test('leaves the existing defaults untouched when Windows has no system CAs', () => {
const tlsApi = fakeTlsApi(['mozilla-root'], [])
const result = installWindowsSystemCaTrust(tlsApi, 'win32')
assert.deepEqual(tlsApi.installed, [])
assert.deepEqual(result, {
applied: false,
systemCertificateCount: 0,
totalCertificateCount: 1
})
})
test('fails open when the runtime cannot load the Windows certificate store', () => {
const tlsApi: NodeTlsCaApi = {
getCACertificates(type = 'default') {
if (type === 'system') {
throw new Error('certificate store unavailable')
}
return ['mozilla-root']
},
setDefaultCACertificates() {
throw new Error('should not install')
}
}
const result = installWindowsSystemCaTrust(tlsApi, 'win32')
assert.deepEqual(result, {
applied: false,
systemCertificateCount: 0,
totalCertificateCount: 0,
error: 'certificate store unavailable'
})
})
@@ -0,0 +1,53 @@
interface NodeTlsCaApi {
getCACertificates(type?: 'default' | 'system'): string[]
setDefaultCACertificates(certificates: string[]): void
}
interface WindowsSystemCaResult {
applied: boolean
systemCertificateCount: number
totalCertificateCount: number
error?: string
}
function installWindowsSystemCaTrust(tlsApi: NodeTlsCaApi, platform = process.platform): WindowsSystemCaResult {
if (platform !== 'win32') {
return {
applied: false,
systemCertificateCount: 0,
totalCertificateCount: 0
}
}
try {
const defaultCertificates = tlsApi.getCACertificates('default')
const systemCertificates = tlsApi.getCACertificates('system')
if (systemCertificates.length === 0) {
return {
applied: false,
systemCertificateCount: 0,
totalCertificateCount: defaultCertificates.length
}
}
const certificates = [...defaultCertificates, ...systemCertificates]
tlsApi.setDefaultCACertificates(certificates)
return {
applied: true,
systemCertificateCount: systemCertificates.length,
totalCertificateCount: certificates.length
}
} catch (error) {
return {
applied: false,
systemCertificateCount: 0,
totalCertificateCount: 0,
error: error instanceof Error ? error.message : String(error)
}
}
}
export { installWindowsSystemCaTrust }
export type { NodeTlsCaApi, WindowsSystemCaResult }
+56 -8
View File
@@ -6,16 +6,17 @@
import assert from 'node:assert/strict'
import { test } from 'vitest'
import { test, vi } from 'vitest'
import {
applyZoomLevel,
clampZoomLevel,
installZoomReassertOnWindowEvents,
percentToZoomLevel,
ZOOM_REASSERT_WINDOW_EVENTS,
ZOOM_RESIZE_REASSERT_DELAY_MS,
ZOOM_STORAGE_KEY,
zoomLevelToPercent,
zoomReassertWindowEvents,
zoomWiringForWindowKind
} from './zoom'
@@ -64,7 +65,7 @@ test('extreme percentages clamp to the level bounds', () => {
assert.equal(percentToZoomLevel(1_000_000), 9)
})
test('installZoomReassertOnWindowEvents wires show, restore, and cross-display moves', () => {
test('installZoomReassertOnWindowEvents wires show, restore, resize, and cross-display moves on macOS and Windows', () => {
const handlers = new Map()
const win = {
@@ -75,15 +76,62 @@ test('installZoomReassertOnWindowEvents wires show, restore, and cross-display m
}
let calls = 0
installZoomReassertOnWindowEvents(win, () => {
calls += 1
})
installZoomReassertOnWindowEvents(
win,
() => {
calls += 1
},
'win32'
)
assert.deepEqual([...handlers.keys()], [...ZOOM_REASSERT_WINDOW_EVENTS])
assert.deepEqual([...handlers.keys()], zoomReassertWindowEvents('win32'))
handlers.get('show')()
handlers.get('restore')()
handlers.get('resized')()
handlers.get('moved')()
assert.equal(calls, 3)
assert.equal(calls, 4)
})
test('installZoomReassertOnWindowEvents debounces Linux resize and move events at the trailing edge', () => {
vi.useFakeTimers()
try {
const handlers = new Map()
let destroyed = false
const win = {
isDestroyed: () => destroyed,
on(event, listener) {
handlers.set(event, listener)
}
}
let calls = 0
installZoomReassertOnWindowEvents(
win,
() => {
calls += 1
},
'linux'
)
assert.deepEqual([...handlers.keys()], zoomReassertWindowEvents('linux'))
handlers.get('resize')()
vi.advanceTimersByTime(ZOOM_RESIZE_REASSERT_DELAY_MS / 2)
handlers.get('move')()
vi.advanceTimersByTime(ZOOM_RESIZE_REASSERT_DELAY_MS / 2)
assert.equal(calls, 0)
vi.advanceTimersByTime(ZOOM_RESIZE_REASSERT_DELAY_MS / 2)
assert.equal(calls, 1)
handlers.get('resize')()
destroyed = true
vi.advanceTimersByTime(ZOOM_RESIZE_REASSERT_DELAY_MS)
assert.equal(calls, 1)
} finally {
vi.useRealTimers()
}
})
test('installZoomReassertOnWindowEvents skips destroyed windows', () => {
+26 -7
View File
@@ -48,23 +48,42 @@ export function applyZoomLevel(webContents, level) {
return clamped
}
// Chromium on Windows can drop webContents zoom when a BrowserWindow is minimized
// and restored or crosses onto a monitor with different display scaling. Re-apply
// the persisted level after each completed lifecycle transition.
export const ZOOM_REASSERT_WINDOW_EVENTS = ['show', 'restore', 'moved']
// Chromium can drop webContents zoom when a BrowserWindow is resized, minimized
// and restored, or crosses onto a monitor with different display scaling. macOS
// and Windows provide trailing `resized`/`moved` events; Linux only provides the
// noisy `resize`/`move` pair, so debounce those fallbacks before re-applying the
// persisted level.
export const ZOOM_RESIZE_REASSERT_DELAY_MS = 100
export function installZoomReassertOnWindowEvents(win, reassert) {
export function zoomReassertWindowEvents(platform = process.platform) {
return platform === 'linux' ? ['show', 'restore', 'resize', 'move'] : ['show', 'restore', 'resized', 'moved']
}
export function installZoomReassertOnWindowEvents(win, reassert, platform = process.platform) {
if (!win?.on) {
return
}
for (const event of ZOOM_REASSERT_WINDOW_EVENTS) {
let resizeTimer
for (const event of zoomReassertWindowEvents(platform)) {
win.on(event, () => {
if (win.isDestroyed?.()) {
return
}
reassert()
if (event !== 'resize' && event !== 'move') {
reassert()
return
}
clearTimeout(resizeTimer)
resizeTimer = setTimeout(() => {
if (!win.isDestroyed?.()) {
reassert()
}
}, ZOOM_RESIZE_REASSERT_DELAY_MS)
})
}
}
@@ -0,0 +1,159 @@
// Measure a profile switch end-to-end: click a profile square in the rail,
// then break the wall time into the phases the renderer can observe:
// - getConnection IPC (Electron: pool backend spawn / reuse + readiness)
// - gateway WS connect
// - swap-target clear ($gatewaySwapTarget → sidebar loader gone)
// - sidebar session rows for the new profile painted
//
// Instruments window.hermesDesktop.getConnection + WebSocket to timestamp the
// phases without touching app code.
//
// Usage:
// node apps/desktop/scripts/measure-profile-switch.mjs <profileName> [settleTimeoutMs]
const CDP_HTTP = 'http://127.0.0.1:9222'
const PROFILE = process.argv[2]
const SETTLE_TIMEOUT = Number(process.argv[3] || 60000)
if (!PROFILE) {
console.error('usage: measure-profile-switch.mjs <profileName>')
process.exit(1)
}
class CDP {
constructor(ws) { this.ws = ws; this.id = 0; this.pending = new Map() }
static async open(url) {
const ws = new WebSocket(url)
await new Promise((r) => ws.addEventListener('open', r, { once: true }))
const cdp = new CDP(ws)
ws.addEventListener('message', (ev) => {
const m = JSON.parse(ev.data.toString())
if (m.id != null && cdp.pending.has(m.id)) {
const { resolve, reject } = cdp.pending.get(m.id)
cdp.pending.delete(m.id)
if (m.error) reject(new Error(m.error.message))
else resolve(m.result)
}
})
ws.addEventListener('close', () => {
for (const { reject } of cdp.pending.values()) reject(new Error('CDP socket closed'))
cdp.pending.clear()
})
return cdp
}
send(method, params) {
const id = ++this.id
return new Promise((res, rej) => {
this.pending.set(id, { resolve: res, reject: rej })
this.ws.send(JSON.stringify({ id, method, params }))
})
}
async eval(expr) {
const r = await this.send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true })
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || 'eval failed')
return r.result.value
}
close() { this.ws.close() }
}
async function main() {
const list = await (await fetch(`${CDP_HTTP}/json`)).json()
const target = list.find((t) => t.type === 'page' && /5174/.test(t.url))
if (!target) { console.error('renderer not found on 9222'); process.exit(1) }
const cdp = await CDP.open(target.webSocketDebuggerUrl)
// Instrument getConnection + WebSocket once.
await cdp.eval(`(() => {
if (window.__PROFILE_SWITCH_OBS__) return 'already'
const obs = { events: [] }
const mark = (name, extra) => obs.events.push({ name, t: performance.now(), ...(extra || {}) })
window.__PROFILE_SWITCH_OBS__ = obs
window.__psMark = mark
const desktop = window.hermesDesktop
if (desktop && desktop.getConnection) {
const orig = desktop.getConnection.bind(desktop)
desktop.getConnection = async (profile) => {
mark('getConnection:start', { profile })
try {
const res = await orig(profile)
mark('getConnection:done', { profile })
return res
} catch (e) {
mark('getConnection:error', { profile, error: String(e).slice(0, 120) })
throw e
}
}
}
const OrigWS = window.WebSocket
window.WebSocket = function (url, ...rest) {
const ws = new OrigWS(url, ...rest)
if (String(url).includes('/api/ws')) {
mark('ws:new', { url: String(url).replace(/token=[^&]+/, 'token=…').slice(0, 90) })
ws.addEventListener('open', () => mark('ws:open'))
}
return ws
}
window.WebSocket.prototype = OrigWS.prototype
Object.assign(window.WebSocket, OrigWS)
return 'installed'
})()`)
const before = await cdp.eval(`(() => {
const rail = document.querySelector('[data-slot="profile-rail"]')
return {
railButtons: rail ? [...rail.querySelectorAll('[role="tab"], button')].map(b => (b.getAttribute('aria-label') || b.title || b.textContent || '').slice(0, 30)) : [],
sessions: document.querySelectorAll('[data-slot="sidebar-session-row"], [data-session-id]').length
}
})()`)
console.log('rail buttons:', JSON.stringify(before.railButtons))
const clicked = await cdp.eval(`(() => {
window.__psMark('click', { profile: ${JSON.stringify(PROFILE)} })
const rail = document.querySelector('[data-slot="profile-rail"]')
if (!rail) return 'no-rail'
const target = [...rail.querySelectorAll('button, [role="tab"]')].find(b =>
((b.getAttribute('aria-label') || '') + ' ' + (b.title || '') + ' ' + (b.textContent || '')).toLowerCase().includes(${JSON.stringify(PROFILE.toLowerCase())}))
if (!target) return 'not-found'
target.click()
return 'clicked'
})()`)
console.log('click:', clicked)
if (clicked !== 'clicked') { cdp.close(); process.exit(2) }
// Poll until the swap settles: loader gone + session rows painted (or empty
// list settled) + active profile pill shows the target.
const t0 = Date.now()
let settled = null
while (Date.now() - t0 < SETTLE_TIMEOUT) {
await new Promise((r) => setTimeout(r, 100))
const s = await cdp.eval(`(() => {
// The swap overlay stays mounted at opacity-0 after the swap — check the
// computed opacity of the container that holds the "Waking up …" label.
const label = [...document.querySelectorAll('div[aria-hidden]')].find(el => /waking up/i.test(el.textContent || ''))
const overlayVisible = label ? Number(getComputedStyle(label).opacity) > 0.05 : false
return {
t: performance.now(),
overlayVisible,
sessions: document.querySelectorAll('[data-slot="row-button"]').length
}
})()`)
if (!s.overlayVisible && s.sessions > 0) { settled = s; break }
}
await new Promise((r) => setTimeout(r, 400))
const obs = await cdp.eval('window.__PROFILE_SWITCH_OBS__')
const events = obs.events
const click = events.find((e) => e.name === 'click' && e.profile === PROFILE)
console.log('\n=== PHASES (ms after click) ===')
for (const e of events) {
if (e.t < click.t - 5) continue
console.log(`${(e.t - click.t).toFixed(0).padStart(7)} ${e.name}${e.profile ? ' [' + e.profile + ']' : ''}${e.error ? ' ' + e.error : ''}${e.url ? ' ' + e.url : ''}`)
}
console.log(settled ? `\nsettled (loader gone + rows painted) at ~${Date.now() - t0} ms wall` : '\nTIMEOUT waiting for settle')
cdp.close()
}
main().catch((e) => { console.error(e); process.exit(1) })
@@ -10,7 +10,7 @@
import { writeFileSync } from 'node:fs'
const CDP_HTTP = 'http://127.0.0.1:9222'
const CDP_HTTP = process.env.CDP_HTTP || 'http://127.0.0.1:9222'
const A = process.argv[2]
const B = process.argv[3]
const ROUNDS = Number(process.argv[4] || 2)
@@ -0,0 +1,71 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import type { ChatBarState } from '@/app/chat/composer/types'
import { $activeSessionId, $currentModel, setCurrentModel, setCurrentModelSource } from '@/store/session'
import { ModelPill } from './model-pill'
const modelState = (over: Partial<ChatBarState['model']> = {}): ChatBarState['model'] => ({
canSwitch: true,
model: 'gpt-6',
provider: 'openai',
...over
})
afterEach(() => {
cleanup()
$activeSessionId.set(null)
setCurrentModel('')
setCurrentModelSource('')
})
// #62055: a manual composer pick is sticky and silently overrides the
// Settings → Model default for every NEW chat. The pill must say so.
describe('ModelPill pinned-override badge', () => {
it('shows the pin dot on a draft running a manual pick', () => {
setCurrentModel('deepseek/deepseek-v4-flash')
setCurrentModelSource('manual')
$activeSessionId.set(null)
render(<ModelPill disabled={false} model={modelState()} />)
expect(screen.getByTestId('model-pinned-dot')).toBeTruthy()
})
it('stays quiet when the composer reflects the profile default', () => {
setCurrentModel('google/gemma-4-26b-a4b-it:free')
setCurrentModelSource('default')
$activeSessionId.set(null)
render(<ModelPill disabled={false} model={modelState()} />)
expect(screen.queryByTestId('model-pinned-dot')).toBeNull()
})
it('stays quiet on a live session (footer shows that session, not the pin)', () => {
setCurrentModel('deepseek/deepseek-v4-flash')
setCurrentModelSource('manual')
$activeSessionId.set('live-1')
render(<ModelPill disabled={false} model={modelState()} />)
expect(screen.queryByTestId('model-pinned-dot')).toBeNull()
})
it('is exercised in both render paths', () => {
setCurrentModel('deepseek/deepseek-v4-flash')
setCurrentModelSource('manual')
$activeSessionId.set(null)
// Fallback (no live menu) path.
const { unmount } = render(<ModelPill disabled={false} model={modelState()} />)
expect(screen.getByTestId('model-pinned-dot')).toBeTruthy()
unmount()
// Live-menu (dropdown) path.
render(<ModelPill disabled={false} model={modelState({ modelMenuContent: <div /> })} />)
expect(screen.getByTestId('model-pinned-dot')).toBeTruthy()
expect($currentModel.get()).toBe('deepseek/deepseek-v4-flash')
})
})
@@ -11,8 +11,10 @@ import { ChevronDown } from '@/lib/icons'
import { formatModelStatusLabel } from '@/lib/model-status-label'
import { cn } from '@/lib/utils'
import {
$activeSessionId,
$currentFastMode,
$currentModel,
$currentModelSource,
$currentProvider,
$currentReasoningEffort,
setModelPickerOpen
@@ -44,8 +46,17 @@ export function ModelPill({
const currentProvider = useStore($currentProvider)
const fastMode = useStore($currentFastMode)
const reasoningEffort = useStore($currentReasoningEffort)
const modelSource = useStore($currentModelSource)
const activeSessionId = useStore($activeSessionId)
const [open, setOpen] = useState(false)
// The composer pick is sticky: a manual selection is pinned and every NEW
// chat uses it instead of the Settings → Model default — silently, which has
// cost users real money on a forgotten paid-model pick (#62055). Surface the
// pin whenever a draft (no live session) is running on a manual override. A
// live session's footer reflects that session's model, so no badge there.
const pinnedOverride = !activeSessionId && modelSource === 'manual' && Boolean(currentModel.trim())
// The model resolves a beat after the gateway/session comes up. Rather than
// flash a literal "No model", show a quiet loader (inherits the pill text
// color at half opacity) until a model lands.
@@ -58,6 +69,14 @@ export function ModelPill({
) : (
<GlyphSpinner className="opacity-50" spinner="braille" />
)}
{pinnedOverride && (
<span
aria-label={copy.modelPinned}
className="size-1 shrink-0 rounded-full bg-(--ui-accent)"
data-testid="model-pinned-dot"
role="img"
/>
)}
<ChevronDown className="size-2.5 shrink-0 opacity-50" />
</>
)
@@ -71,11 +90,15 @@ export function ModelPill({
)
: PILL
const title = currentProvider ? copy.modelTitle(currentProvider, currentModel || copy.modelNone) : copy.switchModel
const baseTitle = currentProvider
? copy.modelTitle(currentProvider, currentModel || copy.modelNone)
: copy.switchModel
const title = pinnedOverride ? `${baseTitle}${copy.modelPinned}` : baseTitle
if (!model.modelMenuContent) {
return (
<Tip label={copy.openModelPicker} side="top">
<Tip label={pinnedOverride ? `${copy.openModelPicker}${copy.modelPinned}` : copy.openModelPicker} side="top">
<Button
aria-label={copy.openModelPicker}
className={pillClass}
+10 -2
View File
@@ -23,7 +23,7 @@ import { cn } from '@/lib/utils'
import { $pinnedSessionIds } from '@/store/layout'
import { $petActive } from '@/store/pet'
import { $petOverlayActive } from '@/store/pet-overlay'
import { $gatewaySwapTarget } from '@/store/profile'
import { $gatewaySwapTarget, $profiles } from '@/store/profile'
import {
$contextSuggestions,
$freshDraftReady,
@@ -50,6 +50,7 @@ import { useComposerScope } from './composer/scope'
import type { ChatBarState } from './composer/types'
import { type DroppedFile, partitionDroppedFiles } from './hooks/use-composer-actions'
import { type DragKind, useFileDropZone } from './hooks/use-file-drop-zone'
import { ProfileTag } from './profile-tag'
import { useRuntimeMessageRepository } from './runtime-repository'
import { ScrollToBottomButton } from './scroll-to-bottom-button'
import { useSessionView } from './session-view'
@@ -101,12 +102,18 @@ function ChatHeader({
}: ChatHeaderProps) {
const sessions = useStore($sessions)
const pinnedSessionIds = useStore($pinnedSessionIds)
const profiles = useStore($profiles)
const activeStoredSession =
(selectedSessionId && sessions.find(session => sessionMatchesStoredId(session, selectedSessionId))) || null
const title = activeStoredSession ? sessionTitle(activeStoredSession) : 'New session'
// Which agent/persona owns this chat — glanceable in the header once a
// second profile exists, so the open session's ownership is never ambiguous
// (#66003). Single-profile users see the unchanged header.
const showProfileTag = profiles.length > 1 && Boolean(activeStoredSession)
// Pins live on the durable lineage-root id, but selectedSessionId is the live
// (tip) id — resolve through the loaded row so the menu reflects the pin
// state after auto-compression rotates the id.
@@ -126,12 +133,13 @@ function ChatHeader({
return (
<header className={cn(titlebarHeaderBaseClass, isRoutedSessionView && titlebarHeaderShadowClass)}>
<div
className={titlebarHeaderTitleClass}
className={cn(titlebarHeaderTitleClass, showProfileTag && 'flex items-center')}
style={{
maxWidth:
'calc(100vw - var(--titlebar-content-inset,0px) - var(--titlebar-tools-right) - var(--titlebar-tools-width) - 1.5rem)'
}}
>
{showProfileTag && <ProfileTag className="pointer-events-auto mr-1.5" profile={activeStoredSession?.profile} />}
<SessionActionsMenu
align="start"
onDelete={selectedSessionId ? onDeleteSelectedSession : undefined}
@@ -0,0 +1,49 @@
import { cleanup, render, screen } from '@testing-library/react'
import { atom } from 'nanostores'
import { afterEach, describe, expect, it, vi } from 'vitest'
// Keep store/profile's side-effecting imports inert (gateway socket layer +
// REST client) — same seam as store/profile.test.ts.
vi.mock('@/store/gateway', () => ({
$gateway: atom<unknown>(null),
ensureGatewayForProfile: vi.fn(async () => undefined)
}))
vi.mock('@/hermes', () => ({
getProfiles: vi.fn(async () => ({ profiles: [] })),
setApiRequestProfile: vi.fn()
}))
vi.mock('@/lib/query-client', () => ({ queryClient: { invalidateQueries: vi.fn() } }))
vi.mock('@/store/starmap', () => ({ resetStarmapGraph: vi.fn() }))
const { ProfileTag } = await import('./profile-tag')
const { setProfileColor } = await import('@/store/profile')
afterEach(cleanup)
describe('ProfileTag', () => {
it('shows the profile initial with an accessible owner label', () => {
render(<ProfileTag profile="xavier" />)
const tag = screen.getByRole('img', { name: 'Profile: xavier' })
expect(tag.textContent).toBe('x')
})
it('normalizes an empty profile to default and stays neutral', () => {
render(<ProfileTag profile="" />)
const tag = screen.getByRole('img', { name: 'Profile: default' })
expect(tag.textContent).toBe('d')
// Default/root profile carries no identity color.
expect(tag.style.color).toBe('')
})
it('uses the profile identity color (user override wins)', () => {
setProfileColor('xavier', 'hsl(120 68% 58%)')
render(<ProfileTag profile="xavier" />)
const tag = screen.getByRole('img', { name: 'Profile: xavier' })
// jsdom normalizes hsl() to rgb(); assert the override landed, not the format.
expect(tag.style.color).toBe('rgb(75, 221, 75)')
})
})
+36
View File
@@ -0,0 +1,36 @@
import { useStore } from '@nanostores/react'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { profileColorSoft, resolveProfileColor } from '@/lib/profile-color'
import { cn } from '@/lib/utils'
import { $profileColors, normalizeProfileKey } from '@/store/profile'
/** Owning-profile chip: soft profile-tint square with the initial, tooltip +
* accessible label carrying the full name. Same visual language as the
* profile rail; the default profile stays neutral. Identity, not status
* session state dots keep their own semantics (#66003). */
export function ProfileTag({ className, profile }: { className?: string; profile: null | string | undefined }) {
const { t } = useI18n()
const colors = useStore($profileColors)
const key = normalizeProfileKey(profile)
const color = resolveProfileColor(key, colors)
const hue = color ?? 'var(--ui-text-quaternary)'
const label = t.sidebar.row.ownedByProfile(key)
return (
<Tip label={label}>
<span
aria-label={label}
className={cn(
'grid size-4 shrink-0 place-items-center rounded-[3px] text-[0.5rem] font-semibold uppercase leading-none',
className
)}
role="img"
style={{ backgroundColor: profileColorSoft(hue, 22), color: color ?? undefined }}
>
{key.replace(/[^a-z0-9]/gi, '').charAt(0) || '?'}
</span>
</Tip>
)
}
+3 -2
View File
@@ -93,11 +93,10 @@ import {
$sessions,
$sessionsLoading,
$sessionsTotal,
$workingSessionIds,
sessionPinId,
setCurrentCwd
} from '@/store/session'
import { $focusedStoredSessionId, type SplitDir } from '@/store/session-states'
import { $focusedStoredSessionId, $workingSessionIds, type SplitDir } from '@/store/session-states'
import {
type AppView,
@@ -1232,6 +1231,7 @@ export function ChatSidebar({
pinned={false}
rootClassName="min-h-32 flex-1 overflow-hidden p-0"
sessions={searchResults}
showProfileTags={showAllProfiles}
workingSessionIdSet={workingSessionIdSet}
/>
)}
@@ -1254,6 +1254,7 @@ export function ChatSidebar({
pinned
rootClassName="shrink-0 p-0 pb-1"
sessions={pinnedSessions}
showProfileTags={showAllProfiles}
sortable={pinnedSessions.length > 1}
workingSessionIdSet={workingSessionIdSet}
/>
@@ -66,6 +66,8 @@ import { DeleteProfileDialog } from '../../profiles/delete-profile-dialog'
import { RenameProfileDialog } from '../../profiles/rename-profile-dialog'
import { PROFILES_ROUTE } from '../../routes'
import { useProfilePrewarm } from './use-profile-prewarm'
const RAIL_GAP = 4 // px — matches gap-1 between squares.
// Past this many profiles the strip of colored squares stops scaling (tiny
@@ -457,30 +459,40 @@ function ProfileDropdown({
<SelectValue placeholder={p.title} />
</SelectTrigger>
<SelectContent collisionPadding={{ bottom: 44, left: 8, right: 8, top: 8 }} side="top">
{profiles.map(profile => {
const color = resolveProfileColor(profile.name, colors)
const hue = color ?? 'var(--ui-text-quaternary)'
return (
<SelectItem key={profile.name} value={profile.name}>
<span className="flex min-w-0 items-center gap-1.5">
<span
aria-hidden="true"
className="grid size-4 shrink-0 place-items-center rounded-[3px] text-[0.5rem] font-semibold uppercase leading-none"
style={{ backgroundColor: profileColorSoft(hue, 22), color: color ?? undefined }}
>
{profile.name.replace(/[^a-z0-9]/gi, '').charAt(0) || '?'}
</span>
<span className="truncate">{profile.name}</span>
</span>
</SelectItem>
)
})}
{profiles.map(profile => (
<ProfileDropdownItem
color={resolveProfileColor(profile.name, colors)}
key={profile.name}
name={profile.name}
/>
))}
</SelectContent>
</Select>
)
}
// One dropdown row per profile — its own component so each row can own a
// hover-intent prewarm timer (see useProfilePrewarm).
function ProfileDropdownItem({ color, name }: { color: null | string; name: string }) {
const hue = color ?? 'var(--ui-text-quaternary)'
const { cancelPrewarm, startPrewarm } = useProfilePrewarm(name)
return (
<SelectItem onPointerEnter={startPrewarm} onPointerLeave={cancelPrewarm} value={name}>
<span className="flex min-w-0 items-center gap-1.5">
<span
aria-hidden="true"
className="grid size-4 shrink-0 place-items-center rounded-[3px] text-[0.5rem] font-semibold uppercase leading-none"
style={{ backgroundColor: profileColorSoft(hue, 22), color: color ?? undefined }}
>
{name.replace(/[^a-z0-9]/gi, '').charAt(0) || '?'}
</span>
<span className="truncate">{name}</span>
</span>
</SelectItem>
)
}
interface ProfilePillProps {
active: boolean
// home / All / Manage are glyph action buttons (navigation, not identity).
@@ -548,6 +560,9 @@ function ProfileSquare({
const [pickerOpen, setPickerOpen] = useState(false)
const pressTimer = useRef<null | number>(null)
const suppressClick = useRef(false)
// Hovering a square telegraphs the switch — start that profile's backend
// spawn now so a cold click doesn't pay the full boot.
const { cancelPrewarm, startPrewarm } = useProfilePrewarm(label)
const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({
id: label,
@@ -637,7 +652,11 @@ function ProfileSquare({
setPickerOpen(true)
}, LONG_PRESS_MS)
}}
onPointerLeave={clearPress}
onPointerEnter={startPrewarm}
onPointerLeave={() => {
clearPress()
cancelPrewarm()
}}
onPointerUp={clearPress}
>
{label.replace(/[^a-z0-9]/gi, '').charAt(0) || '?'}
@@ -1,6 +1,7 @@
import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { ProfileTag } from '@/app/chat/profile-tag'
import { startSessionDrag } from '@/app/chat/session-drag'
import { PlatformAvatar } from '@/app/messaging/platform-icon'
import { Button } from '@/components/ui/button'
@@ -14,12 +15,13 @@ import { handoffOriginSource, sessionSourceLabel } from '@/lib/session-source'
import { coarseElapsed } from '@/lib/time'
import { cn } from '@/lib/utils'
import { $backgroundRunningSessionIds } from '@/store/composer-status'
import { $attentionSessionIds, $unreadFinishedSessionIds } from '@/store/session'
import { openSessionTile } from '@/store/session-states'
import { $unreadFinishedSessionIds } from '@/store/session'
import { $attentionSessionIds, openSessionTile } from '@/store/session-states'
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
import { SidebarRowBody, SidebarRowGrab, SidebarRowLabel, SidebarRowLead, SidebarRowShell } from './chrome'
import { SessionActionsMenu, SessionContextMenu } from './session-actions-menu'
import { useProfilePrewarm } from './use-profile-prewarm'
interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
session: SessionInfo
@@ -36,6 +38,10 @@ interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
reorderable?: boolean
dragging?: boolean
dragHandleProps?: React.HTMLAttributes<HTMLElement>
/** Tag the row with its owning profile (initial chip + tooltip). Used by
* flat cross-profile lists Pinned and search results in the All-profiles
* view where no group header communicates ownership (#66003). */
showProfile?: boolean
}
const AGE_KEY = { day: 'ageDay', hour: 'ageHour', minute: 'ageMin' } as const
@@ -61,6 +67,7 @@ export function SidebarSessionRow({
reorderable = false,
dragging = false,
dragHandleProps,
showProfile = false,
className,
style,
ref,
@@ -68,6 +75,7 @@ export function SidebarSessionRow({
}: SidebarSessionRowProps) {
const { t } = useI18n()
const r = t.sidebar.row
const { cancelPrewarm, startPrewarm } = useProfilePrewarm(session.profile)
const title = sessionTitle(session)
const age = formatAge(session.last_active || session.started_at, r)
const handleLabel = `Reorder ${title}`
@@ -161,6 +169,12 @@ export function SidebarSessionRow({
startSessionDrag({ id: session.id, profile: session.profile || 'default', title }, event)
}}
// Hovering a row from another profile (the all-profiles view) telegraphs
// a cross-profile resume — start that backend's spawn now so the click
// doesn't pay the full cold boot. Same-profile rows no-op inside
// prewarmProfileBackend.
onPointerEnter={startPrewarm}
onPointerLeave={cancelPrewarm}
ref={ref}
style={style}
{...rest}
@@ -245,6 +259,7 @@ export function SidebarSessionRow({
<SidebarRowLabel className="flex-1 font-normal group-hover:text-foreground group-data-[working=true]:text-foreground/90">
{title}
</SidebarRowLabel>
{showProfile && <ProfileTag profile={session.profile} />}
</SidebarRowBody>
</SidebarRowShell>
</SessionContextMenu>
@@ -135,6 +135,10 @@ interface SidebarSessionsSectionProps {
// Rendered atop the entered-project body (a "back to overview" row).
projectBackRow?: React.ReactNode
dndSensors?: ReturnType<typeof useSensors>
// Tag every row with its owning profile. Set on the flat cross-profile
// lists (Pinned / search results) in the All-profiles view, where no group
// header communicates ownership (#66003).
showProfileTags?: boolean
}
export function SidebarSessionsSection({
@@ -174,7 +178,8 @@ export function SidebarSessionsSection({
onReorderSessions,
onReorderProjects,
projectBackRow,
dndSensors
dndSensors,
showProfileTags = false
}: SidebarSessionsSectionProps) {
const sectionOpen = collapsible ? open : true
const hasGroupedSessions = Boolean(groups?.some(group => group.sessions.length > 0))
@@ -203,7 +208,8 @@ export function SidebarSessionsSection({
onPin: () => onTogglePin(sessionPinId(session)),
onResume: () => onResumeSession(session.id),
reorderable: draggable && !branchStem,
session
session,
showProfile: showProfileTags
}
return draggable && !branchStem ? (
@@ -311,6 +317,7 @@ export function SidebarSessionsSection({
onResumeSession={onResumeSession}
onTogglePin={onTogglePin}
pinned={pinned}
showProfileTags={showProfileTags}
sortable={sessionsDraggable}
workingSessionIdSet={workingSessionIdSet}
/>
@@ -0,0 +1,38 @@
import { useCallback, useEffect, useRef } from 'react'
import { prewarmProfileBackend } from '@/store/profile'
// Dwell before firing: long enough that sweeping the pointer across the rail
// or a mixed-profile session list doesn't spawn a backend for every element
// passed through, short enough to beat the click by hundreds of ms.
const PREWARM_DWELL_MS = 120
/**
* pointerenter/pointerleave handlers that pre-warm `profile`'s pool backend
* after a short hover dwell (see prewarmProfileBackend in store/profile).
* Consumers merge these with their own pointer handlers.
*/
export function useProfilePrewarm(profile: string | null | undefined) {
const timer = useRef<null | number>(null)
const profileRef = useRef(profile)
profileRef.current = profile
const cancelPrewarm = useCallback(() => {
if (timer.current != null) {
clearTimeout(timer.current)
timer.current = null
}
}, [])
useEffect(() => cancelPrewarm, [cancelPrewarm])
const startPrewarm = useCallback(() => {
cancelPrewarm()
timer.current = window.setTimeout(() => {
timer.current = null
prewarmProfileBackend(profileRef.current || 'default')
}, PREWARM_DWELL_MS)
}, [cancelPrewarm])
return { cancelPrewarm, startPrewarm }
}
@@ -21,6 +21,7 @@ interface SessionRowCommonProps {
onPin: () => void
onResume: () => void
reorderable?: boolean
showProfile?: boolean
}
interface VirtualSessionListProps {
@@ -33,6 +34,7 @@ interface VirtualSessionListProps {
onResumeSession: (sessionId: string) => void
onTogglePin: (sessionId: string) => void
pinned: boolean
showProfileTags?: boolean
sortable: boolean
workingSessionIdSet: Set<string>
}
@@ -50,6 +52,7 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
onResumeSession,
onTogglePin,
pinned,
showProfileTags = false,
sortable,
workingSessionIdSet
}) => {
@@ -90,7 +93,8 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
onDelete: () => onDeleteSession(session.id),
onPin: () => onTogglePin(sessionPinId(session)),
onResume: () => onResumeSession(session.id),
reorderable
reorderable,
showProfile: showProfileTags
}
return reorderable ? (
@@ -3,7 +3,8 @@ import { useEffect, useRef } from 'react'
import { setPetActivity } from '@/store/pet'
import { setPetScale } from '@/store/pet-gallery'
import { setPetOverlayOpenAppHandler, setPetOverlayScaleHandler, setPetOverlaySubmitHandler } from '@/store/pet-overlay'
import { $attentionSessionIds, $sessions } from '@/store/session'
import { $sessions } from '@/store/session'
import { $attentionSessionIds } from '@/store/session-states'
import { isSecondaryWindow } from '@/store/windows'
import type { GatewayRequester } from '../types'
@@ -28,18 +28,16 @@ import { notify, notifyError } from '@/store/notifications'
import { $activeGatewayProfile, normalizeProfileKey, touchActiveGatewayBackend } from '@/store/profile'
import {
$activeSessionId,
$attentionSessionIds,
$connection,
$currentCwd,
$sessions,
$workingSessionIds,
ensureDefaultWorkspaceCwd,
setConnection,
setCurrentBranch,
setCurrentCwd,
setSessionsLoading
} from '@/store/session'
import { resetTileRuntimeBindings } from '@/store/session-states'
import { $attentionSessionIds, $workingSessionIds, resetTileRuntimeBindings } from '@/store/session-states'
import type { RpcEvent } from '@/types/hermes'
// After this many consecutive failed reconnects (≈45s with the 1→15s backoff)
@@ -286,10 +284,14 @@ export function useGatewayBoot({
return
}
// Same shape as boot(): profile first (session scope depends on it),
// then the independent fetches concurrently.
await adoptPrimaryProfile()
await seedDefaultCwd()
await callbacksRef.current.refreshHermesConfig().catch(() => undefined)
await callbacksRef.current.refreshSessions().catch(() => undefined)
await Promise.all([
seedDefaultCwd(),
callbacksRef.current.refreshHermesConfig().catch(() => undefined),
callbacksRef.current.refreshSessions().catch(() => undefined)
])
completeDesktopBoot()
bootCompleted = true
} catch (err) {
@@ -462,6 +464,11 @@ export function useGatewayBoot({
return
}
// Profile adoption must land first: refreshSessions scopes its fetch by
// $profileScope ← $activeGatewayProfile. The remaining three fetches
// (cwd seed, config, sessions) are independent REST calls — running
// them serially added their sum to time-to-populated-sidebar when only
// the max is needed.
await adoptPrimaryProfile()
setDesktopBootStep({
@@ -469,20 +476,17 @@ export function useGatewayBoot({
message: translateNow('boot.steps.loadingSettings'),
progress: 97
})
await seedDefaultCwd()
await callbacksRef.current.refreshHermesConfig()
await Promise.all([
seedDefaultCwd(),
callbacksRef.current.refreshHermesConfig(),
callbacksRef.current.refreshSessions()
])
if (cancelled) {
return
}
setDesktopBootStep({
phase: 'renderer.sessions',
message: translateNow('boot.steps.loadingSessions'),
progress: 99
})
await callbacksRef.current.refreshSessions()
completeDesktopBoot()
bootCompleted = true
} catch (err) {
+2 -1
View File
@@ -5,7 +5,8 @@ import { useNavigate } from 'react-router-dom'
import { sessionTitle } from '@/lib/chat-runtime'
import { cn } from '@/lib/utils'
import { $attentionSessionIds, $unreadFinishedSessionIds, $workingSessionIds } from '@/store/session'
import { $unreadFinishedSessionIds } from '@/store/session'
import { $attentionSessionIds, $workingSessionIds } from '@/store/session-states'
import { $switcherIndex, $switcherOpen, $switcherSessions, closeSwitcher } from '@/store/session-switcher'
import { HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from './floating-hud'
@@ -2,8 +2,9 @@ import { act, cleanup, render, waitFor } from '@testing-library/react'
import type { MutableRefObject } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createClientSessionState } from '@/lib/chat-runtime'
import { $queuedPromptsBySession, enqueueQueuedPrompt, getQueuedPrompts } from '@/store/composer-queue'
import { $workingSessionIds } from '@/store/session'
import { clearAllSessionStates, publishSessionState } from '@/store/session-states'
import { useBackgroundQueueDrain } from './use-background-queue-drain'
import type { SubmitTextOptions } from './use-prompt-actions/utils'
@@ -32,6 +33,7 @@ function Harness({
describe('useBackgroundQueueDrain', () => {
beforeEach(() => {
vi.useRealTimers()
clearAllSessionStates()
})
afterEach(() => {
@@ -39,7 +41,7 @@ describe('useBackgroundQueueDrain', () => {
vi.restoreAllMocks()
vi.useRealTimers()
$queuedPromptsBySession.set({})
$workingSessionIds.set([])
clearAllSessionStates()
})
it('drains an idle queued prompt for a non-selected background session', async () => {
@@ -47,7 +49,7 @@ describe('useBackgroundQueueDrain', () => {
const submitText = vi.fn(async () => true)
enqueueQueuedPrompt('stored-session-a', { text: 'continue in the background', attachments: [] })
$workingSessionIds.set([])
clearAllSessionStates()
render(<Harness runtimeMap={runtimeMap} submitText={submitText} />)
@@ -68,7 +70,7 @@ describe('useBackgroundQueueDrain', () => {
const submitText = vi.fn(async () => true)
enqueueQueuedPrompt('stored-session-a', { text: 'visible queue entry', attachments: [] })
$workingSessionIds.set([])
clearAllSessionStates()
render(<Harness runtimeMap={runtimeMap} selectedStoredSessionId="stored-session-a" submitText={submitText} />)
@@ -83,7 +85,8 @@ describe('useBackgroundQueueDrain', () => {
const submitText = vi.fn(async () => true)
enqueueQueuedPrompt('stored-session-a', { text: 'wait for current turn', attachments: [] })
$workingSessionIds.set(['stored-session-a'])
// Mark the session as working (busy) so the drain should skip it
publishSessionState('rt-session-a', { ...createClientSessionState('stored-session-a'), busy: true })
render(<Harness runtimeMap={runtimeMap} submitText={submitText} />)
@@ -12,7 +12,7 @@ import {
shouldAutoDrain
} from '@/store/composer-queue'
import { notify } from '@/store/notifications'
import { $workingSessionIds } from '@/store/session'
import { $workingSessionIds } from '@/store/session-states'
import type { SubmitTextOptions } from './use-prompt-actions/utils'
@@ -1,5 +1,5 @@
import type { QueryClient } from '@tanstack/react-query'
import { type MutableRefObject, useCallback, useRef } from 'react'
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
import { writeAgentTerminalChunk } from '@/app/right-sidebar/terminal/agent-terminal-stream'
import { readActiveTerminal } from '@/app/right-sidebar/terminal/buffer'
@@ -26,6 +26,8 @@ import { followActiveSessionCwd } from '@/store/projects'
import { clearAllPrompts, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts'
import {
$currentCwd,
$currentModel,
$currentProvider,
sessionMatchesStoredId,
setCurrentBranch,
setCurrentCwd,
@@ -77,6 +79,7 @@ interface GatewayEventDeps {
queryClient: QueryClient
refreshHermesConfig: () => Promise<void>
sessionInterrupted: (sessionId: string) => boolean
sessionStateByRuntimeIdRef: MutableRefObject<Map<string, ClientSessionState>>
updateSessionState: (
sessionId: string,
updater: (state: ClientSessionState) => ClientSessionState,
@@ -105,12 +108,48 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
queryClient,
refreshHermesConfig,
sessionInterrupted,
sessionStateByRuntimeIdRef,
updateSessionState,
upsertToolCall
} = deps
const unscopedStreamSessionIdRef = useRef<string | null>(null)
// session.info arrives in bursts (agent build ready + turn end + title /
// MCP / compress edges within the same second). Each used to fire its own
// refreshHermesConfig — two REST calls (config + defaults) per event, per
// turn, including for BACKGROUND sessions whose values the fetch can't even
// apply. Coalesce to one trailing fetch per burst; the caller gates on
// `apply` so background traffic doesn't schedule anything.
const configRefreshTimerRef = useRef<null | number>(null)
const scheduleConfigRefresh = useCallback(() => {
if (configRefreshTimerRef.current !== null) {
return
}
if (typeof window === 'undefined') {
void refreshHermesConfig()
return
}
configRefreshTimerRef.current = window.setTimeout(() => {
configRefreshTimerRef.current = null
void refreshHermesConfig()
}, 300)
}, [refreshHermesConfig])
useEffect(
() => () => {
if (configRefreshTimerRef.current !== null && typeof window !== 'undefined') {
window.clearTimeout(configRefreshTimerRef.current)
configRefreshTimerRef.current = null
}
},
[]
)
return useCallback(
(event: RpcEvent) => {
const payload = event.payload as GatewayEventPayload | undefined
@@ -151,6 +190,19 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
const modelChanged = typeof payload?.model === 'string'
const providerChanged = typeof payload?.provider === 'string'
const runningChanged = typeof payload?.running === 'boolean'
// The backend stamps model/provider (as strings) on EVERY session.info,
// so the presence flags above are true on every heartbeat/turn edge —
// fine for the cheap atom writes below (nanostores skips identical
// values), but they also drove queryClient.invalidateQueries, refetching
// the model-options provider catalog once or twice per turn for a model
// that never changed. Only a genuine VALUE change (vs the session's own
// cached runtime state, captured before the state patch below applies;
// composer atoms as the fallback for an uncached session) invalidates.
const knownState = sessionId ? sessionStateByRuntimeIdRef.current.get(sessionId) : undefined
const modelValueChanged = modelChanged && payload!.model !== (knownState?.model ?? $currentModel.get())
const providerValueChanged =
providerChanged && payload!.provider !== (knownState?.provider ?? $currentProvider.get())
// Config is profile-scoped, but session.info also arrives for background
// sessions. Only an active-session event from the currently active
@@ -249,6 +301,15 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
}
if (busy) {
// Don't re-arm busy from a stale session.info if the user
// just clicked Stop (interrupted=true). The backend's
// cooperative interrupt may not have propagated yet, so
// running is still true in the heartbeat. The turn's
// finally block will emit running=false to clear busy.
if (state.interrupted) {
return state
}
return {
...state,
busy,
@@ -283,11 +344,14 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
if (apply) {
reportInstallMethodWarning(payload?.install_warning)
// Config refetch is only meaningful for the foreground context —
// everything refreshHermesConfig applies is either active-session
// guarded or a composer/global pref. Background sessions' heartbeats
// used to trigger it too (two REST calls each, every turn).
scheduleConfigRefresh()
}
void refreshHermesConfig()
if (modelChanged || providerChanged) {
if (modelValueChanged || providerValueChanged) {
void queryClient.invalidateQueries({
queryKey: explicitSid && sessionId ? ['model-options', sessionId] : ['model-options']
})
@@ -307,14 +371,27 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
triggerHaptic('streamStart')
}
updateSessionState(sessionId, state => ({
...state,
busy: true,
awaitingResponse: true,
sawAssistantPayload: false,
interrupted: false,
turnStartedAt: Date.now()
}))
updateSessionState(sessionId, state => {
// If the user clicked Stop (cancelRun set interrupted=true), don't
// let a stale message.start from a chained turn (goal follow-up,
// completion drain) or an in-flight LLM response re-arm busy.
// The interrupt is user intent — the backend's cooperative cancel
// may not have propagated yet, so its events are stale. The turn's
// finally block will emit session.info with running=false to clear
// busy for real once the agent loop actually exits.
if (state.interrupted) {
return state
}
return {
...state,
busy: true,
awaitingResponse: true,
sawAssistantPayload: false,
interrupted: false,
turnStartedAt: Date.now()
}
})
if (isActiveEvent) {
setTurnStartedAt(Date.now())
@@ -733,8 +810,9 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
lastCwdInfoSessionRef,
nativeSubagentSessionsRef,
queryClient,
refreshHermesConfig,
scheduleConfigRefresh,
sessionInterrupted,
sessionStateByRuntimeIdRef,
updateSessionState,
upsertToolCall
]
@@ -131,6 +131,46 @@ export function useMessageStream({
[updateSessionState]
)
// Turn-complete triggers a full sidebar refresh (recents + cron + messaging
// REST fan-out, each scanning profile state.dbs server-side) plus a
// cross-window broadcast that makes every other window do the same. Parallel
// tiles / multi-window finishing near-simultaneously used to multiply that.
// Coalesce completions into one trailing refresh per burst — a ~300ms title
// lag is invisible; the redundant aggregator scans are not.
const sessionsRefreshTimerRef = useRef<null | number>(null)
const scheduleSessionsRefresh = useCallback(() => {
if (sessionsRefreshTimerRef.current !== null) {
return
}
const run = () => {
sessionsRefreshTimerRef.current = null
void refreshSessions().catch(() => undefined)
// Sync freshly-titled rows to other windows (e.g. main, when the turn
// ran in the pop-out).
broadcastSessionsChanged()
}
if (typeof window === 'undefined') {
run()
return
}
sessionsRefreshTimerRef.current = window.setTimeout(run, 300)
}, [refreshSessions])
useEffect(
() => () => {
if (sessionsRefreshTimerRef.current !== null && typeof window !== 'undefined') {
window.clearTimeout(sessionsRefreshTimerRef.current)
sessionsRefreshTimerRef.current = null
}
},
[]
)
const queuedDeltasRef = useRef<Map<string, QueuedStreamDeltas>>(new Map())
const flushHandleRef = useRef<number | null>(null)
const lastFlushAtRef = useRef<number>(0)
@@ -444,10 +484,7 @@ export function useMessageStream({
}
})
void refreshSessions().catch(() => undefined)
// Sync the freshly-titled row to other windows (e.g. main, when the turn
// ran in the pop-out).
broadcastSessionsChanged()
scheduleSessionsRefresh()
if (compactedTurnRef.current.delete(sessionId)) {
shouldHydrate = false
@@ -464,7 +501,7 @@ export function useMessageStream({
title: translateNow('notifications.native.turnDoneTitle')
})
},
[hydrateFromStoredSession, refreshSessions, updateSessionState]
[hydrateFromStoredSession, scheduleSessionsRefresh, updateSessionState]
)
const failAssistantMessage = useCallback(
@@ -526,6 +563,7 @@ export function useMessageStream({
queryClient,
refreshHermesConfig,
sessionInterrupted,
sessionStateByRuntimeIdRef,
updateSessionState,
upsertToolCall
})
@@ -0,0 +1,155 @@
import { QueryClient } from '@tanstack/react-query'
import { act, cleanup, render, waitFor } from '@testing-library/react'
import { useEffect, useRef } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ClientSessionState } from '@/app/types'
import { createClientSessionState } from '@/lib/chat-runtime'
import { setCurrentModel, setCurrentProvider } from '@/store/session'
import type { RpcEvent } from '@/types/hermes'
import { useMessageStream } from './index'
// Per-turn REST amplification guards: session.info must not refetch config for
// background sessions nor invalidate the model-options catalog when the model
// string is merely PRESENT (the backend stamps it on every event) rather than
// actually changed. message.complete must coalesce sidebar refreshes.
const ACTIVE_SID = 'session-active'
let handleEvent: ((event: RpcEvent) => void) | null = null
let refreshHermesConfig: ReturnType<typeof vi.fn<() => Promise<void>>>
let refreshSessions: ReturnType<typeof vi.fn<() => Promise<void>>>
let queryClient: QueryClient
function Harness() {
const activeSessionIdRef = useRef<string | null>(ACTIVE_SID)
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
const stream = useMessageStream({
activeSessionIdRef,
hydrateFromStoredSession: vi.fn(async () => undefined),
queryClient,
refreshHermesConfig,
refreshSessions,
sessionStateByRuntimeIdRef,
updateSessionState: (sessionId, updater) => {
const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()
const next = updater(current)
sessionStateByRuntimeIdRef.current.set(sessionId, next)
return next
}
})
useEffect(() => {
handleEvent = stream.handleGatewayEvent
}, [stream.handleGatewayEvent])
return null
}
async function mountStream() {
render(<Harness />)
await waitFor(() => expect(handleEvent).not.toBeNull())
}
const sessionInfo = (sessionId: string, payload: Record<string, unknown>) =>
act(() => handleEvent!({ payload, session_id: sessionId, type: 'session.info' }))
beforeEach(() => {
handleEvent = null
refreshHermesConfig = vi.fn<() => Promise<void>>(async () => undefined)
refreshSessions = vi.fn<() => Promise<void>>(async () => undefined)
queryClient = new QueryClient()
setCurrentModel('')
setCurrentProvider('')
})
afterEach(() => {
cleanup()
setCurrentModel('')
setCurrentProvider('')
vi.useRealTimers()
vi.restoreAllMocks()
})
describe('session.info config refetch gating', () => {
it('coalesces active-session bursts into one trailing config fetch', async () => {
// Mount under real timers (waitFor), then freeze time for the debounce.
await mountStream()
vi.useFakeTimers()
sessionInfo(ACTIVE_SID, { model: 'm1', running: true })
sessionInfo(ACTIVE_SID, { model: 'm1', running: false })
sessionInfo(ACTIVE_SID, { model: 'm1', title: 't' })
expect(refreshHermesConfig).not.toHaveBeenCalled()
await act(async () => {
await vi.advanceTimersByTimeAsync(400)
})
expect(refreshHermesConfig).toHaveBeenCalledTimes(1)
})
it('never fetches config for a background session heartbeat', async () => {
await mountStream()
vi.useFakeTimers()
sessionInfo('session-background', { model: 'm1', running: true })
sessionInfo('session-background', { model: 'm1', running: false })
await act(async () => {
await vi.advanceTimersByTimeAsync(400)
})
expect(refreshHermesConfig).not.toHaveBeenCalled()
})
})
describe('session.info model-options invalidation gating', () => {
it('skips invalidation when model/provider merely restate the known values', async () => {
await mountStream()
const invalidate = vi.spyOn(queryClient, 'invalidateQueries')
// Seed the session's cached runtime state.
sessionInfo(ACTIVE_SID, { model: 'm1', provider: 'p1', running: true })
invalidate.mockClear()
// Turn-end heartbeat restating the same model/provider — the pre-fix path
// invalidated (and refetched the provider catalog) on every one of these.
sessionInfo(ACTIVE_SID, { model: 'm1', provider: 'p1', running: false })
expect(invalidate).not.toHaveBeenCalled()
})
it('invalidates when the session model actually changes', async () => {
await mountStream()
const invalidate = vi.spyOn(queryClient, 'invalidateQueries')
sessionInfo(ACTIVE_SID, { model: 'm1', provider: 'p1', running: true })
invalidate.mockClear()
sessionInfo(ACTIVE_SID, { model: 'm2', provider: 'p1', running: true })
expect(invalidate).toHaveBeenCalledWith({ queryKey: ['model-options', ACTIVE_SID] })
})
})
describe('message.complete sidebar refresh coalescing', () => {
it('collapses near-simultaneous completions into one refresh', async () => {
await mountStream()
vi.useFakeTimers()
act(() => handleEvent!({ payload: { text: 'a' }, session_id: 's1', type: 'message.complete' }))
act(() => handleEvent!({ payload: { text: 'b' }, session_id: 's2', type: 'message.complete' }))
expect(refreshSessions).not.toHaveBeenCalled()
await act(async () => {
await vi.advanceTimersByTimeAsync(400)
})
expect(refreshSessions).toHaveBeenCalledTimes(1)
})
})
@@ -207,6 +207,47 @@ describe('useModelControls', () => {
expect($currentModel.get()).toBe('openai/gpt-5.5')
})
it('reseeds a sticky manual pick that was removed from the catalog', async () => {
vi.mocked(getGlobalModelInfo).mockResolvedValue({ model: 'openai/gpt-5.5', provider: 'openai-codex' })
const queryClient = new QueryClient()
queryClient.setQueryData(['model-options', 'global'], {
providers: [{ models: ['openai/gpt-5.5'], name: 'OpenRouter', slug: 'openrouter' }]
})
// A manual pick whose model no longer exists on its provider.
setCurrentModel('openrouter/owl-alpha')
setCurrentProvider('openrouter')
setCurrentModelSource('manual')
const { result } = renderHook(() => useModelControls({ queryClient, requestGateway: vi.fn() }))
await result.current.refreshCurrentModel()
expect($currentModel.get()).toBe('openai/gpt-5.5')
expect(getCurrentModelSource()).toBe('default')
})
it('keeps a sticky manual pick that is still in the catalog', async () => {
vi.mocked(getGlobalModelInfo).mockResolvedValue({ model: 'openai/gpt-5.5', provider: 'openai-codex' })
const queryClient = new QueryClient()
queryClient.setQueryData(['model-options', 'global'], {
providers: [{ models: ['openrouter/glm-4.7', 'openai/gpt-5.5'], name: 'OpenRouter', slug: 'openrouter' }]
})
setCurrentModel('openrouter/glm-4.7')
setCurrentProvider('openrouter')
setCurrentModelSource('manual')
const { result } = renderHook(() => useModelControls({ queryClient, requestGateway: vi.fn() }))
await result.current.refreshCurrentModel()
expect($currentModel.get()).toBe('openrouter/glm-4.7')
expect(getCurrentModelSource()).toBe('manual')
})
it('refreshes legacy/default-derived composer state from the profile default', async () => {
setCurrentModel('openai/gpt-5.5')
setCurrentProvider('nous')
@@ -3,6 +3,7 @@ import { useCallback } from 'react'
import { getGlobalModelInfo } from '@/hermes'
import { useI18n } from '@/i18n'
import { manualPickRemoved } from '@/lib/model-options'
import { notifyError } from '@/store/notifications'
import {
$activeSessionId,
@@ -58,13 +59,28 @@ export function useModelControls({ queryClient, requestGateway }: ModelControlsO
return
}
if (!force && $currentModel.get() && getCurrentModelSource() === 'manual') {
// A manual pick stays sticky UNLESS it was removed from the catalog (its
// model no longer exists on the provider), in which case keeping it would
// 404 every new chat — fall through to reseed from the profile default.
// Reads the model-options cache the composer already populated; an
// unknown/not-yet-loaded catalog conservatively preserves the pick.
const keepManualPick = () => {
if (force || !$currentModel.get() || getCurrentModelSource() !== 'manual') {
return false
}
const options = queryClient.getQueryData<ModelOptionsResponse>(['model-options', 'global'])
return !manualPickRemoved(options?.providers, $currentProvider.get(), $currentModel.get())
}
if (keepManualPick()) {
return
}
const result = await getGlobalModelInfo()
if ($activeSessionId.get() || (!force && $currentModel.get() && getCurrentModelSource() === 'manual')) {
if ($activeSessionId.get() || keepManualPick()) {
return
}
@@ -82,7 +98,7 @@ export function useModelControls({ queryClient, requestGateway }: ModelControlsO
} catch {
// The delayed session.info event still updates this once the agent is ready.
}
}, [])
}, [queryClient])
// Returns whether the switch succeeded so callers can await it before applying
// follow-up changes. The composer model is plain UI state: with no live
@@ -514,7 +514,16 @@ export function usePromptActions({
)
const cancelRun = useCallback(async () => {
const sessionId = activeSessionId || activeSessionIdRef.current
// Read from the ref, not the closure-captured `activeSessionId`. The
// actions bag is a stable ref mutated in place (Object.assign on each
// ContribWiring render), and ChatRoutesSurface is memoized on that stable
// ref — so it does NOT re-render when activeSessionId changes, which means
// the ChatView element's onCancel prop holds a stale cancelRun closure.
// The closure's `activeSessionId` can be a previous session's id (or null
// from a new-chat draft), sending session.interrupt to the wrong session.
// The ref is updated via useEffect on every activeSessionId change, so it
// always reflects the current session — same pattern submitText uses.
const sessionId = activeSessionIdRef.current
const releaseBusy = () => {
setMutableRef(busyRef, false)
@@ -588,15 +597,7 @@ export function usePromptActions({
releaseBusy()
notifyError(stopError, copy.stopFailed)
}
}, [
activeSessionId,
activeSessionIdRef,
busyRef,
copy.stopFailed,
requestGateway,
selectedStoredSessionIdRef,
updateSessionState
])
}, [activeSessionIdRef, busyRef, copy.stopFailed, requestGateway, selectedStoredSessionIdRef, updateSessionState])
// Steer = nudge the live turn without interrupting: the gateway appends the
// text to the next tool result so the model reads it on its next iteration
@@ -212,14 +212,18 @@ describe('createBackendSessionForSend profile routing', () => {
// (b) arm $resumeFailedSessionId so use-route-resume can retry. A resume that
// succeeds must NOT leave the flag armed.
function ResumeHarness({
onStateUpdate,
onReady,
requestGateway,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionId = null,
sessionStateByRuntimeIdRef
}: {
onStateUpdate?: (sessionId: string, state: ClientSessionState) => void
onReady: (resume: (storedSessionId: string, replaceRoute?: boolean) => Promise<unknown>) => void
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
runtimeIdByStoredSessionIdRef?: MutableRefObject<Map<string, string>>
selectedStoredSessionId?: string | null
sessionStateByRuntimeIdRef?: MutableRefObject<Map<string, ClientSessionState>>
}) {
const ref = <T,>(value: T): MutableRefObject<T> => ({ current: value })
@@ -235,11 +239,16 @@ function ResumeHarness({
requestGateway,
resetViewSync: vi.fn(),
runtimeIdByStoredSessionIdRef: runtimeIdByStoredSessionIdRef ?? ref(new Map<string, string>()),
selectedStoredSessionId: null,
selectedStoredSessionIdRef: ref<string | null>(null),
selectedStoredSessionId,
selectedStoredSessionIdRef: ref<string | null>(selectedStoredSessionId),
sessionStateByRuntimeIdRef: sessionStateByRuntimeIdRef ?? ref(new Map<string, ClientSessionState>()),
syncSessionStateToView: vi.fn(),
updateSessionState: (_sessionId, updater) => updater({} as ClientSessionState)
updateSessionState: (sessionId, updater) => {
const next = updater({} as ClientSessionState)
onStateUpdate?.(sessionId, next)
return next
}
})
useEffect(() => {
@@ -321,6 +330,155 @@ describe('resumeSession failure recovery', () => {
expect($messages.get().length).toBeGreaterThan(0)
})
it('preserves an optimistic user message during a same-session reconnect', async () => {
setMessages([
{
id: 'stored-user',
role: 'user',
parts: [{ type: 'text', text: 'earlier question' }]
},
{
id: 'stored-assistant',
role: 'assistant',
parts: [{ type: 'text', text: 'earlier answer' }]
},
{
id: 'user-optimistic',
role: 'user',
parts: [{ type: 'text', text: 'message sent during reconnect' }]
}
])
const storedMessages = [
{ content: 'earlier question', role: 'user', timestamp: 1 },
{ content: 'earlier answer', role: 'assistant', timestamp: 2 }
]
vi.mocked(getSessionMessages).mockResolvedValue({ messages: storedMessages, session_id: 'stored-1' } as never)
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.resume') {
return {
session_id: 'runtime-1',
session_key: 'stored-1',
resumed: 'stored-1',
message_count: 2,
messages: storedMessages,
info: {}
} as never
}
return {} as never
})
let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise<unknown>) | null = null
render(
<ResumeHarness onReady={r => (resume = r)} requestGateway={requestGateway} selectedStoredSessionId="stored-1" />
)
await waitFor(() => expect(resume).not.toBeNull())
await resume!('stored-1', true)
expect($messages.get().map(message => message.id)).toContain('user-optimistic')
})
it('restores the in-flight turn and queued user prompt after a full renderer restart', async () => {
const storedMessages = [
{ content: 'earlier question', role: 'user', timestamp: 1 },
{ content: 'earlier answer', role: 'assistant', timestamp: 2 }
]
vi.mocked(getSessionMessages).mockResolvedValue({ messages: storedMessages, session_id: 'stored-1' } as never)
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.resume') {
return {
session_id: 'runtime-1',
session_key: 'stored-1',
resumed: 'stored-1',
message_count: storedMessages.length,
messages: storedMessages,
running: true,
inflight: {
user: 'current prompt',
assistant: 'partial answer',
streaming: true
},
queued: { user: 'newest prompt' },
info: {}
} as never
}
return {} as never
})
let resumedState: ClientSessionState | undefined
let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise<unknown>) | null = null
render(
<ResumeHarness
onReady={ready => (resume = ready)}
onStateUpdate={(_sessionId, state) => (resumedState = state)}
requestGateway={requestGateway}
/>
)
await waitFor(() => expect(resume).not.toBeNull())
await resume!('stored-1', true)
const renderedMessages = JSON.stringify(resumedState?.messages)
expect(renderedMessages).toContain('current prompt')
expect(renderedMessages).toContain('partial answer')
expect(renderedMessages).toContain('newest prompt')
})
it('uses the continuation projection when resume rotates an equal-length stored transcript', async () => {
const parentMessages = [
{ content: 'question before compression', role: 'user', timestamp: 1 },
{ content: 'answer before compression', role: 'assistant', timestamp: 2 }
]
const continuationMessages = [
{ content: 'prompt after compression', role: 'user', timestamp: 3 },
{ content: 'answer after compression', role: 'assistant', timestamp: 4 }
]
vi.mocked(getSessionMessages).mockResolvedValue({
messages: parentMessages,
session_id: 'stored-1'
} as never)
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.resume') {
return {
session_id: 'runtime-continuation',
session_key: 'stored-continuation',
resumed: 'stored-continuation',
message_count: continuationMessages.length,
messages: continuationMessages,
info: {}
} as never
}
return {} as never
})
let resumedState: ClientSessionState | undefined
let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise<unknown>) | null = null
render(
<ResumeHarness
onReady={ready => (resume = ready)}
onStateUpdate={(_sessionId, state) => (resumedState = state)}
requestGateway={requestGateway}
/>
)
await waitFor(() => expect(resume).not.toBeNull())
await resume!('stored-1', true)
const renderedMessages = JSON.stringify(resumedState?.messages)
expect(renderedMessages).toContain('prompt after compression')
expect(renderedMessages).toContain('answer after compression')
expect(renderedMessages).not.toContain('answer before compression')
})
it('does NOT throw out of the fallback when REST also fails (no unhandled rejection)', async () => {
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.resume') {
@@ -601,9 +759,10 @@ describe('resumeSession warm-cache mapping integrity', () => {
expect(sessionStateByRuntimeIdRef.current.has('rt-recycled')).toBe(false)
})
it('honours a warm cache entry whose stored id matches (no needless refetch)', async () => {
it('honours a warm cache entry whose stored id matches and refreshes its persisted transcript', async () => {
// Correctly-wired mapping: 'rt-A' <-> 'stored-A'. The fast-path should trust
// it and never reach session.resume (only the lightweight usage probe).
// it and never reach session.resume. session.activate refreshes the live
// projection and, critically, rebinds its event transport after reconnect.
const runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>> = {
current: new Map([['stored-A', 'rt-A']])
}
@@ -613,8 +772,141 @@ describe('resumeSession warm-cache mapping integrity', () => {
}
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.usage') {
return { input: 0, output: 0, total: 0 } as never
if (method === 'session.activate') {
return {
session_id: 'rt-A',
session_key: 'stored-A',
resumed: 'stored-A',
message_count: 0,
messages: [],
running: false,
info: {}
} as never
}
return {} as never
})
vi.mocked(getSessionMessages).mockResolvedValue({ messages: [], session_id: 'stored-A' } as never)
let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise<unknown>) | null = null
render(
<ResumeHarness
onReady={r => (resume = r)}
requestGateway={requestGateway}
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
sessionStateByRuntimeIdRef={sessionStateByRuntimeIdRef}
/>
)
await waitFor(() => expect(resume).not.toBeNull())
await resume!('stored-A', true)
// Fast-path served the session from cache: no full resume RPC, mapping intact.
// The persisted transcript still refreshes in parallel because the runtime
// projection can differ even when its row count matches.
const methods = requestGateway.mock.calls.map(([method]) => method)
expect(methods).toContain('session.activate')
expect(methods).not.toContain('session.resume')
expect(getSessionMessages).toHaveBeenCalledWith('stored-A', undefined)
expect(runtimeIdByStoredSessionIdRef.current.get('stored-A')).toBe('rt-A')
})
it('repairs an idle warm cache from a divergent equal-length persisted transcript', async () => {
const runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>> = {
current: new Map([['stored-A', 'rt-A']])
}
const state = clientState('stored-A')
state.messages = [
{
id: 'cached-user',
role: 'user',
parts: [{ type: 'text', text: 'stale runtime prompt' }]
},
{
id: 'cached-assistant',
role: 'assistant',
parts: [{ type: 'text', text: 'stale runtime answer' }]
}
]
const sessionStateByRuntimeIdRef: MutableRefObject<Map<string, ClientSessionState>> = {
current: new Map([['rt-A', state]])
}
const staleRuntimeMessages = [
{ content: 'stale runtime prompt', role: 'user', timestamp: 1 },
{ content: 'stale runtime answer', role: 'assistant', timestamp: 2 }
]
const persistedMessages = [
{ content: 'prompt saved after compression', role: 'user', timestamp: 3 },
{ content: 'answer saved after compression', role: 'assistant', timestamp: 4 }
]
vi.mocked(getSessionMessages).mockResolvedValue({
messages: persistedMessages,
session_id: 'stored-A'
} as never)
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.activate') {
return {
session_id: 'rt-A',
session_key: 'stored-A',
resumed: 'stored-A',
message_count: staleRuntimeMessages.length,
messages: staleRuntimeMessages,
running: false,
info: {}
} as never
}
return {} as never
})
let resumedState: ClientSessionState | undefined
let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise<unknown>) | null = null
render(
<ResumeHarness
onReady={ready => (resume = ready)}
onStateUpdate={(_sessionId, next) => (resumedState = next)}
requestGateway={requestGateway}
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
sessionStateByRuntimeIdRef={sessionStateByRuntimeIdRef}
/>
)
await waitFor(() => expect(resume).not.toBeNull())
await resume!('stored-A', true)
const renderedMessages = JSON.stringify(resumedState?.messages)
expect(renderedMessages).toContain('prompt saved after compression')
expect(renderedMessages).toContain('answer saved after compression')
expect(renderedMessages).not.toContain('stale runtime answer')
})
it('keeps a warm runtime and optimistic turn on a transient activation timeout', async () => {
const runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>> = {
current: new Map([['stored-A', 'rt-A']])
}
const state = clientState('stored-A')
state.messages = [
{
id: 'user-optimistic',
role: 'user',
parts: [{ type: 'text', text: 'do not lose me' }]
}
]
const sessionStateByRuntimeIdRef: MutableRefObject<Map<string, ClientSessionState>> = {
current: new Map([['rt-A', state]])
}
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.activate') {
throw new Error('request timed out: session.activate')
}
return {} as never
@@ -632,10 +924,9 @@ describe('resumeSession warm-cache mapping integrity', () => {
await waitFor(() => expect(resume).not.toBeNull())
await resume!('stored-A', true)
// Fast-path served the session from cache: no full resume RPC, mapping intact.
const methods = requestGateway.mock.calls.map(([method]) => method)
expect(methods).not.toContain('session.resume')
expect(requestGateway.mock.calls.map(([method]) => method)).not.toContain('session.resume')
expect(runtimeIdByStoredSessionIdRef.current.get('stored-A')).toBe('rt-A')
expect(sessionStateByRuntimeIdRef.current.get('rt-A')?.messages[0]?.id).toBe('user-optimistic')
})
})
@@ -5,7 +5,8 @@ import type { NavigateFunction } from 'react-router-dom'
import { revealTreePane } from '@/components/pane-shell/tree/store'
import { deleteSession, getSessionMessages, setSessionArchived } from '@/hermes'
import { useI18n } from '@/i18n'
import { preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages'
import { type ChatMessage, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages'
import { isMissingRpcMethod } from '@/lib/gateway-rpc'
import { setSessionYolo } from '@/lib/yolo-session'
import { clearQueuedPrompts } from '@/store/composer-queue'
import { $pinnedSessionIds } from '@/store/layout'
@@ -62,12 +63,14 @@ import { NEW_CHAT_ROUTE, sessionRoute, SETTINGS_ROUTE } from '../../../routes'
import type { ClientSessionState, SidebarNavItem } from '../../../types'
import {
appendLiveSessionProjection,
applyRuntimeInfo,
applyStoredSessionPreviewRuntimeInfo,
type BranchMessage,
chatMessageArraysEquivalent,
isSessionGoneError,
patchSessionWorkspace,
preserveLocalPendingTurnMessages,
reconcileResumeMessages,
resolveStoredSession,
sessionMatchesStoredId,
@@ -117,6 +120,19 @@ function applyStoredUsage(stored: { input_tokens?: number | null; output_tokens?
setCurrentUsage(current => ({ ...current, input, output, total: input + output }))
}
function reconcileAuthoritativeMessages(
authoritativeMessages: SessionResumeResponse['messages'],
previousMessages: ChatMessage[],
liveProjection?: Pick<SessionResumeResponse, 'inflight' | 'queued' | 'session_id'>
): ChatMessage[] {
const authoritative = toChatMessages(authoritativeMessages)
const withLiveProjection = liveProjection ? appendLiveSessionProjection(authoritative, liveProjection) : authoritative
const reconciled = reconcileResumeMessages(withLiveProjection, previousMessages)
const withPendingTurn = preserveLocalPendingTurnMessages(reconciled, previousMessages)
return preserveLocalAssistantErrors(withPendingTurn, previousMessages)
}
// `session.create` params from the current profile + sticky-UI model/effort/fast,
// ensuring the gateway is on that profile first. Shared by the primary send path
// and the "open in split" tile path; `cwd` is the one thing that differs (the
@@ -431,6 +447,8 @@ export function useSessionActions({
async (storedSessionId: string, replaceRoute = false) => {
const requestId = resumeRequestRef.current + 1
resumeRequestRef.current = requestId
const resumedSameSelectedSession = selectedStoredSessionIdRef.current === storedSessionId
const resumeStartMessages = resumedSameSelectedSession ? $messages.get() : []
const isCurrentResume = () =>
resumeRequestRef.current === requestId && selectedStoredSessionIdRef.current === storedSessionId
@@ -462,7 +480,7 @@ export function useSessionActions({
// session being resumed. A pooled profile backend that gets idle-reaped
// and respawned (pruneSecondaryGateways) re-mints runtime ids, so a
// recycled id can resolve to a live-but-DIFFERENT session's cache entry.
// The session.usage 404 guard below only catches a fully-DEAD id — a
// The session.activate 404 guard below only catches a fully-DEAD id — a
// recycled-live id 200s, so an unchecked hit paints the wrong transcript
// under the current route (the "open chat A, chat B loads" bug). On a
// mismatch the mapping is cross-wired: purge both sides and report a miss
@@ -489,7 +507,10 @@ export function useSessionActions({
if (!takeWarmCache()) {
setActiveSessionId(null)
activeSessionIdRef.current = null
setMessages([])
if (!resumedSameSelectedSession) {
setMessages([])
}
}
// Swap the single live gateway to this session's profile before any
@@ -517,7 +538,7 @@ export function useSessionActions({
const stored =
$sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId)) ?? storedForProfile
const cachedViewState =
let cachedViewState =
!cachedState.model && stored?.model != null
? {
...cachedState,
@@ -525,6 +546,14 @@ export function useSessionActions({
}
: cachedState
if (resumedSameSelectedSession) {
const messages = preserveLocalPendingTurnMessages(cachedViewState.messages, resumeStartMessages)
if (messages !== cachedViewState.messages) {
cachedViewState = { ...cachedViewState, messages }
}
}
if (cachedViewState !== cachedState) {
sessionStateByRuntimeIdRef.current.set(cachedRuntimeId, cachedViewState)
publishSessionState(cachedRuntimeId, cachedViewState)
@@ -535,6 +564,17 @@ export function useSessionActions({
sessionStateByRuntimeIdRef.current.delete(cachedRuntimeId)
dropSessionState(cachedRuntimeId)
} else {
// Paint the warm cache immediately, but also refresh the persisted
// transcript in parallel. A resumed runtime carries the agent's
// compression projection, which can have the same row count as the
// stored conversation while containing different rows. Trusting that
// projection alone made completed prompts disappear after an app
// restart whenever this warm path short-circuited the cold REST
// prefetch. Watch mirrors stay live-only by design.
const persistedTranscriptPromise = isWatchWindow()
? null
: getSessionMessages(storedSessionId, sessionProfile).catch(() => null)
setFreshDraftReady(false)
clearNotifications()
setSelectedStoredSessionId(storedSessionId)
@@ -547,27 +587,111 @@ export function useSessionActions({
setSessionStartedAt(Date.now())
try {
const usage = await requestGateway<UsageStats>('session.usage', { session_id: cachedRuntimeId })
let activated: SessionResumeResponse | null = null
try {
activated = await requestGateway<SessionResumeResponse>('session.activate', {
session_id: cachedRuntimeId,
cols: 96
})
} catch (error) {
// Compatibility for older backends. Modern backends require
// session.activate here because it rebinds the live session's
// event transport to this newly-opened WebSocket.
if (!isMissingRpcMethod(error)) {
throw error
}
const usage = await requestGateway<UsageStats>('session.usage', { session_id: cachedRuntimeId })
if (!isCurrentResume()) {
return
}
if (usage) {
setCurrentUsage(current => ({ ...current, ...usage }))
}
return
}
if (!isCurrentResume()) {
return
}
if (usage) {
setCurrentUsage(current => ({ ...current, ...usage }))
}
if (activated.session_key && activated.session_key !== storedSessionId) {
runtimeIdByStoredSessionIdRef.current.delete(storedSessionId)
sessionStateByRuntimeIdRef.current.delete(cachedRuntimeId)
dropSessionState(cachedRuntimeId)
} else {
const runtimeInfo = applyRuntimeInfo(activated.info)
return
} catch {
let activatedMessages =
activated.messages.length || activated.inflight || activated.queued
? reconcileAuthoritativeMessages(activated.messages, cachedViewState.messages, activated)
: cachedViewState.messages
const running = Boolean(activated.running ?? cachedViewState.busy)
// While idle, the persisted REST transcript is the display
// authority: session.activate returns the runtime's compressed
// context projection, not necessarily the complete conversation.
// During a live turn, keep the runtime/cache projection so an
// accepted but not-yet-persisted prompt or stream is never lost.
if (!running && persistedTranscriptPromise) {
const persisted = await persistedTranscriptPromise
if (!isCurrentResume()) {
return
}
const activatedStoredSessionId = activated.session_key || activated.resumed
const persistedMatchesActivatedSession =
!persisted?.session_id ||
!activatedStoredSessionId ||
persisted.session_id === activatedStoredSessionId
if (persisted && persistedMatchesActivatedSession) {
activatedMessages = reconcileAuthoritativeMessages(persisted.messages, activatedMessages)
}
}
const activatedState = updateSessionState(
cachedRuntimeId,
state => ({
...state,
...(runtimeInfo ?? {}),
messages: activatedMessages,
busy: running,
awaitingResponse: running
}),
storedSessionId
)
busyRef.current = running
setBusy(running)
setAwaitingResponse(running)
syncSessionStateToView(cachedRuntimeId, activatedState)
return
}
} catch (error) {
// The cached runtime id was minted by a prior backend instance. A
// pooled profile backend that gets idle-reaped (pruneSecondaryGateways)
// and respawned across a profile swap mints fresh ids, so this mapping
// now 404s ("session not found"). Drop it and fall through to a full
// resume that rebinds a live runtime id.
// resume that rebinds a live runtime id. A transient timeout or
// transport error is NOT proof that the session is dead: keep the
// cache and optimistic turn intact for the next reconnect attempt.
if (!isCurrentResume()) {
return
}
if (!isSessionGoneError(error)) {
return
}
runtimeIdByStoredSessionIdRef.current.delete(storedSessionId)
sessionStateByRuntimeIdRef.current.delete(cachedRuntimeId)
dropSessionState(cachedRuntimeId)
@@ -585,7 +709,7 @@ export function useSessionActions({
// session's transcript would leak into this cold resume ("switching
// sessions shows the same messages"). Clear it so the loader/prefetch
// paints fresh; guarded so the normal cold path (already cleared) no-ops.
if ($messages.get().length > 0) {
if (!resumedSameSelectedSession && $messages.get().length > 0) {
setMessages([])
}
@@ -610,7 +734,14 @@ export function useSessionActions({
try {
const watchWindow = isWatchWindow()
let localSnapshot = $messages.get()
let localSnapshot = resumedSameSelectedSession
? preserveLocalPendingTurnMessages($messages.get(), resumeStartMessages)
: $messages.get()
let prefetchApplied = false
let prefetchedMessageCount = 0
let prefetchedStoredSessionId: string | null = null
// REST transcript prefetch and the gateway resume RPC are independent
// — run them concurrently so a big session's wall time is
@@ -641,7 +772,14 @@ export function useSessionActions({
const storedMessages = await prefetchPromise
if (isCurrentResume()) {
localSnapshot = preserveLocalAssistantErrors(toChatMessages(storedMessages.messages), $messages.get())
const previousMessages = resumedSameSelectedSession
? preserveLocalPendingTurnMessages($messages.get(), resumeStartMessages)
: $messages.get()
localSnapshot = reconcileAuthoritativeMessages(storedMessages.messages, previousMessages)
prefetchApplied = true
prefetchedMessageCount = storedMessages.messages.length
prefetchedStoredSessionId = storedMessages.session_id || storedSessionId
if (!chatMessageArraysEquivalent($messages.get(), localSnapshot)) {
setMessages(localSnapshot)
@@ -665,14 +803,25 @@ export function useSessionActions({
// skip converting/reconciling the resume payload entirely — on a
// 1000+-message session that second conversion plus the deep
// equivalence compare costs over a second of main-thread time.
const resumedStoredSessionId = resumed.session_key || resumed.resumed
const prefetchMatchesResumedSession =
!prefetchedStoredSessionId || !resumedStoredSessionId || prefetchedStoredSessionId === resumedStoredSessionId
const hasLiveProjection = Boolean(resumed.inflight || resumed.queued)
const preferredMessages =
localSnapshot.length > 0
prefetchApplied &&
prefetchMatchesResumedSession &&
!hasLiveProjection &&
resumed.messages.length <= prefetchedMessageCount
? localSnapshot
: (() => {
const resumedMessages = preserveLocalAssistantErrors(
reconcileResumeMessages(toChatMessages(resumed.messages), currentMessages),
currentMessages
)
const previousMessages = resumedSameSelectedSession
? preserveLocalPendingTurnMessages(currentMessages, resumeStartMessages)
: currentMessages
const resumedMessages = reconcileAuthoritativeMessages(resumed.messages, previousMessages, resumed)
return chatMessageArraysEquivalent(currentMessages, resumedMessages) ? currentMessages : resumedMessages
})()
@@ -735,7 +884,11 @@ export function useSessionActions({
return
}
setMessages(preserveLocalAssistantErrors(toChatMessages(fallback.messages), $messages.get()))
const previousMessages = resumedSameSelectedSession
? preserveLocalPendingTurnMessages($messages.get(), resumeStartMessages)
: $messages.get()
setMessages(reconcileAuthoritativeMessages(fallback.messages, previousMessages))
} catch (e) {
// Fallback also failed: nothing to paint. Leave whatever messages are
// already shown and fall through to arm the resume-failure latch so
@@ -6,11 +6,13 @@ import { $activeGatewayProfile } from '@/store/profile'
import type { SessionInfo } from '@/types/hermes'
import {
appendLiveSessionProjection,
applyRuntimeInfo,
chatMessageArraysEquivalent,
chatMessagesEquivalent,
chatPartsEquivalent,
isSessionGoneError,
preserveLocalPendingTurnMessages,
reconcileResumeMessages,
sessionMatchesStoredId,
sessionShouldHaveTranscript,
@@ -289,3 +291,72 @@ describe('reconcileResumeMessages', () => {
expect(out.parts.some(p => p.type === 'reasoning')).toBe(true)
})
})
describe('preserveLocalPendingTurnMessages', () => {
it('keeps an optimistic user turn and pending assistant when the server projection is behind', () => {
const next = [msg('1-user', 'user', 'first'), msg('2-assistant', 'assistant', 'first answer')]
const previous = [
...next,
msg('user-optimistic', 'user', 'new question'),
msg('assistant-stream-1', 'assistant', 'partial answer', { pending: true })
]
expect(preserveLocalPendingTurnMessages(next, previous).map(message => message.id)).toEqual([
'1-user',
'2-assistant',
'user-optimistic',
'assistant-stream-1'
])
})
it('drops the local copies once the same role ordinals are authoritative', () => {
const previous = [
msg('1-user', 'user', 'first'),
msg('2-assistant', 'assistant', 'first answer'),
msg('user-optimistic', 'user', 'new question'),
msg('assistant-stream-1', 'assistant', 'partial answer', { pending: true })
]
const next = [
msg('1-user-stored', 'user', 'first'),
msg('2-assistant-stored', 'assistant', 'first answer'),
msg('3-user-stored', 'user', 'new question'),
msg('4-assistant-stored', 'assistant', 'complete answer')
]
expect(preserveLocalPendingTurnMessages(next, previous)).toBe(next)
})
})
describe('appendLiveSessionProjection', () => {
it('restores the running turn and accepted queued prompt after a renderer restart', () => {
const stored = [msg('stored-user', 'user', 'earlier'), msg('stored-assistant', 'assistant', 'earlier answer')]
const restored = appendLiveSessionProjection(stored, {
session_id: 'runtime-1',
inflight: {
user: 'current prompt',
assistant: 'partial answer',
streaming: true
},
queued: { user: 'newest prompt' }
})
expect(restored.map(message => message.role)).toEqual(['user', 'assistant', 'user', 'assistant', 'user'])
expect(restored.map(message => message.parts.map(part => ('text' in part ? part.text : '')).join(''))).toEqual([
'earlier',
'earlier answer',
'current prompt',
'partial answer',
'newest prompt'
])
expect(restored[3]).toMatchObject({ id: 'assistant-stream-runtime-1', pending: true })
})
it('preserves the original array when no live projection exists', () => {
const stored = [msg('stored-user', 'user', 'earlier')]
expect(appendLiveSessionProjection(stored, { session_id: 'runtime-1' })).toBe(stored)
})
})
@@ -1,5 +1,5 @@
import { getSession } from '@/hermes'
import { type ChatMessage, chatMessageText } from '@/lib/chat-messages'
import { assistantTextPart, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
import { normalizePersonalityValue } from '@/lib/chat-runtime'
import { embeddedImageUrls, textWithoutEmbeddedImages } from '@/lib/embedded-images'
import { reconcileApprovalModeForProfile } from '@/store/approval-mode'
@@ -26,7 +26,7 @@ import {
// it from here; the canonical definition lives in @/store/session.
export { sessionMatchesStoredId }
import { reportBackendContract, reportInstallMethodWarning } from '@/store/updates'
import type { SessionCreateResponse, SessionInfo, SessionRuntimeInfo } from '@/types/hermes'
import type { SessionCreateResponse, SessionInfo, SessionResumeResponse, SessionRuntimeInfo } from '@/types/hermes'
import type { ClientSessionState } from '../../../types'
@@ -224,6 +224,124 @@ export function reconcileResumeMessages(nextMessages: ChatMessage[], previousMes
})
}
/**
* Keep the local tail of a turn while a reconnect hydrates an older server
* projection. The user's optimistic row exists before prompt.submit persists
* it, and the pending assistant row exists before message.complete commits it;
* dropping either makes an accepted turn appear to vanish during transport
* churn.
*
* Authoritative rows use different ids, so match by role ordinal. A matching
* user row is considered committed only when its visible text also matches;
* any authoritative assistant at the same ordinal supersedes the local stream.
*/
export function preserveLocalPendingTurnMessages(
nextMessages: ChatMessage[],
previousMessages: ChatMessage[]
): ChatMessage[] {
if (!previousMessages.length) {
return nextMessages
}
const nextByRoleOrdinal = new Map<string, ChatMessage>()
const nextRoleCounts = new Map<ChatMessage['role'], number>()
for (const message of nextMessages) {
const ordinal = nextRoleCounts.get(message.role) ?? 0
nextRoleCounts.set(message.role, ordinal + 1)
nextByRoleOrdinal.set(`${message.role}:${ordinal}`, message)
}
const nextIds = new Set(nextMessages.map(message => message.id))
const previousRoleCounts = new Map<ChatMessage['role'], number>()
const preserved: ChatMessage[] = []
for (const message of previousMessages) {
const ordinal = previousRoleCounts.get(message.role) ?? 0
previousRoleCounts.set(message.role, ordinal + 1)
const isOptimisticUser = message.role === 'user' && message.id.startsWith('user-')
const isPendingAssistant =
message.role === 'assistant' && (message.pending === true || message.id.startsWith('assistant-stream-'))
if ((!isOptimisticUser && !isPendingAssistant) || nextIds.has(message.id)) {
continue
}
const authoritative = nextByRoleOrdinal.get(`${message.role}:${ordinal}`)
if (authoritative) {
if (isPendingAssistant) {
continue
}
if (chatMessageText(authoritative).trim() === chatMessageText(message).trim()) {
continue
}
}
preserved.push(message)
}
return preserved.length ? [...nextMessages, ...preserved] : nextMessages
}
/**
* Append the backend-only tail of a live turn to a stored transcript.
*
* Session history is committed only when a turn finishes. During a reconnect,
* `inflight` is therefore the authority for the currently running user/assistant
* pair, while `queued` is an accepted next-turn prompt waiting in gateway
* memory. Stable ids let repeated activate/resume hydration reconcile instead
* of growing duplicate rows.
*/
export function appendLiveSessionProjection(
messages: ChatMessage[],
projection: Pick<SessionResumeResponse, 'inflight' | 'queued' | 'session_id'>
): ChatMessage[] {
const inflightUser = projection.inflight?.user?.trim() ?? ''
const inflightAssistant = projection.inflight?.assistant ?? ''
const inflightStreaming = Boolean(projection.inflight?.streaming)
const queuedUser = projection.queued?.user?.trim() ?? ''
if (!inflightUser && !inflightAssistant && !inflightStreaming && !queuedUser) {
return messages
}
const sessionId = projection.session_id || 'session'
const projected: ChatMessage[] = []
if (inflightUser) {
projected.push({
id: `user-inflight-${sessionId}`,
role: 'user',
parts: [textPart(inflightUser)]
})
}
// Keep a pending assistant boundary even before the first delta when a
// queued user turn follows it. This preserves the two distinct turns.
if (inflightAssistant || inflightStreaming || (inflightUser && queuedUser)) {
projected.push({
id: `assistant-stream-${sessionId}`,
role: 'assistant',
parts: inflightAssistant ? [assistantTextPart(inflightAssistant)] : [],
pending: inflightStreaming
})
}
if (queuedUser) {
projected.push({
id: `user-queued-${sessionId}`,
role: 'user',
parts: [textPart(queuedUser)]
})
}
return projected.length ? [...messages, ...projected] : messages
}
export interface BranchMessage {
content: string
role: ChatMessage['role']
@@ -0,0 +1,207 @@
import { act, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { SessionInfo, SidebarSessionsResponse } from '@/hermes'
import {
$cronSessions,
$messagingSessions,
$sessions,
$sessionsLoading,
setCronSessions,
setMessagingSessions,
setSessions,
setSessionsLoading
} from '@/store/session'
import { useSessionListActions } from './use-session-list-actions'
// Sidebar refresh hygiene: a content-identical refresh (turn complete,
// cross-window broadcast, reconnect) must not replace $sessions' array
// identity — that identity is the dependency for every sidebar memo — and
// must not flicker the loading flag over an already-populated list.
const row = (id: string, over: Partial<SessionInfo> = {}): SessionInfo =>
({
ended_at: null,
id,
input_tokens: 0,
is_active: false,
last_active: 1000,
message_count: 3,
model: 'm',
output_tokens: 0,
preview: 'hey',
profile: 'default',
source: 'desktop',
started_at: 900,
title: `Chat ${id}`,
...over
}) as SessionInfo
// Batched sidebar response builder. `refreshSessions` now makes ONE
// listSidebarSessions call that returns all three slices, replacing the three
// separate listAllProfileSessions calls (each of which reopened every profile
// DB) — #66377-adjacent perf work from the desktop audit canvas.
const sidebar = (
recents: { sessions: SessionInfo[]; total?: number; profile_totals?: Record<string, number> },
cron: SessionInfo[] = [],
messaging: SessionInfo[] = []
): SidebarSessionsResponse => ({
recents: { sessions: recents.sessions, total: recents.total, profile_totals: recents.profile_totals },
cron: { sessions: cron },
messaging: { sessions: messaging, total: messaging.length }
})
const listSidebarSessions = vi.fn()
const listAllProfileSessions = vi.fn()
vi.mock('@/hermes', async importOriginal => ({
...(await importOriginal<Record<string, unknown>>()),
getCronJobs: vi.fn(async () => []),
listAllProfileSessions: (...args: unknown[]) => listAllProfileSessions(...args),
listSidebarSessions: (...args: unknown[]) => listSidebarSessions(...args)
}))
beforeEach(() => {
listSidebarSessions.mockReset()
listAllProfileSessions.mockReset()
setSessions([])
setCronSessions([])
setMessagingSessions([])
setSessionsLoading(false)
})
afterEach(() => {
setSessions([])
setCronSessions([])
setMessagingSessions([])
setSessionsLoading(false)
})
describe('refreshSessions identity + loading hygiene', () => {
it('keeps the previous $sessions array when the refresh is content-identical', async () => {
const rows = [row('a'), row('b')]
listSidebarSessions.mockResolvedValue(sidebar({ sessions: rows, total: 2, profile_totals: { default: 2 } }))
const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' }))
await act(async () => {
await result.current.refreshSessions()
})
const first = $sessions.get()
expect(first.map(s => s.id)).toEqual(['a', 'b'])
// Second refresh returns fresh (but equal) row objects, as the API does.
listSidebarSessions.mockResolvedValue(
sidebar({ sessions: [row('a'), row('b')], total: 2, profile_totals: { default: 2 } })
)
await act(async () => {
await result.current.refreshSessions()
})
expect($sessions.get()).toBe(first)
})
it('swaps the array when rows actually changed', async () => {
listSidebarSessions.mockResolvedValue(sidebar({ sessions: [row('a')], total: 1, profile_totals: {} }))
const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' }))
await act(async () => {
await result.current.refreshSessions()
})
const first = $sessions.get()
listSidebarSessions.mockResolvedValue(
sidebar({ sessions: [row('a', { last_active: 2000, title: 'Renamed' })], total: 1, profile_totals: {} })
)
await act(async () => {
await result.current.refreshSessions()
})
expect($sessions.get()).not.toBe(first)
expect($sessions.get()[0].title).toBe('Renamed')
})
it('does not flicker the loading flag over a populated list', async () => {
listSidebarSessions.mockResolvedValue(sidebar({ sessions: [row('a')], total: 1, profile_totals: {} }))
const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' }))
await act(async () => {
await result.current.refreshSessions()
})
const loadingStates: boolean[] = []
const off = $sessionsLoading.subscribe(value => loadingStates.push(value))
await act(async () => {
await result.current.refreshSessions()
})
off()
// Only the initial subscribe emission — no true/false churn per refresh.
expect(loadingStates).toEqual([false])
})
it('still shows loading for the initial (empty-list) fetch', async () => {
listSidebarSessions.mockResolvedValue(sidebar({ sessions: [row('a')], total: 1, profile_totals: {} }))
const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' }))
const loadingStates: boolean[] = []
const off = $sessionsLoading.subscribe(value => loadingStates.push(value))
await act(async () => {
await result.current.refreshSessions()
})
off()
expect(loadingStates).toEqual([false, true, false])
})
})
describe('refreshSessions batches slices into one request', () => {
it('makes a single sidebar call and distributes recents / cron / messaging', async () => {
const recents = [row('a'), row('b')]
const cron = [row('c1', { source: 'cron', title: 'nightly' })]
const messaging = [row('m1', { source: 'telegram', title: 'tg chat' })]
listSidebarSessions.mockResolvedValue(
sidebar({ sessions: recents, total: 2, profile_totals: { default: 2 } }, cron, messaging)
)
const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' }))
await act(async () => {
await result.current.refreshSessions()
})
// One batched call, not three separate listAllProfileSessions reads.
expect(listSidebarSessions).toHaveBeenCalledTimes(1)
expect(listAllProfileSessions).not.toHaveBeenCalled()
// Each slice landed in its own store.
expect($sessions.get().map(s => s.id)).toEqual(['a', 'b'])
expect($cronSessions.get().map(s => s.id)).toEqual(['c1'])
expect($messagingSessions.get().map(s => s.id)).toEqual(['m1'])
})
it('forwards the active profile scope + section limits to the batched call', async () => {
listSidebarSessions.mockResolvedValue(sidebar({ sessions: [], total: 0, profile_totals: {} }))
const { result } = renderHook(() => useSessionListActions({ profileScope: 'work' }))
await act(async () => {
await result.current.refreshSessions()
})
expect(listSidebarSessions).toHaveBeenCalledWith(
expect.objectContaining({
recentsProfile: 'work',
recentsExclude: expect.arrayContaining(['cron']),
messagingExclude: expect.arrayContaining(['cron'])
})
)
})
})
@@ -1,6 +1,6 @@
import { useCallback, useRef } from 'react'
import { getCronJobs, listAllProfileSessions, type SessionInfo } from '@/hermes'
import { getCronJobs, listAllProfileSessions, listSidebarSessions, type SessionInfo } from '@/hermes'
import { sameCronSignature } from '@/lib/session-signatures'
import {
isMessagingSource,
@@ -15,9 +15,7 @@ import {
$messagingSessions,
$selectedStoredSessionId,
$sessions,
$workingSessionIds,
CRON_SECTION_LIMIT,
getRecentlySettledSessionIds,
mergeSessionPage,
MESSAGING_SECTION_LIMIT,
setCronSessions,
@@ -29,6 +27,7 @@ import {
setSessionsLoading,
setSessionsTotal
} from '@/store/session'
import { $workingSessionIds, getRecentlySettledSessionIds } from '@/store/session-states'
// The recents list is local-only: cron rows have their own section, and each
// messaging platform (telegram, discord, …) is fetched separately into its own
@@ -76,22 +75,6 @@ interface UseSessionListActionsArgs {
export function useSessionListActions({ profileScope }: UseSessionListActionsArgs) {
const refreshSessionsRequestRef = useRef(0)
// Cron-job sessions as their own list (latest N). Independent of the recents
// page so the two never compete for slots. Cheap + bounded. Kept (even though
// the sidebar now lists cron *jobs*, not run sessions) so a pinned cron run
// still resolves into the Pinned section via sessionByAnyId.
const refreshCronSessions = useCallback(async () => {
try {
const { sessions } = await listAllProfileSessions(CRON_SECTION_LIMIT, 1, 'exclude', 'recent', 'all', {
source: 'cron'
})
setCronSessions(prev => (sameCronSignature(prev, sessions) ? prev : sessions))
} catch {
// Non-fatal: the cron section just stays empty/stale.
}
}, [])
// Messaging-platform sessions as their own slice, fetched separately from
// local recents so each platform renders a self-managed section and never
// competes with local chats for the recents page budget. One combined fetch
@@ -154,7 +137,15 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg
const refreshSessions = useCallback(async () => {
const requestId = refreshSessionsRequestRef.current + 1
refreshSessionsRequestRef.current = requestId
setSessionsLoading(true)
// The loading flag exists to drive the initial skeletons (they only render
// while the list is empty). Turn-complete / reconnect refreshes over a
// populated list used to flip it true→false anyway, churning every
// $sessionsLoading subscriber twice per turn for no visible change.
const showLoading = $sessions.get().length === 0
if (showLoading) {
setSessionsLoading(true)
}
try {
const limit = $sessionsLimit.get()
@@ -164,33 +155,69 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg
// clutter the sidebar.
// Unified cross-profile list (served read-only off each profile's
// state.db; no per-profile backend is spawned). Single-profile users get
// the same rows tagged profile="default". Cron sessions are excluded here
// and fetched separately (refreshCronSessions) so the scheduler's
// always-newest rows can't consume the recents page budget.
// Scope the fetch to the active profile (not always 'all') so a profile
// the same rows tagged profile="default".
// Scope recents to the active profile (not always 'all') so a profile
// with few recent sessions isn't windowed out of the cross-profile
// recency page — the empty-history-on-profile-switch bug.
// recency page — the empty-history-on-profile-switch bug. Cron + messaging
// stay cross-profile.
const sessionProfile = profileScope === ALL_PROFILES ? 'all' : profileScope
const result = await listAllProfileSessions(limit, 1, 'exclude', 'recent', sessionProfile, {
excludeSources: SIDEBAR_EXCLUDED_SOURCES
// Batched: one request opens each profile DB once and returns all three
// source-scoped slices, instead of three separate listAllProfileSessions
// calls that each reopened + re-counted every profile DB per refresh.
const result = await listSidebarSessions({
recentsProfile: sessionProfile,
recentsLimit: limit,
recentsExclude: SIDEBAR_EXCLUDED_SOURCES,
cronLimit: CRON_SECTION_LIMIT,
messagingLimit: MESSAGING_SECTION_LIMIT,
messagingExclude: MESSAGING_EXCLUDED_SOURCES
})
if (refreshSessionsRequestRef.current === requestId) {
setSessions(prev => mergeSessionPage(prev, result.sessions, sessionsToKeep()))
setSessionsTotal(typeof result.total === 'number' ? result.total : result.sessions.length)
setSessionProfileTotals(result.profile_totals ?? {})
const recents = result.recents
// Signature-gate the swap (same pattern as cron/messaging): a refresh
// that returns content-identical rows must keep the previous array
// identity, or every sidebar memo keyed on $sessions recomputes and the
// whole list re-renders once per turn/broadcast for nothing.
setSessions(prev => {
const next = mergeSessionPage(prev, recents.sessions, sessionsToKeep())
return sameCronSignature(prev, next) ? prev : next
})
setSessionsTotal(typeof recents.total === 'number' ? recents.total : recents.sessions.length)
setSessionProfileTotals(prev => {
const next = recents.profile_totals ?? {}
const prevKeys = Object.keys(prev)
return prevKeys.length === Object.keys(next).length && prevKeys.every(key => prev[key] === next[key])
? prev
: next
})
// Cron section: latest N cron sessions (kept so a pinned cron run still
// resolves via sessionByAnyId), signature-gated like above.
setCronSessions(prev => (sameCronSignature(prev, result.cron.sessions) ? prev : result.cron.sessions))
// Messaging sections: drop any non-messaging source the broad exclude
// didn't catch (custom sources stay in local recents), then split per
// platform in the UI.
const messagingRows = result.messaging.sessions.filter(s => isMessagingSource(s.source))
setMessagingSessions(prev => (sameCronSignature(prev, messagingRows) ? prev : messagingRows))
// Hit the cap → at least one platform may have more on disk than loaded.
setMessagingTruncated(result.messaging.sessions.length >= MESSAGING_SECTION_LIMIT)
}
} finally {
if (refreshSessionsRequestRef.current === requestId) {
if (showLoading && refreshSessionsRequestRef.current === requestId) {
setSessionsLoading(false)
}
}
void refreshCronSessions()
// Cron *jobs* are a distinct API (getCronJobs), not a session slice.
void refreshCronJobs()
void refreshMessagingSessions()
}, [profileScope, refreshCronSessions, refreshCronJobs, refreshMessagingSessions])
}, [profileScope, refreshCronJobs])
const loadMoreSessions = useCallback(async () => {
bumpSessionsLimit()
@@ -6,24 +6,18 @@ import { preserveLocalAssistantErrors } from '@/lib/chat-messages'
import { createClientSessionState } from '@/lib/chat-runtime'
import { setMutableRef } from '@/lib/mutable-ref'
import {
$activeSessionId,
$busy,
$messages,
noteSessionActivity,
onSessionWatchdogClear,
setActiveSessionStoredId,
setCurrentFastMode,
setCurrentModel,
setCurrentPersonality,
setCurrentProvider,
setCurrentReasoningEffort,
setCurrentServiceTier,
setSessionAttention,
setSessionWorking,
setTurnStartedAt,
setYoloActive
} from '@/store/session'
import { publishSessionState } from '@/store/session-states'
import { publishSessionState, setWatchdogClearFn } from '@/store/session-states'
import type { ClientSessionState } from '../../types'
@@ -103,33 +97,20 @@ export function useSessionStateCache({
const existing = sessionStateByRuntimeIdRef.current.get(sessionId)
if (existing) {
if (storedSessionId !== undefined) {
const previousStoredSessionId = existing.storedSessionId
existing.storedSessionId = storedSessionId
if (storedSessionId !== undefined && storedSessionId !== existing.storedSessionId) {
// Stored id changed (e.g. auto-compression rotated it). Create a NEW
// state object rather than mutating in place — updateSessionState needs
// the PREVIOUS state to detect transitions (busy→idle, id rotation).
const updated = { ...existing, storedSessionId }
sessionStateByRuntimeIdRef.current.set(sessionId, updated)
if (storedSessionId) {
runtimeIdByStoredSessionIdRef.current.set(storedSessionId, sessionId)
if (existing.busy) {
setSessionWorking(storedSessionId, true)
}
}
if (previousStoredSessionId && previousStoredSessionId !== storedSessionId) {
setSessionWorking(previousStoredSessionId, false)
// Auto-compression rotated the stored id on the active session. Signal
// the route-following effect in use-session-actions so the URL + selection
// re-anchor to the continuation id — otherwise the next send hits a stale
// stored→runtime mapping (getRuntimeIdForStoredSession returns null) and
// triggers a full thread reload via resumeStoredSession.
if (sessionId === $activeSessionId.get()) {
setActiveSessionStoredId(storedSessionId)
}
}
}
return existing
return sessionStateByRuntimeIdRef.current.get(sessionId)!
}
const created = createClientSessionState(storedSessionId ?? null)
@@ -275,30 +256,10 @@ export function useSessionStateCache({
const previous = ensureSessionState(sessionId, storedSessionId)
const next = updater({ ...previous, messages: previous.messages })
sessionStateByRuntimeIdRef.current.set(sessionId, next)
// Mirror into the reactive multi-session store — session tiles (and any
// other non-primary surface) subscribe per runtime id there instead of
// through the single active $messages view.
// Publishing to $sessionStates automatically fires transition side-effects
// (watchdog, settle grace, unread marker, compression id rotation) inside
// publishSessionState — no manual transition call needed.
publishSessionState(sessionId, next)
if (previous.storedSessionId !== next.storedSessionId || !next.busy) {
setSessionWorking(previous.storedSessionId, false)
}
if (previous.storedSessionId !== next.storedSessionId || !next.needsInput) {
setSessionAttention(previous.storedSessionId, false)
}
setSessionWorking(next.storedSessionId, next.busy)
setSessionAttention(next.storedSessionId, next.needsInput)
// Every state update is effectively a "still alive" heartbeat for
// streaming events. The session-store watchdog uses this to keep the
// working flag alive during long-running turns and to clear it once
// the stream goes silent.
if (next.busy) {
noteSessionActivity(next.storedSessionId)
}
syncSessionStateToView(sessionId, next)
return next
@@ -318,30 +279,32 @@ export function useSessionStateCache({
return runtimeState?.storedSessionId === storedSessionId ? runtimeId : null
}, [])
// When the store watchdog force-clears a stuck session (8 min of stream
// silence — a hung or looping turn that never delivered its terminal event),
// also drop that session's busy/awaiting flags here. Clearing the sidebar dot
// alone leaves the composer wedged on "Thinking"/Stop; updateSessionState
// re-syncs `$busy` when the healed session is the one on screen.
useEffect(
() =>
onSessionWatchdogClear(storedSessionId => {
const runtimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
const state = runtimeId ? sessionStateByRuntimeIdRef.current.get(runtimeId) : undefined
// Wire the watchdog's force-clear callback to our cache. When the watchdog
// fires (8 min of stream silence — a hung or looping turn that never
// delivered its terminal event), it calls this to clear the session's busy
// state. Clearing the sidebar dot alone would leave the composer wedged on
// "Thinking"/Stop; updateSessionState propagates the clear to $sessionStates
// → $workingSessionIds (computed) follows automatically, and
// syncSessionStateToView re-syncs $busy when the healed session is the one
// on screen.
useEffect(() => {
setWatchdogClearFn(runtimeId => {
const state = sessionStateByRuntimeIdRef.current.get(runtimeId)
if (!runtimeId || !state?.busy) {
return
}
if (!state?.busy) {
return
}
updateSessionState(runtimeId, current => ({
...current,
awaitingResponse: false,
busy: false,
needsInput: false
}))
}),
[updateSessionState]
)
updateSessionState(runtimeId, current => ({
...current,
awaitingResponse: false,
busy: false,
needsInput: false
}))
})
return () => setWatchdogClearFn(null)
}, [updateSessionState])
return {
activeSessionIdRef,
@@ -0,0 +1,172 @@
import { renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { BillingChargeResponse, BillingStateResponse } from './types'
const requestGatewayMock = vi.hoisted(() => vi.fn())
vi.mock('@/app/gateway/hooks/use-gateway-request', () => ({
useGatewayRequest: () => ({ requestGateway: requestGatewayMock })
}))
import { createBillingApi, useBillingApi } from './api'
describe('createBillingApi', () => {
beforeEach(() => {
requestGatewayMock.mockReset()
vi.restoreAllMocks()
})
it('passes successful RPC results through as data', async () => {
const state = {
auto_reload: null,
balance_display: '$10.00',
balance_usd: '10',
can_charge: true,
card: null,
charge_presets: ['10'],
charge_presets_display: ['$10'],
cli_billing_enabled: true,
is_admin: true,
logged_in: true,
max_usd: '100',
min_usd: '10',
monthly_cap: null,
ok: true,
org_name: 'Nous',
portal_url: 'https://portal.nousresearch.com/billing',
role: 'OWNER'
} satisfies BillingStateResponse
requestGatewayMock.mockResolvedValueOnce(state)
const { result } = renderHook(() => useBillingApi())
const response = await result.current.fetchBillingState()
expect(response).toEqual({ data: state, ok: true })
expect(requestGatewayMock).toHaveBeenCalledWith('billing.state', {})
})
it('normalizes object-shaped refusal envelopes', async () => {
requestGatewayMock.mockResolvedValueOnce({
error: {
kind: 'no_payment_method',
message: 'No saved card.',
portal_url: 'https://portal.nousresearch.com/billing',
retry_after: 30
},
ok: false
})
const api = createBillingApi(requestGatewayMock)
const response = await api.chargeStatus('ch_123')
expect(response).toMatchObject({
ok: false,
refusal: {
kind: 'no_payment_method',
message: 'No saved card.',
portalUrl: 'https://portal.nousresearch.com/billing',
retryAfter: 30
}
})
expect(requestGatewayMock).toHaveBeenCalledWith('billing.charge_status', { charge_id: 'ch_123' })
})
it('normalizes current string-shaped refusal envelopes', async () => {
requestGatewayMock.mockResolvedValueOnce({
error: 'monthly_cap_exceeded',
message: 'Monthly spend cap reached.',
ok: false,
payload: { remainingUsd: '4.50' },
portal_url: 'https://portal.nousresearch.com/billing'
})
const api = createBillingApi(requestGatewayMock)
const response = await api.updateAutoReload({ enabled: true, reload_to_usd: '100', threshold_usd: '25' })
expect(response).toMatchObject({
ok: false,
refusal: {
kind: 'monthly_cap_exceeded',
message: 'Monthly spend cap reached.',
payload: { remainingUsd: '4.50' },
portalUrl: 'https://portal.nousresearch.com/billing'
}
})
expect(requestGatewayMock).toHaveBeenCalledWith('billing.auto_reload', {
enabled: true,
threshold: '25',
top_up_amount: '100'
})
})
it('maps thrown gateway failures to transport refusals', async () => {
requestGatewayMock.mockRejectedValueOnce(new Error('connection closed'))
const api = createBillingApi(requestGatewayMock)
const response = await api.fetchSubscriptionState()
expect(response).toEqual({
ok: false,
refusal: {
kind: 'transport',
message: 'connection closed',
raw: expect.any(Error)
}
})
})
it('maps thrown timeout failures to timeout refusals', async () => {
requestGatewayMock.mockRejectedValueOnce(new Error('request timed out after 5000ms'))
const api = createBillingApi(requestGatewayMock)
const response = await api.stepUp()
expect(response).toEqual({
ok: false,
refusal: {
kind: 'timeout',
message: 'request timed out after 5000ms',
raw: expect.any(Error)
}
})
})
it('sends a step-up session id when provided', async () => {
requestGatewayMock.mockResolvedValueOnce({ granted: true, ok: true })
const api = createBillingApi(requestGatewayMock)
await api.stepUp('session-123')
expect(requestGatewayMock).toHaveBeenCalledWith('billing.step_up', { session_id: 'session-123' })
})
it('sends a minted charge idempotency key and reuses it on explicit retry', async () => {
vi.spyOn(crypto, 'randomUUID').mockReturnValue('11111111-1111-4111-8111-111111111111')
const submitted = {
charge_id: 'ch_123',
idempotency_key: '11111111-1111-4111-8111-111111111111',
ok: true
} satisfies BillingChargeResponse
requestGatewayMock.mockResolvedValue(submitted)
const api = createBillingApi(requestGatewayMock)
const first = await api.charge('25')
const second = await api.charge('25', first.idempotencyKey)
expect(first).toEqual({ data: submitted, idempotencyKey: '11111111-1111-4111-8111-111111111111', ok: true })
expect(second).toEqual({ data: submitted, idempotencyKey: '11111111-1111-4111-8111-111111111111', ok: true })
expect(crypto.randomUUID).toHaveBeenCalledTimes(1)
expect(requestGatewayMock).toHaveBeenNthCalledWith(1, 'billing.charge', {
amount_usd: '25',
idempotency_key: '11111111-1111-4111-8111-111111111111'
})
expect(requestGatewayMock).toHaveBeenNthCalledWith(2, 'billing.charge', {
amount_usd: '25',
idempotency_key: '11111111-1111-4111-8111-111111111111'
})
})
})
@@ -0,0 +1,168 @@
import { useMemo } from 'react'
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
import type {
BillingChargeResponse,
BillingChargeStatusResponse,
BillingErrorPayload,
BillingMutationResponse,
BillingRefusalCode,
BillingStateResponse,
SubscriptionStateResponse
} from './types'
export type BillingErrorKind = BillingRefusalCode
export interface BillingRefusal {
actor?: string
code?: string
kind: BillingErrorKind | 'timeout' | 'transport'
message: string
payload?: BillingErrorPayload
portalUrl?: string
raw?: unknown
recovery?: string
retryAfter?: number
}
export type BillingResult<T> = { data: T; ok: true } | { ok: false; refusal: BillingRefusal }
export type BillingChargeResult = BillingResult<BillingChargeResponse> & { idempotencyKey: string }
export interface UpdateAutoReloadInput {
enabled: boolean
reload_to_usd?: string
threshold_usd?: string
}
export type BillingRequestGateway = <T>(
method: string,
params?: Record<string, unknown>,
timeoutMs?: number,
signal?: AbortSignal
) => Promise<T>
export interface BillingApi {
charge: (amountUsd: string, idempotencyKey?: string) => Promise<BillingChargeResult>
chargeStatus: (chargeId: string) => Promise<BillingResult<BillingChargeStatusResponse>>
fetchBillingState: () => Promise<BillingResult<BillingStateResponse>>
fetchSubscriptionState: () => Promise<BillingResult<SubscriptionStateResponse>>
stepUp: (sessionId?: string) => Promise<BillingResult<BillingMutationResponse>>
updateAutoReload: (input: UpdateAutoReloadInput) => Promise<BillingResult<BillingMutationResponse>>
}
interface RefusalRecord {
actor?: unknown
code?: unknown
error?: unknown
kind?: unknown
message?: unknown
payload?: unknown
portal_url?: unknown
recovery?: unknown
retry_after?: unknown
}
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null
const asOptionalString = (value: unknown): string | undefined =>
typeof value === 'string' && value.length > 0 ? value : undefined
const asOptionalNumber = (value: unknown): number | undefined => (typeof value === 'number' ? value : undefined)
const asPayload = (value: unknown): BillingErrorPayload | undefined =>
isRecord(value) ? (value as BillingErrorPayload) : undefined
const getMessage = (value: unknown): string => {
if (value instanceof Error && value.message) {
return value.message
}
if (typeof value === 'string' && value.length > 0) {
return value
}
return String(value || 'Billing request failed.')
}
const normalizeRefusal = (raw: Record<string, unknown>): BillingRefusal => {
const rawError = raw.error
const error = isRecord(rawError) ? (rawError as RefusalRecord) : undefined
const kind = asOptionalString(error?.kind) ?? asOptionalString(error?.error) ?? asOptionalString(rawError) ?? 'error'
const message = asOptionalString(error?.message) ?? asOptionalString(raw.message) ?? kind
return {
actor: asOptionalString(error?.actor) ?? asOptionalString(raw.actor),
code: asOptionalString(error?.code) ?? asOptionalString(raw.code),
kind,
message,
payload: asPayload(error?.payload) ?? asPayload(raw.payload),
portalUrl: asOptionalString(error?.portal_url) ?? asOptionalString(raw.portal_url),
raw,
recovery: asOptionalString(error?.recovery) ?? asOptionalString(raw.recovery),
retryAfter: asOptionalNumber(error?.retry_after) ?? asOptionalNumber(raw.retry_after)
}
}
const normalizeThrown = (error: unknown): BillingRefusal => {
const message = getMessage(error)
const name = error instanceof Error ? error.name : ''
return {
kind: name === 'TimeoutError' || /timed?\s*out|timeout/i.test(message) ? 'timeout' : 'transport',
message,
raw: error
}
}
const normalizeRpcResult = <T>(response: T): BillingResult<T> => {
if (isRecord(response) && response.ok === false) {
return { ok: false, refusal: normalizeRefusal(response) }
}
return { data: response, ok: true }
}
const callBilling = async <T>(
requestGateway: BillingRequestGateway,
method: string,
params: Record<string, unknown> = {}
): Promise<BillingResult<T>> => {
try {
return normalizeRpcResult(await requestGateway<T>(method, params))
} catch (error) {
return { ok: false, refusal: normalizeThrown(error) }
}
}
export const createBillingApi = (requestGateway: BillingRequestGateway): BillingApi => ({
charge: async (amountUsd, idempotencyKey = crypto.randomUUID()) => {
const result = await callBilling<BillingChargeResponse>(requestGateway, 'billing.charge', {
amount_usd: amountUsd,
idempotency_key: idempotencyKey
})
return { ...result, idempotencyKey }
},
chargeStatus: chargeId =>
callBilling<BillingChargeStatusResponse>(requestGateway, 'billing.charge_status', { charge_id: chargeId }),
fetchBillingState: () => callBilling<BillingStateResponse>(requestGateway, 'billing.state'),
fetchSubscriptionState: () => callBilling<SubscriptionStateResponse>(requestGateway, 'subscription.state'),
stepUp: sessionId =>
callBilling<BillingMutationResponse>(requestGateway, 'billing.step_up', {
...(sessionId !== undefined ? { session_id: sessionId } : {})
}),
updateAutoReload: input =>
callBilling<BillingMutationResponse>(requestGateway, 'billing.auto_reload', {
enabled: input.enabled,
...(input.threshold_usd !== undefined ? { threshold: input.threshold_usd } : {}),
...(input.reload_to_usd !== undefined ? { top_up_amount: input.reload_to_usd } : {})
})
})
export function useBillingApi(): BillingApi {
const { requestGateway } = useGatewayRequest()
return useMemo(() => createBillingApi(requestGateway), [requestGateway])
}
@@ -0,0 +1,315 @@
import type { BillingResult } from './api'
import type { BillingStateResponse, SubscriptionStateResponse } from './types'
const current = (
overrides: Partial<NonNullable<SubscriptionStateResponse['current']>> = {}
): NonNullable<SubscriptionStateResponse['current']> => ({
cancel_at_period_end: false,
cancellation_effective_at: null,
cancellation_effective_display: null,
credits_remaining: '120',
cycle_ends_at: '2026-07-11T08:14:55.000Z',
monthly_credits: '220',
pending_downgrade_at: null,
pending_downgrade_display: null,
pending_downgrade_tier_name: null,
tier_id: 'ultra',
tier_name: 'Ultra',
...overrides
})
export const todayBillingState = {
auto_reload: {
card: { kind: 'canonical' },
enabled: true,
reload_to_display: '$10',
reload_to_usd: '10',
threshold_display: '$5',
threshold_usd: '5'
},
balance_display: '$996.47',
balance_usd: '996.47',
can_charge: false,
card: {
brand: 'visa',
last4: '3206',
masked: 'visa ....3206'
},
charge_presets: ['100', '250', '500'],
charge_presets_display: ['$100', '$250', '$500'],
cli_billing_enabled: false,
is_admin: true,
logged_in: true,
max_usd: '1000',
min_usd: '10',
monthly_cap: {
is_default_ceiling: true,
limit_display: '$100',
limit_usd: '100',
spent_display: '$10',
spent_this_month_usd: '10'
},
ok: true,
org_name: 'sid-5',
portal_url: 'https://portal.nousresearch.com/billing',
role: 'OWNER',
usage: {
available: true,
has_topup: true,
plan_name: 'Ultra',
renews_at: '2026-07-11T08:14:55.000Z',
renews_display: 'Jul 11',
status: 'active',
subscription_remaining_display: '$120',
topup_remaining_display: '$876.47',
total_spendable_display: '$996.47'
}
} satisfies BillingStateResponse
export const todaySubscriptionState = {
can_change_plan: true,
context: 'team',
current: current(),
is_admin: true,
logged_in: true,
ok: true,
org_id: 'sid-5',
org_name: 'sid-5',
portal_url: 'https://portal.nousresearch.com/billing',
role: 'OWNER',
tiers: [
{
dollars_per_month_display: '$200',
is_current: true,
is_enabled: true,
monthly_credits: '220',
name: 'Ultra',
tier_id: 'ultra',
tier_order: 3
}
],
usage: todayBillingState.usage
} satisfies SubscriptionStateResponse
export const postTrainBillingState = {
...todayBillingState,
auto_reload: {
card: { kind: 'canonical' },
enabled: false,
reload_to_display: '$100',
reload_to_usd: '100',
threshold_display: '$25',
threshold_usd: '25'
},
balance_display: '$142.50',
balance_usd: '142.50',
can_charge: true,
card: {
brand: 'visa',
display: 'Visa ....4242 - the card on your subscription',
last4: '4242',
masked: 'visa ....4242',
resolved_via: 'subPin'
},
charge_presets: ['25', '50', '100'],
charge_presets_display: ['$25', '$50', '$100'],
cli_billing_enabled: true,
monthly_cap: {
is_default_ceiling: false,
limit_display: '$1,000',
limit_usd: '1000',
spent_display: '$180',
spent_this_month_usd: '180'
},
org_name: 'Acme Research',
usage: {
available: true,
has_topup: true,
plan_bar: {
fill_fraction: 0.4,
kind: 'plan',
pct_used: 60,
remaining_display: '$40',
spent_display: '$60',
total_display: '$100'
},
plan_name: 'Pro',
renews_at: '2026-07-31T00:00:00Z',
renews_display: 'Jul 31',
status: 'active',
subscription_remaining_display: '$40',
topup_bar: {
fill_fraction: 0.75,
kind: 'topup',
pct_used: 25,
remaining_display: '$75',
spent_display: '$25',
total_display: '$100'
},
topup_remaining_display: '$75',
total_spendable_display: '$115'
}
} satisfies BillingStateResponse
export const postTrainSubscriptionState = {
...todaySubscriptionState,
current: current({
credits_remaining: '40',
cycle_ends_at: '2026-07-31T00:00:00Z',
monthly_credits: '100',
tier_id: 'pro',
tier_name: 'Pro'
}),
org_id: 'org_123',
org_name: 'Acme Research',
tiers: [
{
dollars_per_month_display: '$20',
is_current: true,
is_enabled: true,
monthly_credits: '100',
name: 'Pro',
tier_id: 'pro',
tier_order: 2
}
],
usage: postTrainBillingState.usage
} satisfies SubscriptionStateResponse
export const loggedOutBillingState = {
...todayBillingState,
auto_reload: null,
balance_display: '$0.00',
balance_usd: null,
can_charge: false,
card: null,
charge_presets: [],
charge_presets_display: [],
logged_in: false,
monthly_cap: null,
org_name: null,
portal_url: 'https://portal.nousresearch.com/login',
role: null,
usage: undefined
} satisfies BillingStateResponse
export const loggedOutSubscriptionState = {
...todaySubscriptionState,
can_change_plan: false,
current: null,
is_admin: false,
logged_in: false,
org_id: null,
org_name: null,
portal_url: 'https://portal.nousresearch.com/login',
role: null,
tiers: [],
usage: undefined
} satisfies SubscriptionStateResponse
const okBilling = (data: BillingStateResponse): BillingResult<BillingStateResponse> => ({ data, ok: true })
const okSubscription = (data: SubscriptionStateResponse): BillingResult<SubscriptionStateResponse> => ({
data,
ok: true
})
function withUsage(
name: string,
{
autoReload = postTrainBillingState.auto_reload,
canCharge = true,
card = postTrainBillingState.card,
cliBillingEnabled = true,
monthlyCapSpent = '89',
remaining,
subscriptionCurrent = current({ credits_remaining: remaining, monthly_credits: '220' })
}: {
autoReload?: BillingStateResponse['auto_reload']
canCharge?: boolean
card?: BillingStateResponse['card']
cliBillingEnabled?: boolean
monthlyCapSpent?: string
remaining: string
subscriptionCurrent?: SubscriptionStateResponse['current']
}
) {
const billing = {
...postTrainBillingState,
auto_reload: autoReload,
balance_display: '$142.50',
balance_usd: '142.50',
can_charge: canCharge,
card,
cli_billing_enabled: cliBillingEnabled,
monthly_cap: {
is_default_ceiling: false,
limit_display: '$100',
limit_usd: '100',
spent_display: `$${monthlyCapSpent}`,
spent_this_month_usd: monthlyCapSpent
},
org_name: `${name} Fixture`,
usage: {
...postTrainBillingState.usage,
plan_name: 'Ultra',
subscription_remaining_display: `$${remaining}`,
total_spendable_display: '$142.50'
}
} satisfies BillingStateResponse
const subscription = {
...todaySubscriptionState,
current: subscriptionCurrent,
org_name: `${name} Fixture`,
usage: billing.usage
} satisfies SubscriptionStateResponse
return { billing: okBilling(billing), subscription: okSubscription(subscription) }
}
export const billingDevFixtures = {
healthy: withUsage('Healthy', { monthlyCapSpent: '89', remaining: '132' }),
'auto-refill-divergent': withUsage('Auto Refill Divergent', {
autoReload: {
...postTrainBillingState.auto_reload,
card: { kind: 'distinct', payment_method_id: 'pm_divergent_1', brand: 'mastercard', last4: '4444' },
enabled: true
},
remaining: '132'
}),
low: withUsage('Low', { remaining: '19.8' }),
boundary: withUsage('Boundary', { remaining: '22' }),
'empty-overdrawn': withUsage('Empty Overdrawn', { remaining: '-0.79' }),
'cap-near': withUsage('Cap Near', { monthlyCapSpent: '92', remaining: '132' }),
'cap-hit': withUsage('Cap Hit', { monthlyCapSpent: '100', remaining: '132' }),
'no-card': withUsage('No Card', { card: null, remaining: '132' }),
'no-subscription': withUsage('No Subscription', { remaining: '132', subscriptionCurrent: null }),
'logged-out': {
billing: okBilling(loggedOutBillingState),
subscription: okSubscription(loggedOutSubscriptionState)
},
refusal: {
billing: {
ok: false,
refusal: {
kind: 'temporarily_unavailable',
message: 'Billing is temporarily unavailable.',
retryAfter: 90
}
},
subscription: okSubscription(todaySubscriptionState)
},
'billing-off': {
billing: okBilling(todayBillingState),
subscription: okSubscription(todaySubscriptionState)
}
} satisfies Record<
string,
{
billing: BillingResult<BillingStateResponse>
subscription: BillingResult<SubscriptionStateResponse>
}
>
export type BillingDevFixtureName = keyof typeof billingDevFixtures
@@ -0,0 +1,84 @@
import type { KnownBillingRefusalCode } from '@hermes/shared/billing'
import { describe, expect, it } from 'vitest'
import type { BillingRefusal } from './api'
import { resolveRefusal } from './errors'
const expectedActions: Record<
KnownBillingRefusalCode | 'timeout' | 'transport',
'none' | 'portal' | 'retry' | 'step_up'
> = {
auto_top_up_disabled_failures: 'none',
cli_billing_disabled: 'portal',
consent_required: 'portal',
endpoint_unavailable: 'retry',
idempotency_conflict: 'none',
idempotency_key_required: 'none',
insufficient_scope: 'step_up',
internal_error: 'none',
invalid_charge_id: 'none',
invalid_request: 'none',
monthly_cap_exceeded: 'portal',
network_error: 'none',
no_payment_method: 'portal',
org_access_denied: 'none',
preview_rejected: 'none',
rate_limited: 'retry',
remote_spending_disabled: 'portal',
remote_spending_revoked: 'portal',
role_required: 'portal',
session_revoked: 'portal',
stripe_unavailable: 'retry',
temporarily_unavailable: 'retry',
timeout: 'retry',
transport: 'retry',
upgrade_cap_exceeded: 'none',
validation_failed: 'none'
}
describe('resolveRefusal', () => {
it('maps every known refusal kind to copy and the expected action', () => {
for (const [kind, actionType] of Object.entries(expectedActions)) {
const resolved = resolveRefusal({
kind: kind as BillingRefusal['kind'],
message: 'Server message.',
portalUrl: 'https://portal.nousresearch.com/billing',
retryAfter: 90
})
expect(resolved.title, kind).not.toHaveLength(0)
expect(resolved.message, kind).not.toHaveLength(0)
expect(resolved.action.type, kind).toBe(actionType)
}
})
it('includes monthly cap headroom when the server sends it', () => {
const resolved = resolveRefusal({
kind: 'monthly_cap_exceeded',
message: 'Monthly spend cap reached.',
payload: { remainingUsd: '4.50' }
})
expect(resolved.message).toContain('$4.50 headroom left')
})
it('includes Stripe retry timing when the server sends it', () => {
const resolved = resolveRefusal({
kind: 'stripe_unavailable',
message: 'Stripe is unavailable.',
retryAfter: 120
})
expect(resolved.message).toContain('try again in ~2 min')
})
it('falls back sanely for unknown refusal kinds', () => {
const resolved = resolveRefusal({ kind: 'new_billing_code', message: 'Something changed upstream.' })
expect(resolved).toEqual({
action: { type: 'none' },
message: 'Something changed upstream.',
title: 'Billing request failed'
})
})
})
@@ -0,0 +1,162 @@
import type { BillingRefusal } from './api'
export interface BillingRefusalPresentation {
action: { type: 'none' } | { type: 'portal'; url?: string } | { type: 'retry' } | { type: 'step_up' }
message: string
title: string
}
const portalAction = (url?: string): BillingRefusalPresentation['action'] => ({ type: 'portal', url })
const retryMessage = (refusal: BillingRefusal): string => {
const mins = refusal.retryAfter ? ` (try again in ~${Math.max(1, Math.round(refusal.retryAfter / 60))} min)` : ''
return `🟡 Too many charges right now${mins}. This isn't a payment failure.`
}
const stripeRetryMessage = (refusal: BillingRefusal): string => {
const mins = refusal.retryAfter ? ` (try again in ~${Math.max(1, Math.round(refusal.retryAfter / 60))} min)` : ''
return `Stripe is having trouble — try again shortly${mins}`
}
export const resolveRefusal = (refusal: BillingRefusal): BillingRefusalPresentation => {
switch (refusal.kind) {
case 'consent_required':
return {
action: portalAction(refusal.portalUrl),
message: 'Confirm this card for terminal charges in the portal',
title: 'Card confirmation needed'
}
case 'insufficient_scope':
return {
action: { type: 'step_up' },
message: 'This needs terminal billing enabled. Start a top-up to enable it, then retry.',
title: 'Terminal billing needs approval'
}
case 'remote_spending_revoked': {
const who =
refusal.actor === 'admin'
? 'An admin turned off terminal billing for this terminal.'
: 'You turned off terminal billing for this terminal.'
return {
action: portalAction(refusal.portalUrl),
message: `${who} Reconnect from Settings → Gateway to re-authorize this device.`,
title: 'Terminal billing was turned off'
}
}
case 'session_revoked':
return {
action: portalAction(refusal.portalUrl),
message: 'Your session was logged out. Sign in again from Settings → Gateway.',
title: 'Session logged out'
}
case 'cli_billing_disabled':
case 'remote_spending_disabled':
return {
action: portalAction(refusal.portalUrl),
message: 'Terminal billing is off for this account — an admin must enable it on the portal.',
title: 'Terminal billing is off'
}
case 'role_required':
return {
action: portalAction(refusal.portalUrl),
message: 'Adding funds needs an org admin/owner. Ask an admin, or manage on the portal.',
title: 'Admin role required'
}
case 'idempotency_conflict':
return {
action: { type: 'none' },
message: '🔴 That charge key was already used for a different amount. Start a fresh top-up.',
title: 'Start a fresh top-up'
}
case 'no_payment_method':
return {
action: portalAction(refusal.portalUrl),
message:
'💳 No saved card for terminal charges yet. Set one up on the portal ' +
"(one-time credit buys don't save a reusable card).",
title: 'No saved card'
}
case 'org_access_denied':
return {
action: { type: 'none' },
message: "This token isn't bound to an org you can manage",
title: 'Org access denied'
}
case 'monthly_cap_exceeded': {
const remaining = refusal.payload?.remainingUsd
return {
action: portalAction(refusal.portalUrl),
message:
remaining != null
? `🔴 Monthly spend cap reached — $${remaining} headroom left.`
: '🔴 Monthly spend cap reached.',
title: 'Monthly spend cap reached'
}
}
case 'rate_limited':
case 'temporarily_unavailable':
return {
action: { type: 'retry' },
message: retryMessage(refusal),
title: 'Too many charges right now'
}
case 'stripe_unavailable':
return {
action: { type: 'retry' },
message: stripeRetryMessage(refusal),
title: 'Stripe is having trouble'
}
case 'upgrade_cap_exceeded':
return {
action: { type: 'none' },
message: 'Daily plan-change limit reached — try again tomorrow',
title: 'Daily plan-change limit reached'
}
case 'endpoint_unavailable':
return {
action: { type: 'retry' },
message:
refusal.message ||
'Billing endpoint returned a non-JSON response (it may not be available on this deployment).',
title: 'Billing endpoint unavailable'
}
case 'timeout':
return {
action: { type: 'retry' },
message: refusal.message || 'Billing request timed out.',
title: 'Billing request timed out'
}
case 'transport':
return {
action: { type: 'retry' },
message: refusal.message || 'Billing request failed before reaching the gateway.',
title: 'Billing connection failed'
}
default:
return {
action: { type: 'none' },
message: refusal.message || 'Billing request failed.',
title: 'Billing request failed'
}
}
}
@@ -0,0 +1,35 @@
import type { BillingResult } from './api'
import type { BillingStateResponse, SubscriptionStateResponse } from './types'
export {
billingDevFixtures,
loggedOutBillingState,
loggedOutSubscriptionState,
postTrainBillingState,
postTrainSubscriptionState,
todayBillingState,
todaySubscriptionState
} from './dev-fixtures'
export const okBilling = (data: BillingStateResponse): BillingResult<BillingStateResponse> => ({ data, ok: true })
export const okSubscription = (data: SubscriptionStateResponse): BillingResult<SubscriptionStateResponse> => ({
data,
ok: true
})
export const endpointUnavailableBilling = {
ok: false,
refusal: {
kind: 'endpoint_unavailable',
message: 'Billing endpoint returned a non-JSON response.'
}
} satisfies BillingResult<BillingStateResponse>
export const endpointUnavailableSubscription = {
ok: false,
refusal: {
kind: 'endpoint_unavailable',
message: 'Subscription endpoint is not available.'
}
} satisfies BillingResult<SubscriptionStateResponse>
@@ -0,0 +1,380 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
billingDevFixtures,
loggedOutBillingState,
loggedOutSubscriptionState,
okBilling,
okSubscription,
postTrainBillingState,
postTrainSubscriptionState,
todayBillingState,
todaySubscriptionState
} from './fixtures.test-util'
import { formatUsageUpdatedAgo } from './use-billing-state'
import { BillingSettings } from './index'
const apiMocks = vi.hoisted(() => ({
charge: vi.fn(),
chargeStatus: vi.fn(),
fetchBillingState: vi.fn(),
fetchSubscriptionState: vi.fn(),
openExternal: vi.fn(),
stepUp: vi.fn(),
updateAutoReload: vi.fn()
}))
vi.mock('./api', () => ({
useBillingApi: () => ({
charge: apiMocks.charge,
chargeStatus: apiMocks.chargeStatus,
fetchBillingState: apiMocks.fetchBillingState,
fetchSubscriptionState: apiMocks.fetchSubscriptionState,
stepUp: apiMocks.stepUp,
updateAutoReload: apiMocks.updateAutoReload
})
}))
function renderBilling() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
render(
<QueryClientProvider client={client}>
<BillingSettings />
</QueryClientProvider>
)
return client
}
beforeEach(() => {
apiMocks.fetchBillingState.mockResolvedValue(okBilling(todayBillingState))
apiMocks.fetchSubscriptionState.mockResolvedValue(okSubscription(todaySubscriptionState))
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: {
openExternal: apiMocks.openExternal
}
})
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
describe('BillingSettings', () => {
it('renders the deployed-today payload with buy controls hidden and usage rows visible', async () => {
renderBilling()
expect(await screen.findByText('$996.47')).toBeTruthy()
expect(screen.getByText('Ultra · $200/mo')).toBeTruthy()
expect(screen.getByText('Visa •••• 3206')).toBeTruthy()
expect(
screen.getByText('Terminal billing is off for this account — an admin must enable it on the portal.')
).toBeTruthy()
expect(screen.queryByRole('button', { name: '$100' })).toBeNull()
expect(screen.getByText('Refill $10 when balance falls below $5')).toBeTruthy()
expect(screen.getByText('$120 of $220 left')).toBeTruthy()
expect(screen.getByText('$876.47')).toBeTruthy()
expect(screen.getByText('$10 of $100 used').classList.contains('tabular-nums')).toBe(true)
expect(screen.getByText('Default ceiling')).toBeTruthy()
})
it('renders the post-train payload with enabled buy controls and card provenance', async () => {
apiMocks.fetchBillingState.mockResolvedValue(okBilling(postTrainBillingState))
apiMocks.fetchSubscriptionState.mockResolvedValue(okSubscription(postTrainSubscriptionState))
renderBilling()
expect(await screen.findByText('$142.50')).toBeTruthy()
expect(screen.getByText('Visa •••• 4242 - subscription card')).toBeTruthy()
expect(screen.getByRole('button', { name: '$25' }).hasAttribute('disabled')).toBe(false)
expect(screen.getByRole('button', { name: '$50' }).hasAttribute('disabled')).toBe(false)
expect(screen.getByRole('button', { name: '$100' }).hasAttribute('disabled')).toBe(false)
expect(screen.getByRole('spinbutton', { name: 'Custom credit amount' })).toBeTruthy()
expect(screen.getByRole('button', { name: /^Buy$/ }).hasAttribute('disabled')).toBe(false)
})
it('disables buy controls when no card is on file', async () => {
const fixture = billingDevFixtures['no-card']
apiMocks.fetchBillingState.mockResolvedValue(fixture.billing)
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
renderBilling()
expect(await screen.findByText('No card on file')).toBeTruthy()
expect(screen.getByRole('button', { name: '$25' }).hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('button', { name: '$50' }).hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('button', { name: '$100' }).hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('spinbutton', { name: 'Custom credit amount' }).hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('button', { name: /^Buy$/ }).hasAttribute('disabled')).toBe(true)
fireEvent.click(screen.getByRole('button', { name: /^Buy$/ }))
expect(apiMocks.charge).not.toHaveBeenCalled()
})
it('saves enabled auto-refill edits and refreshes billing state', async () => {
const client = renderBilling()
const invalidate = vi.spyOn(client, 'invalidateQueries')
apiMocks.updateAutoReload.mockResolvedValue({ data: { ok: true }, ok: true })
fireEvent.click(await screen.findByRole('button', { name: 'Manage' }))
fireEvent.change(screen.getByRole('spinbutton', { name: 'Auto-refill threshold' }), {
target: { value: '15' }
})
fireEvent.change(screen.getByRole('spinbutton', { name: 'Auto-refill reload-to amount' }), {
target: { value: '20' }
})
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() =>
expect(apiMocks.updateAutoReload).toHaveBeenCalledWith({
enabled: true,
reload_to_usd: '20',
threshold_usd: '15'
})
)
await waitFor(() => expect(invalidate).toHaveBeenCalledWith({ queryKey: ['billing', 'state'] }))
expect(await screen.findByText('Auto-refill updated.')).toBeTruthy()
})
it('rejects auto-refill amounts outside the billing bounds', async () => {
renderBilling()
fireEvent.click(await screen.findByRole('button', { name: 'Manage' }))
fireEvent.change(screen.getByRole('spinbutton', { name: 'Auto-refill threshold' }), {
target: { value: '7.50' }
})
expect(screen.getByText('Threshold: minimum is $10.')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Save' }).hasAttribute('disabled')).toBe(true)
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(apiMocks.updateAutoReload).not.toHaveBeenCalled()
})
it('requires inline confirmation before disabling auto-refill', async () => {
renderBilling()
apiMocks.updateAutoReload.mockResolvedValue({ data: { ok: true }, ok: true })
fireEvent.click(await screen.findByRole('button', { name: 'Manage' }))
fireEvent.click(screen.getByRole('button', { name: 'Disable' }))
expect(screen.getByText('Turn off auto-refill?')).toBeTruthy()
expect(apiMocks.updateAutoReload).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Turn off' }))
await waitFor(() => expect(apiMocks.updateAutoReload).toHaveBeenCalledWith({ enabled: false }))
})
it('renders auto-refill mutation refusals and step-up affordance', async () => {
renderBilling()
apiMocks.updateAutoReload.mockResolvedValue({
ok: false,
refusal: {
kind: 'insufficient_scope',
message: 'billing:manage required'
}
})
fireEvent.click(await screen.findByRole('button', { name: 'Manage' }))
fireEvent.change(screen.getByRole('spinbutton', { name: 'Auto-refill threshold' }), {
target: { value: '15' }
})
fireEvent.change(screen.getByRole('spinbutton', { name: 'Auto-refill reload-to amount' }), {
target: { value: '20' }
})
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(await screen.findByText('Terminal billing needs approval:')).toBeTruthy()
expect(
screen.getByText('This needs terminal billing enabled. Start a top-up to enable it, then retry.')
).toBeTruthy()
expect(screen.getByRole('button', { name: 'Verify to continue' })).toBeTruthy()
})
it('keeps disabled auto-refill portal-only with no enable control', async () => {
apiMocks.fetchBillingState.mockResolvedValue(okBilling(postTrainBillingState))
apiMocks.fetchSubscriptionState.mockResolvedValue(okSubscription(postTrainSubscriptionState))
renderBilling()
expect((await screen.findAllByText('Off')).length).toBeGreaterThan(0)
expect(screen.getByText('Turn on auto-refill from the portal')).toBeTruthy()
expect(screen.queryByRole('button', { name: /enable/i })).toBeNull()
expect(screen.queryByRole('button', { name: 'Manage' })).toBeNull()
})
it('disables buy controls while polling and renders the settled outcome', async () => {
let settleStatus: (value: unknown) => void = () => {}
const statusPromise = new Promise(resolve => {
settleStatus = resolve
})
apiMocks.fetchBillingState.mockResolvedValue(okBilling(postTrainBillingState))
apiMocks.fetchSubscriptionState.mockResolvedValue(okSubscription(postTrainSubscriptionState))
apiMocks.charge.mockResolvedValue({
data: {
charge_id: 'ch_123',
ok: true
},
idempotencyKey: 'key-1',
ok: true
})
apiMocks.chargeStatus.mockReturnValue(statusPromise)
renderBilling()
fireEvent.click(await screen.findByRole('button', { name: /^Buy$/ }))
expect(await screen.findByText('Processing… checking settlement')).toBeTruthy()
expect(screen.getByRole('button', { name: '$25' }).hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('button', { name: '$50' }).hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('spinbutton', { name: 'Custom credit amount' }).hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('button', { name: /^Buy$/ }).hasAttribute('disabled')).toBe(true)
settleStatus({
data: {
amount_usd: '25',
ok: true,
status: 'settled'
},
ok: true
})
await waitFor(() => expect(screen.getByText('$25 added. Balance is refreshing.')).toBeTruthy())
})
it('renders logged-out as a connect card without normal account rows', async () => {
apiMocks.fetchBillingState.mockResolvedValue(okBilling(loggedOutBillingState))
apiMocks.fetchSubscriptionState.mockResolvedValue(okSubscription(loggedOutSubscriptionState))
renderBilling()
expect(await screen.findByText('Connect your Nous account')).toBeTruthy()
expect(screen.getByText('Run /portal in the TUI or open the Nous portal to connect your account.')).toBeTruthy()
expect(screen.queryByText('Payment method')).toBeNull()
expect(screen.queryByText('Usage')).toBeNull()
})
it('renders danger value text for overdrawn subscription credits', async () => {
const fixture = billingDevFixtures['empty-overdrawn']
apiMocks.fetchBillingState.mockResolvedValue(fixture.billing)
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
renderBilling()
expect((await screen.findByText('$0 of $220 left · $0.79 over')).classList.contains('text-destructive')).toBe(true)
const subscriptionTrack = screen.getByRole('progressbar', { name: 'Subscription credits remaining' })
expect(subscriptionTrack.classList.contains('dither')).toBe(true)
expect(subscriptionTrack.classList.contains('text-destructive/60')).toBe(true)
expect(subscriptionTrack.classList.contains('bg-destructive/10')).toBe(true)
})
it('renders an empty neutral usage track when a row has no bar data', async () => {
const fixture = billingDevFixtures['no-subscription']
apiMocks.fetchBillingState.mockResolvedValue(
okBilling({
...todayBillingState,
monthly_cap: {
...todayBillingState.monthly_cap,
spent_display: '$0',
spent_this_month_usd: '0'
}
})
)
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
renderBilling()
await screen.findByText('Subscription credits')
const subscriptionTrack = screen.getByRole('progressbar', { name: 'Subscription credits usage' })
expect(subscriptionTrack.getAttribute('aria-valuenow')).toBe('0')
expect(subscriptionTrack.classList.contains('text-destructive')).toBe(false)
expect(subscriptionTrack.classList.contains('dither')).toBe(true)
const monthlyCapTrack = screen.getByRole('progressbar', { name: 'Monthly spend cap used' })
expect(monthlyCapTrack.getAttribute('aria-valuenow')).toBe('0')
expect(monthlyCapTrack.classList.contains('dither')).toBe(true)
expect(monthlyCapTrack.classList.contains('bg-(--ui-bg-elevated)')).toBe(true)
})
it('refreshes both billing queries from the usage refresh button', async () => {
renderBilling()
await screen.findByText('$120 of $220 left')
expect(apiMocks.fetchBillingState).toHaveBeenCalledTimes(1)
expect(apiMocks.fetchSubscriptionState).toHaveBeenCalledTimes(1)
fireEvent.click(screen.getByRole('button', { name: 'Refresh' }))
await waitFor(() => expect(apiMocks.fetchBillingState).toHaveBeenCalledTimes(2))
expect(apiMocks.fetchSubscriptionState).toHaveBeenCalledTimes(2)
})
it('disables the usage refresh button while either query is fetching', async () => {
let settleBilling: (value: unknown) => void = () => {}
let settleSubscription: (value: unknown) => void = () => {}
apiMocks.fetchBillingState.mockResolvedValueOnce(okBilling(todayBillingState)).mockReturnValueOnce(
new Promise(resolve => {
settleBilling = resolve
})
)
apiMocks.fetchSubscriptionState.mockResolvedValueOnce(okSubscription(todaySubscriptionState)).mockReturnValueOnce(
new Promise(resolve => {
settleSubscription = resolve
})
)
renderBilling()
const refresh = await screen.findByRole('button', { name: 'Refresh' })
fireEvent.click(refresh)
await waitFor(() => expect(refresh.hasAttribute('disabled')).toBe(true))
settleBilling(okBilling(todayBillingState))
settleSubscription(okSubscription(todaySubscriptionState))
await waitFor(() => expect(refresh.hasAttribute('disabled')).toBe(false))
})
})
describe('formatUsageUpdatedAgo', () => {
it('formats sub-second and current timestamps as just now', () => {
expect(formatUsageUpdatedAgo(1_000, 1_000)).toBe('just now')
expect(formatUsageUpdatedAgo(1_500, 1_000)).toBe('just now')
})
it('formats seconds below a minute', () => {
expect(formatUsageUpdatedAgo(1_000, 60_000)).toBe('59s ago')
})
it('rounds elapsed time to whole minutes from 61 seconds', () => {
expect(formatUsageUpdatedAgo(1_000, 62_000)).toBe('1m ago')
})
it('formats one hour and later as hours', () => {
expect(formatUsageUpdatedAgo(1_000, 3_601_000)).toBe('1h ago')
})
})
@@ -0,0 +1,961 @@
import { useQueryClient } from '@tanstack/react-query'
import { useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Tip } from '@/components/ui/tooltip'
import { BarChart3, ExternalLink, RefreshCw } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { ListRow, Pill, SectionHeading, SettingsContent } from '../primitives'
import type { BillingRefusal } from './api'
import { useBillingApi } from './api'
import { type BillingDevFixtureName, billingDevFixtures } from './dev-fixtures'
import { resolveRefusal } from './errors'
import type { BillingAutoReload, BillingStateResponse } from './types'
import {
type BillingAccountRowView,
type BillingNoticeView,
type BillingUsageRowView,
deriveBillingView,
EMPTY_BILLING_VALUE,
formatUsageUpdatedAgo,
useBillingState,
useSubscriptionState
} from './use-billing-state'
import { useChargeFlow } from './use-charge-poller'
import { useStepUpFlow } from './use-step-up'
const FEATURE_BILLING_INVOICES = false
const BILLING_DEV_FIXTURE_NAMES = import.meta.env.DEV
? (Object.keys(billingDevFixtures) as BillingDevFixtureName[])
: []
type BillingFixtureSelection = 'live' | BillingDevFixtureName
function openExternal(url?: string) {
if (!url) {
return
}
void window.hermesDesktop?.openExternal?.(url)
}
function SummaryCard({ label, value, tone }: { label: string; tone?: 'muted' | 'primary'; value: string }) {
return (
<div className="min-w-0">
<div className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">{label}</div>
<div
className={cn(
'mt-1 min-w-0 truncate text-lg font-semibold tabular-nums',
tone === 'primary' ? 'text-(--ui-green)' : tone === 'muted' ? 'text-(--ui-text-tertiary)' : 'text-foreground'
)}
>
{value}
</div>
</div>
)
}
function NoticeCard({ notice }: { notice: BillingNoticeView }) {
return (
<div className="mb-5 rounded-lg border border-border/70 bg-muted/20 p-4">
<div className="text-[length:var(--conversation-text-font-size)] font-medium text-foreground">{notice.title}</div>
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{notice.message}
</div>
{notice.action && (
<Button
className="mt-3"
onClick={() => openExternal(notice.action?.url)}
size="sm"
type="button"
variant="outline"
>
{notice.action.label}
<ExternalLink className="size-3.5" />
</Button>
)}
</div>
)
}
function RowValue({ onAction, row }: { onAction?: () => void; row: BillingAccountRowView }) {
return (
<div className="flex min-w-0 flex-wrap items-center justify-start gap-2 @2xl:justify-end">
{row.value && (
<span className="min-w-0 truncate text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
{row.value}
</span>
)}
{row.pill && <Pill tone={row.pill.tone}>{row.pill.label}</Pill>}
{row.secondaryPill && <Pill>{row.secondaryPill}</Pill>}
{row.chips?.map(chip => (
<Button disabled={chip.disabled} key={chip.label} size="sm" type="button" variant="outline">
{chip.label}
</Button>
))}
{row.action && (
<Button
disabled={row.action.disabled}
onClick={row.action.disabled ? undefined : onAction ? onAction : () => openExternal(row.action?.url)}
size="sm"
type="button"
variant="outline"
>
{row.action.label}
{!row.action.disabled && row.action.url && <ExternalLink className="size-3.5" />}
</Button>
)}
</div>
)
}
function AccountRow({ billing, row }: { billing?: BillingStateResponse; row: BillingAccountRowView }) {
if (row.id === 'buy_credits' && row.action && row.chips && billing?.can_charge && billing.cli_billing_enabled) {
return <BuyCreditsRow billing={billing} row={row} />
}
if (row.id === 'auto_reload' && billing?.auto_reload) {
return <AutoReloadRow autoReload={billing.auto_reload} bounds={billing} row={row} />
}
return (
<ListRow
action={<RowValue row={row} />}
below={
row.caption ? (
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{row.caption}
</div>
) : undefined
}
description={row.description}
key={row.id}
title={row.title}
/>
)
}
function AutoReloadRow({
autoReload,
bounds,
row
}: {
autoReload: BillingAutoReload
bounds: Pick<BillingStateResponse, 'max_usd' | 'min_usd'>
row: BillingAccountRowView
}) {
const api = useBillingApi()
const queryClient = useQueryClient()
const [confirmDisable, setConfirmDisable] = useState(false)
const [editing, setEditing] = useState(false)
const [message, setMessage] = useState<null | { kind: 'error' | 'success'; text: string }>(null)
const [refusal, setRefusal] = useState<BillingRefusal | null>(null)
const [reloadTo, setReloadTo] = useState(
initialAutoReloadAmount(autoReload.reload_to_usd, autoReload.reload_to_display)
)
const [saving, setSaving] = useState(false)
const [threshold, setThreshold] = useState(
initialAutoReloadAmount(autoReload.threshold_usd, autoReload.threshold_display)
)
const validation = validateAutoReloadInputs(threshold, reloadTo, bounds)
const busy = saving
const maxBound = bounds.max_usd ?? undefined
const minBound = bounds.min_usd ?? undefined
const resetFeedback = () => {
setConfirmDisable(false)
setMessage(null)
setRefusal(null)
}
const save = async () => {
if (!validation.values || busy) {
return
}
resetFeedback()
setSaving(true)
const result = await api.updateAutoReload({
enabled: true,
reload_to_usd: validation.values.reloadTo,
threshold_usd: validation.values.threshold
})
setSaving(false)
if (!result.ok) {
setRefusal(result.refusal)
return
}
await queryClient.invalidateQueries({ queryKey: ['billing', 'state'] })
setMessage({ kind: 'success', text: 'Auto-refill updated.' })
setEditing(false)
}
const disable = async () => {
if (busy) {
return
}
resetFeedback()
setSaving(true)
const result = await api.updateAutoReload({ enabled: false })
setSaving(false)
if (!result.ok) {
setRefusal(result.refusal)
return
}
await queryClient.invalidateQueries({ queryKey: ['billing', 'state'] })
setMessage({ kind: 'success', text: 'Auto-refill turned off.' })
setEditing(false)
}
const below = editing ? (
<div className="mt-3 space-y-3">
<div className="grid gap-2 @2xl:grid-cols-2">
<label className="min-w-0 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
Threshold
<Input
aria-label="Auto-refill threshold"
className="mt-1 h-8"
disabled={busy}
inputMode="decimal"
max={maxBound}
min={minBound}
onChange={event => {
resetFeedback()
setThreshold(event.target.value)
}}
step="0.01"
type="number"
value={threshold}
/>
</label>
<label className="min-w-0 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
Reload to
<Input
aria-label="Auto-refill reload-to amount"
className="mt-1 h-8"
disabled={busy}
inputMode="decimal"
max={maxBound}
min={minBound}
onChange={event => {
resetFeedback()
setReloadTo(event.target.value)
}}
step="0.01"
type="number"
value={reloadTo}
/>
</label>
</div>
{validation.error && (
<div className="text-[length:var(--conversation-caption-font-size)] text-destructive">{validation.error}</div>
)}
<div className="flex min-w-0 flex-wrap items-center gap-2">
<Button disabled={busy || !validation.values} onClick={() => void save()} size="sm" type="button">
{busy ? 'Saving…' : 'Save'}
</Button>
<Button disabled={busy} onClick={() => setConfirmDisable(true)} size="sm" type="button" variant="outline">
Disable
</Button>
<Button
disabled={busy}
onClick={() => {
resetFeedback()
setEditing(false)
}}
size="sm"
type="button"
variant="outline"
>
Cancel
</Button>
</div>
{confirmDisable && (
<div className="flex min-w-0 flex-wrap items-center gap-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<span>Turn off auto-refill?</span>
<Button disabled={busy} onClick={() => void disable()} size="sm" type="button" variant="outline">
Turn off
</Button>
<Button disabled={busy} onClick={() => setConfirmDisable(false)} size="sm" type="button" variant="ghost">
Cancel
</Button>
</div>
)}
<BillingRefusalInline refusal={refusal} />
{message && <InlineMessage kind={message.kind}>{message.text}</InlineMessage>}
</div>
) : (
<>
{row.caption ? (
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{row.caption}
</div>
) : null}
<BillingRefusalInline refusal={refusal} />
{message && <InlineMessage kind={message.kind}>{message.text}</InlineMessage>}
</>
)
return (
<ListRow
action={
<RowValue
onAction={
row.action?.url
? undefined
: () => {
resetFeedback()
setEditing(true)
}
}
row={row}
/>
}
below={below}
description={row.description}
key={row.id}
title={row.title}
/>
)
}
function BuyCreditsRow({ billing, row }: { billing: BillingStateResponse; row: BillingAccountRowView }) {
const presets = useMemo(
() =>
billing.charge_presets.map((amount, index) => ({
amount,
label: billing.charge_presets_display[index] || formatMoney(amount)
})),
[billing.charge_presets, billing.charge_presets_display]
)
const initialAmount = presets[0]?.amount ?? billing.min_usd ?? ''
const [amount, setAmount] = useState(initialAmount)
const flow = useChargeFlow()
const busy = flow.phase === 'charging' || flow.phase === 'polling'
const controlsDisabled = busy || !billing.card
const clampedAmount = clampAmount(amount, billing)
const canBuy = !controlsDisabled && clampedAmount !== ''
const startBuy = () => {
if (!canBuy) {
return
}
setAmount(clampedAmount)
void flow.start(clampedAmount)
}
return (
<ListRow
action={
<div className="flex min-w-0 flex-wrap items-center justify-start gap-2 @2xl:justify-end">
{presets.map(preset => (
<Button
aria-pressed={amount === preset.amount}
disabled={controlsDisabled}
key={preset.amount}
onClick={() => setAmount(preset.amount)}
size="sm"
type="button"
variant={amount === preset.amount ? 'default' : 'outline'}
>
{preset.label}
</Button>
))}
<Input
aria-label="Custom credit amount"
className="h-8 w-24"
disabled={controlsDisabled}
inputMode="decimal"
max={billing.max_usd ?? undefined}
min={billing.min_usd ?? undefined}
onBlur={() => setAmount(clampedAmount)}
onChange={event => {
flow.reset()
setAmount(event.target.value)
}}
placeholder={billing.min_usd ? formatMoney(billing.min_usd) : '$'}
step="0.01"
type="number"
value={amount}
/>
<Button disabled={!canBuy} onClick={startBuy} size="sm" type="button" variant="outline">
Buy
</Button>
</div>
}
below={
<BuyCreditsOutcome
amount={clampedAmount}
busy={busy}
onPortal={openExternal}
onRetry={() => {
if (!clampedAmount) {
return
}
void flow.start(clampedAmount)
}}
outcome={flow.outcome}
/>
}
description={row.description}
key={row.id}
title={row.title}
/>
)
}
function BuyCreditsOutcome({
amount,
busy,
onPortal,
onRetry,
outcome
}: {
amount: string
busy: boolean
onPortal: (url?: string) => void
onRetry: () => void
outcome: ReturnType<typeof useChargeFlow>['outcome']
}) {
const stepUp = useStepUpFlow()
if (busy) {
return (
<div className="mt-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
Processing checking settlement
</div>
)
}
if (!outcome) {
return null
}
if (outcome.kind === 'success') {
return (
<div className="mt-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{formatMoney(outcome.amountUsd ?? amount)} added. Balance is refreshing.
</div>
)
}
if (outcome.kind === 'ambiguous') {
return (
<div className="mt-2 flex min-w-0 flex-wrap items-center gap-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<span>
{outcome.title}: {outcome.message}
</span>
{outcome.portalUrl && (
<Button onClick={() => onPortal(outcome.portalUrl)} size="sm" type="button" variant="outline">
Open portal
<ExternalLink className="size-3.5" />
</Button>
)}
</div>
)
}
const portalUrl = outcome.action?.type === 'portal' ? outcome.action.url : undefined
return (
<div className="mt-2 flex min-w-0 flex-wrap items-center gap-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<span>
{outcome.title}: {outcome.message}
</span>
{outcome.action?.type === 'retry' && (
<Button onClick={onRetry} size="sm" type="button" variant="outline">
Retry
</Button>
)}
{outcome.action?.type === 'step_up' && <StepUpInlineAction flow={stepUp} />}
{portalUrl && (
<Button onClick={() => onPortal(portalUrl)} size="sm" type="button" variant="outline">
Open portal
<ExternalLink className="size-3.5" />
</Button>
)}
</div>
)
}
function BillingRefusalInline({ refusal }: { refusal: BillingRefusal | null }) {
const stepUp = useStepUpFlow()
if (!refusal) {
return null
}
const resolved = resolveRefusal(refusal)
const portalUrl = resolved.action.type === 'portal' ? resolved.action.url : undefined
return (
<div className="mt-2 flex min-w-0 flex-wrap items-center gap-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<span>
<span className="font-medium text-foreground">{resolved.title}:</span> {resolved.message}
</span>
{resolved.action.type === 'step_up' && <StepUpInlineAction flow={stepUp} />}
{portalUrl && (
<Button onClick={() => openExternal(portalUrl)} size="sm" type="button" variant="outline">
Open portal
<ExternalLink className="size-3.5" />
</Button>
)}
</div>
)
}
function StepUpInlineAction({ flow }: { flow: ReturnType<typeof useStepUpFlow> }) {
if (flow.verification) {
return (
<span className="inline-flex min-w-0 flex-wrap items-center gap-2">
<span className="font-mono text-[0.72rem] font-semibold text-foreground">{flow.verification.code}</span>
<Button onClick={flow.openVerification} size="sm" type="button" variant="outline">
Open verification page
<ExternalLink className="size-3.5" />
</Button>
</span>
)
}
if (flow.message) {
return (
<span className="inline-flex min-w-0 flex-wrap items-center gap-2">
<span>
{flow.message.title}: {flow.message.text}
</span>
<Button onClick={flow.dismiss} size="sm" type="button" variant="outline">
Dismiss
</Button>
</span>
)
}
if (flow.phase === 'waiting') {
return <span>Waiting for verification link</span>
}
return (
<Button onClick={() => void flow.start()} size="sm" type="button" variant="outline">
Verify to continue
</Button>
)
}
function InlineMessage({ children, kind }: { children: string; kind: 'error' | 'success' }) {
return (
<div
className={cn(
'mt-2 text-[length:var(--conversation-caption-font-size)]',
kind === 'error' ? 'text-destructive' : 'text-(--ui-text-tertiary)'
)}
>
{children}
</div>
)
}
function UsageBar({ bar, fallbackLabel }: { bar?: BillingUsageRowView['bar']; fallbackLabel: string }) {
const resolvedBar = bar ?? {
label: `${fallbackLabel} usage`,
state: 'neutral',
tone: 'topup',
value: 0
}
const width = Math.round(resolvedBar.value * 100)
const isEmpty = resolvedBar.value === 0
const showDangerNub = resolvedBar.track === 'danger' && resolvedBar.state === 'danger' && width === 0
return (
<div
aria-label={resolvedBar.label}
aria-valuemax={100}
aria-valuemin={0}
aria-valuenow={width}
className={cn(
// Radius follows the app-wide rounded-full progress-bar idiom.
'relative h-2 w-full overflow-hidden rounded-full',
resolvedBar.track === 'danger'
? 'dither text-destructive/60 bg-destructive/10'
: isEmpty
? 'dither bg-(--ui-bg-elevated)'
: 'bg-muted shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--ui-stroke-secondary)_50%,transparent)]'
)}
role="progressbar"
>
{showDangerNub && <div className="absolute inset-y-0 left-0 z-10 w-2 rounded-full bg-destructive" />}
<div
className={cn(
'relative h-full rounded-full transition-[width] duration-300 ease-out',
resolvedBar.state === 'danger'
? 'bg-destructive'
: resolvedBar.state === 'ok' && (resolvedBar.tone === 'subscription' || resolvedBar.tone === 'topup')
? 'bg-(--ui-green)'
: 'bg-muted-foreground/45'
)}
style={{
minWidth: resolvedBar.value > 0 ? 4 : undefined,
width: `${width}%`
}}
/>
</div>
)
}
function UsageRow({ row }: { row: BillingUsageRowView }) {
return (
<div className="@container">
<div className="grid min-w-0 gap-2 py-3 @2xl:grid-cols-[minmax(0,180px)_minmax(0,1fr)_220px] @2xl:items-center @2xl:gap-4">
<div className="min-w-0">
<div className="text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
{row.title}
</div>
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{row.caption}
</div>
</div>
<div className="min-w-0">
<UsageBar bar={row.bar} fallbackLabel={row.title} />
</div>
<div
className={cn(
'min-w-0 whitespace-nowrap text-[length:var(--conversation-text-font-size)] font-medium tabular-nums @2xl:w-[220px] @2xl:flex-none @2xl:text-right',
row.bar?.state === 'danger' ? 'text-destructive' : 'text-foreground'
)}
>
{row.value}
</div>
</div>
</div>
)
}
function UsageRefreshRow({
fixtureName,
isFetching,
onRefresh,
updatedAt
}: {
fixtureName?: BillingFixtureSelection
isFetching: boolean
onRefresh: () => void
updatedAt: number
}) {
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
const interval = window.setInterval(() => setNow(Date.now()), 30_000)
return () => window.clearInterval(interval)
}, [])
if (fixtureName && fixtureName !== 'live') {
return (
<div className="flex items-center justify-end pt-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
fixture: {fixtureName}
</div>
)
}
return (
<div className="flex min-w-0 items-center justify-end gap-1.5 pt-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<span>Updated {formatUsageUpdatedAgo(updatedAt, now)}</span>
<Tip label="Refresh">
<Button
aria-label="Refresh"
className="size-7 p-0 text-(--ui-text-tertiary)"
disabled={isFetching}
onClick={onRefresh}
size="sm"
type="button"
variant="ghost"
>
<RefreshCw className={cn('size-3.5', isFetching && 'animate-spin')} />
</Button>
</Tip>
</div>
)
}
function BillingFixtureSelect({
onValueChange,
value
}: {
onValueChange: (value: BillingFixtureSelection) => void
value: BillingFixtureSelection
}) {
return (
<Select onValueChange={value => onValueChange(value as BillingFixtureSelection)} value={value}>
<SelectTrigger
aria-label="Billing fixture"
className="h-7 w-32 border-transparent bg-transparent px-1.5 text-xs font-normal text-(--ui-text-tertiary) shadow-none hover:bg-muted/40 focus-visible:ring-0 focus-visible:ring-offset-0 data-[state=open]:bg-muted/40"
size="sm"
>
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="live">live</SelectItem>
{BILLING_DEV_FIXTURE_NAMES.map(name => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
)
}
function BillingHeader({
fixtureName,
onFixtureChange
}: {
fixtureName?: BillingFixtureSelection
onFixtureChange?: (value: BillingFixtureSelection) => void
}) {
return (
<div className="mb-2.5 flex items-center justify-between gap-3 pt-2 text-[length:var(--conversation-text-font-size)] font-medium">
<div className="flex min-w-0 items-center gap-2">
<BarChart3 className="size-4 shrink-0 text-muted-foreground" />
<span>Billing</span>
</div>
{import.meta.env.DEV && fixtureName && onFixtureChange ? (
<BillingFixtureSelect onValueChange={onFixtureChange} value={fixtureName} />
) : null}
</div>
)
}
function BillingSettingsContent({
fixtureName,
onFixtureChange
}: {
fixtureName?: BillingFixtureSelection
onFixtureChange?: (value: BillingFixtureSelection) => void
}) {
const fixture =
import.meta.env.DEV && fixtureName && fixtureName !== 'live' ? billingDevFixtures[fixtureName] : undefined
const billingState = useBillingState(!fixture)
const subscriptionState = useSubscriptionState(!fixture)
const billingResult = fixture?.billing ?? billingState.data
const subscriptionResult = fixture?.subscription ?? subscriptionState.data
const view = deriveBillingView(billingResult, subscriptionResult)
const billing = billingResult?.ok ? billingResult.data : undefined
const usageUpdatedAt = oldestUpdatedAt(billingState.dataUpdatedAt, subscriptionState.dataUpdatedAt)
const usageIsFetching = billingState.isFetching || subscriptionState.isFetching
const refreshUsage = () => {
void Promise.all([billingState.refetch(), subscriptionState.refetch()])
}
return (
<SettingsContent>
<BillingHeader fixtureName={fixtureName} onFixtureChange={onFixtureChange} />
<div className="@container mb-5">
<div className="grid gap-3 rounded-lg border border-border/70 bg-muted/20 p-4 @2xl:grid-cols-3">
{view.summary.map(item => (
<SummaryCard key={item.label} label={item.label} tone={item.tone} value={item.value} />
))}
</div>
</div>
{view.notice && <NoticeCard notice={view.notice} />}
{view.accountRows.length > 0 && (
<>
<SectionHeading icon={BarChart3} title="Account" />
{view.accountRows.map(row => (
<AccountRow billing={billing} key={row.id} row={row} />
))}
</>
)}
{view.usageRows.length > 0 && (
<>
<SectionHeading icon={BarChart3} title="Usage" />
<div className="@container rounded-lg border border-border/70 bg-muted/20 px-4 py-2">
{view.usageRows.map(row => (
<UsageRow key={row.id} row={row} />
))}
<UsageRefreshRow
fixtureName={fixtureName}
isFetching={usageIsFetching}
onRefresh={refreshUsage}
updatedAt={usageUpdatedAt}
/>
</div>
</>
)}
{
// no endpoint yet — NAS capability-board gap
FEATURE_BILLING_INVOICES ? <SectionHeading icon={BarChart3} title="Invoices" /> : null
}
</SettingsContent>
)
}
function BillingSettingsWithDevFixtures() {
const [fixtureName, setFixtureName] = useState<BillingFixtureSelection>('live')
return <BillingSettingsContent fixtureName={fixtureName} onFixtureChange={setFixtureName} />
}
export function BillingSettings() {
if (import.meta.env.DEV) {
return <BillingSettingsWithDevFixtures />
}
return <BillingSettingsContent />
}
function clampAmount(raw: string, billing: Pick<BillingStateResponse, 'max_usd' | 'min_usd'>): string {
const amount = parseAmount(raw)
if (amount == null) {
return ''
}
const min = parseAmount(billing.min_usd)
const max = parseAmount(billing.max_usd)
const clampedMin = min == null ? amount : Math.max(min, amount)
const clamped = max == null ? clampedMin : Math.min(max, clampedMin)
return formatAmountForRequest(clamped)
}
function parseAmount(value?: null | number | string): null | number {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : null
}
if (typeof value !== 'string') {
return null
}
const parsed = Number(value.replace(/[$,\s]/g, ''))
return Number.isFinite(parsed) && parsed > 0 ? parsed : null
}
function formatAmountForRequest(value: number): string {
return Number.isInteger(value) ? String(value) : value.toFixed(2).replace(/0+$/, '').replace(/\.$/, '')
}
function oldestUpdatedAt(...timestamps: number[]): number {
const populated = timestamps.filter(timestamp => timestamp > 0)
return populated.length > 0 ? Math.min(...populated) : Date.now()
}
function initialAutoReloadAmount(...candidates: Array<null | string | undefined>): string {
for (const candidate of candidates) {
const amount = parseAmount(candidate)
if (amount != null) {
return formatAmountForRequest(amount)
}
}
return ''
}
function validateAutoReloadInputs(
thresholdRaw: string,
reloadToRaw: string,
bounds: Pick<BillingStateResponse, 'max_usd' | 'min_usd'>
): { error?: string; values?: { reloadTo: string; threshold: string } } {
const threshold = validateBillingAmount('Threshold', thresholdRaw, bounds)
if (threshold.error || threshold.amount == null) {
return { error: threshold.error }
}
const reloadTo = validateBillingAmount('Reload-to', reloadToRaw, bounds)
if (reloadTo.error || reloadTo.amount == null) {
return { error: reloadTo.error }
}
if (reloadTo.amount <= threshold.amount) {
return { error: 'Reload-to amount must be greater than the threshold.' }
}
return {
values: {
reloadTo: formatAmountForRequest(reloadTo.amount),
threshold: formatAmountForRequest(threshold.amount)
}
}
}
function validateBillingAmount(
label: string,
raw: string,
bounds: Pick<BillingStateResponse, 'max_usd' | 'min_usd'>
): { amount?: number; error?: string } {
const cleaned = raw.trim().replace(/^\$/, '').trim()
if (!cleaned || !/^\d+(\.\d{1,2})?$/.test(cleaned)) {
return { error: `${label}: enter a dollar amount with at most 2 decimal places.` }
}
const amount = Number(cleaned)
if (!(amount > 0)) {
return { error: `${label}: amount must be greater than $0.` }
}
const min = parseAmount(bounds.min_usd)
if (min != null && amount < min) {
return { error: `${label}: minimum is ${formatMoney(min)}.` }
}
const max = parseAmount(bounds.max_usd)
if (max != null && amount > max) {
return { error: `${label}: maximum is ${formatMoney(max)}.` }
}
return { amount }
}
function formatMoney(value?: null | number | string): string {
const amount = parseAmount(value)
if (amount == null) {
return EMPTY_BILLING_VALUE
}
return new Intl.NumberFormat(undefined, {
currency: 'USD',
maximumFractionDigits: amount % 1 === 0 ? 0 : 2,
minimumFractionDigits: amount % 1 === 0 ? 0 : 2,
style: 'currency'
}).format(amount)
}
@@ -0,0 +1,117 @@
import { describe, expect, it } from 'vitest'
import type { BillingStateResponse, SubscriptionStateResponse } from './types'
const fullBillingState = {
auto_reload: {
card: { kind: 'canonical' },
enabled: true,
reload_to_display: '$100',
reload_to_usd: '100',
threshold_display: '$25',
threshold_usd: '25'
},
balance_display: '$142.50',
balance_usd: '142.50',
can_charge: true,
card: {
brand: 'visa',
display: 'Visa ....4242 - the card on your subscription',
last4: '4242',
masked: 'visa ....4242',
resolved_via: 'subPin'
},
charge_presets: ['25', '50', '100'],
charge_presets_display: ['$25', '$50', '$100'],
cli_billing_enabled: true,
is_admin: true,
logged_in: true,
max_usd: '10000',
min_usd: '10',
monthly_cap: {
is_default_ceiling: false,
limit_display: '$1,000',
limit_usd: '1000',
spent_display: '$180',
spent_this_month_usd: '180'
},
ok: true,
org_name: 'Acme Research',
portal_url: 'https://portal.nousresearch.com/billing',
role: 'OWNER',
usage: {
available: true,
has_topup: true,
plan_bar: {
fill_fraction: 0.4,
kind: 'plan',
pct_used: 60,
remaining_display: '$40',
spent_display: '$60',
total_display: '$100'
},
plan_name: 'Pro',
renews_at: '2026-07-31T00:00:00Z',
renews_display: 'Jul 31',
status: 'active',
subscription_remaining_display: '$40',
topup_bar: {
fill_fraction: 0.75,
kind: 'topup',
pct_used: 25,
remaining_display: '$75',
spent_display: '$25',
total_display: '$100'
},
topup_remaining_display: '$75',
total_spendable_display: '$115'
}
} satisfies BillingStateResponse
const deployedTodayBillingState = {
auto_reload: null,
balance_display: '$0.00',
balance_usd: null,
can_charge: false,
card: {
brand: 'mastercard',
last4: '4444',
masked: 'mastercard ....4444'
},
charge_presets: [],
charge_presets_display: [],
cli_billing_enabled: false,
is_admin: true,
logged_in: true,
max_usd: null,
min_usd: null,
monthly_cap: null,
ok: true,
org_name: 'Fresh Deploy',
portal_url: null,
role: 'OWNER'
} satisfies BillingStateResponse
const loggedOutSubscriptionState = {
can_change_plan: false,
context: 'personal',
current: null,
is_admin: false,
logged_in: false,
ok: true,
org_id: null,
org_name: null,
portal_url: 'https://portal.nousresearch.com/login',
role: null,
tiers: []
} satisfies SubscriptionStateResponse
describe('desktop billing wire types', () => {
it('pins realistic billing and subscription RPC payload shapes', () => {
expect(fullBillingState.card?.resolved_via).toBe('subPin')
expect(deployedTodayBillingState.can_charge).toBe(false)
expect(deployedTodayBillingState.cli_billing_enabled).toBe(false)
expect(deployedTodayBillingState.card?.last4).toBe('4444')
expect(loggedOutSubscriptionState.logged_in).toBe(false)
})
})
@@ -0,0 +1,33 @@
import type {
BillingAutoReload,
BillingCardInfo,
BillingChargeResponse,
BillingChargeStatusResponse,
BillingErrorPayload,
BillingMonthlyCap,
BillingMutationResponse,
BillingRefusalCode,
BillingStateResponse,
ChargeFailureReason,
SubscriptionStateResponse,
SubscriptionTierOption,
UsageBarData,
UsageModelData
} from '@hermes/shared/billing'
export type {
BillingAutoReload,
BillingCardInfo,
BillingChargeResponse,
BillingChargeStatusResponse,
BillingErrorPayload,
BillingMonthlyCap,
BillingMutationResponse,
BillingRefusalCode,
BillingStateResponse,
ChargeFailureReason,
SubscriptionStateResponse,
SubscriptionTierOption,
UsageBarData,
UsageModelData
}

Some files were not shown because too many files have changed in this diff Show More