Compare commits

..

193 Commits

Author SHA1 Message Date
teknium1
3042045540 fix(picker): keep max_models=0 distinct from unlimited; lock cap semantics
Follow-up to the cap-removal salvage. The contributor guarded the new
unlimited default with `[:max_models] if max_models else ...`, which conflates
max_models=0 (used by slug-only callers that want an empty model list) with
None (unlimited). Tighten to `is not None` at all five slicing sites in
list_authenticated_providers / list_picker_providers, and add a regression test
asserting the three-way contract: None=full, 0=empty, N=first N.
2026-06-18 13:47:31 -07:00
islam666
9705e7944a fix(picker): remove max_models=50 cap in interactive model pickers
The interactive model pickers (Desktop REST API, TUI model.options, CLI
/model) were hard-capped at max_models=50, which truncated large provider
catalogs like Kilo Gateway (336 models) to just 50 entries. This made
most models undiscoverable via the picker search box.

Changes:
- Change build_models_payload() default from max_models=50 to None (unlimited)
- Change list_authenticated_providers() default from max_models=8 to None
- Change list_picker_providers() default from max_models=8 to None
- Fix all [:max_models] slicing to handle None as 'no limit'
- Remove max_models=50 from 5 interactive picker callers:
  * web_server.py: get_model_options (Desktop /api/model/options)
  * web_server.py: get_recommended_default_model
  * model_switch.py: prewarm_picker_cache_async
  * tui_gateway/server.py: model.options JSON-RPC
  * cli.py: HermesCLI model picker
- Telegram/Discord inline keyboard picker (gateway/slash_commands.py)
  still passes max_models=50 explicitly — unchanged behavior.

The total_models field was already in the response payload and is now
meaningful since models.length == total_models for interactive pickers.

Fixes #48279
2026-06-18 13:47:31 -07:00
alelpoan
4ed2f33994
fix(thread): allow scrolling long user messages in chat history (#48619) 2026-06-18 15:44:27 -05:00
teknium1
0879d5cc8f fix(gateway): preserve original transcript when /compress rotation is skipped
The manual /compress handler called rewrite_transcript() unconditionally on
the session id returned by _compress_context(). When rotation does not occur
(e.g. _session_db unavailable, or the DB split raised), session_id is unchanged
and rewrite_transcript() DELETEs the original messages and replaces them with
only the compressed summary — permanent data loss (#44794, #39704).

Guard the rewrite on actual rotation: only overwrite when _compress_context
produced a new session id. Otherwise leave the original transcript intact and
log a warning.
2026-06-18 13:38:35 -07:00
kyssta-exe
81ff916e57 fix(agent): flush un-persisted messages before session rotation (#47202)
compress_context() rotates the session (end_session -> create_session)
mid-turn when auto-compress triggers, but never called
_flush_messages_to_session_db() first. Messages generated during the
current turn that hadn't been persisted to state.db were silently lost.

The same bug existed in cli.py:new_session() (/new command). Both paths
now flush un-persisted messages before ending the old session.
2026-06-18 13:38:35 -07:00
Siddharth Balyan
73cd8622f9
feat(billing): /billing terminal billing — interactive TUI + CLI client (#45449)
* feat(billing): nous_billing http client + BillingState core (phase 2b)

Phase 2b terminal-billing client foundation:
- hermes_cli/nous_billing.py: typed client for the 4 /api/billing/* endpoints
  (state/charge/poll/auto-top-up). Raises typed errors (BillingScopeRequired,
  BillingRateLimited, BillingAuthError) mapped from the live-verified contract;
  fail-open is the caller's job. Idempotency-Key enforced client-side.
- agent/billing_view.py: surface-agnostic BillingState core + Decimal money
  parsing (server emits decimal strings, not 2dp), fail-open builder,
  idempotency-key gen, custom-amount validation.
- 51 unit tests (decimal parse/format, payload tiering, error->exception
  matrix, fail-open, amount validation).

Plan: docs/plans/2026-06-13-001-phase-2b-terminal-billing-tui-plan.md

* feat(billing): billing:manage scope + lazy step-up re-auth (phase 2b)

- NOUS_BILLING_MANAGE_SCOPE constant.
- nous_token_has_billing_scope(): split-based scope check (no false-positive
  substring match).
- step_up_nous_billing_scope(): re-runs the device flow requesting
  billing:manage, reusing the held credential's portal/inference URLs + client_id
  (so a preview stays a preview), persists like _login_nous but WITHOUT the model
  picker. Returns True iff the minted token carries the scope (False when NAS
  silently downscopes a non-admin / unticked grant).

Lazy step-up (plan D-A): normal login path unchanged; 403 insufficient_scope
from a billing call triggers this. 7 unit tests.

* feat(billing): billing JSON-RPC methods for the TUI (phase 2b)

billing.state / charge / charge_status / auto_reload / step_up in
tui_gateway/server.py. Return STRUCTURED success envelopes (result.ok +
result.error=<code>) rather than JSON-RPC-level errors, so the Ink rpc() promise
always resolves and the TUI branches on the typed billing error code
(insufficient_scope, rate_limited, no_payment_method, …) to render the right
affordance. Money serialized as decimal STRINGS + display strings. charge mints
+ echoes an idempotency_key for retry reuse. 16 unit tests.

* feat(billing): /billing CLI handler + command registry (phase 2b)

- CommandDef("billing", subcommands=buy|auto-reload|limit), added to
  _SLACK_VIA_HERMES_ONLY so it routes via /hermes on Slack (keeps the 50-cap
  parity test green, same as /credits).
- cli.py::_show_billing + screen helpers: all 5 screens (overview, buy→confirm→
  poll, auto-reload, monthly-limit read-only). Reuses _prompt_text_input_modal /
  _prompt_text_input (D-C). Non-interactive (_app is None) renders text + portal
  deep-link, never prompts (R7). Decimal money end-to-end. 2s/5-min cancellable
  poll loop; 429/503 = retry not failure; settled = ledger truth. Lazy step-up on
  403 insufficient_scope. no_payment_method treated as mainline funnel-to-portal.
- 6 CLI tests; 156 command tests (incl. Slack/Telegram parity) green.

* feat(billing): /billing Ink TUI screens + tests (phase 2b)

- ui-tui/src/app/slash/commands/billing.ts: /billing TUI command covering all 5
  screens — overview (text), buy <amt> → ConfirmReq → charge → non-blocking 2s/
  5-min poll loop → settled/failed/timeout branches, auto-reload <below> <to> →
  ConfirmReq → PATCH, limit (read-only). Reuses the existing ConfirmReq overlay
  (D-C) — no bespoke component. Typed-error envelope branching: insufficient_scope
  arms the lazy step-up confirm; no_payment_method/rate_limited/cap funnel to
  portal. Client-side amount validation mirrors the server (bounds + 2dp).
- gatewayTypes.ts: Billing* response interfaces.
- registry.ts: register billingCommands.
- billingCommand.test.ts: 12 vitest cases (overview/gating/buy-confirm-poll-
  settled/no_payment_method/step-up/limit/auto-reload/validation).

TUI build green; 12/12 vitest pass; slash tests pass once @hermes/ink is built.

* docs(billing): scrub private cross-repo references

NAS is a private repo — remove all references to it from the public PR:
- drop the cross-repo planning doc (planning scaffolding, not a deliverable;
  the PR description documents the design)
- replace 'NAS' / 'PR #412 preview' mentions in code + test comments with
  generic 'the server' / 'a preview deployment'

* docs(billing): scrub final NAS reference in step-up docstring

* docs(billing): drop dangling plan-doc refs

The phase-2b plan doc was removed in the cross-repo scrub (300afcc0b)
but two module docstrings still pointed at it. Drop the dead refs.

* feat(billing): interactive /billing overlay + step-up UX, portal-URL & token fixes

Adds the interactive /billing TUI overlay and hardens the terminal-billing
client across CLI and TUI.

- TUI: full /billing overlay state machine (overview to buy to confirm,
  auto-reload, read-only monthly limit) reusing the existing confirm overlay.
- Step-up: surface the verification link in-transcript and open the browser
  via the TUI's own opener (the device flow runs in the headless gateway, so a
  printed URL was being dropped); run the step-up handler off the main loop and
  emit the link as an out-of-band event so the gateway stays responsive.
- Step-up copy is scope-accurate ("Billing permission granted") and re-checks
  /state so it never claims "enabled" when the org kill-switch is still off.
- Portal deep-links resolve to absolute URLs against the active portal base
  (the server emits them relative) - fixes a bare "/billing?topup=open" link.
- Billing calls refresh an expired access token via the stored refresh token
  instead of reporting a false "not logged in".
- Optimistic funnel: advise "set up a saved card on the portal" up front when
  no card is on file (advisory, not a hard gate).
- Token resolution is cached briefly so the 2s charge poll loop stops
  re-locking + re-reading the auth store on every tick; 401 re-resolves fresh.
- Remove the temporary demo-mode shims.

Validation: 87 Python billing tests, 88 TS tests (billing command + gateway
event handler), tsc clean, ink + ui-tui builds green.

* docs(billing): add /billing TUI screenshots for PR

* fix(cli): guard _last_invalidate on bare instances; update stale prompt-fallback test

The UI-invalidate throttle read self._last_invalidate unconditionally, which
raised AttributeError on HermesCLI instances built without __init__ (the
thread-safety test's object.__new__ shell). Guard the read with getattr.

The off-main-thread branch of _prompt_text_input was changed (#23185) to cancel
cleanly to None instead of falling back to a bare input() that would hang on the
slash-worker thread; the test still asserted the old direct-input fallback.
Update it to assert the current intended behavior: returns None, calls neither
run_in_terminal nor input(), and does not hang.
2026-06-19 01:53:32 +05:30
brooklyn!
81eaedd0f5
Merge pull request #48533 from NousResearch/hermes/hermes-4061c6a8
fix(prompt,desktop,tui): dedupe parallel-tool-call steer + surface self-improvement review summary
2026-06-18 13:27:07 -05:00
Brooklyn Nicholson
51ee5b2c94 fix(desktop,tui): surface self-improvement review summary + honor memory_notifications
The "💾 Self-improvement review" summary (skill/memory updated) was invisible
on two surfaces:

- Desktop Electron app had no review.summary event handler — skill/memory
  writes happened silently. Now appends a persistent system message to the
  transcript (matching the Ink TUI's persistent-line semantics, not a
  transient toast that can be missed).
- tui_gateway (backs both 'hermes --tui' and the desktop) never read
  display.memory_notifications, so it always behaved as 'on' and ignored a
  user who set 'off'/'verbose'. Added _load_memory_notifications() (mirrors
  the messaging gateway's bool->str normalization, defaults to 'on') and
  wired it to agent.memory_notifications, matching gateway/run.py and the CLI.

Delivery chain now reaches all surfaces:
background_review.py -> background_review_callback -> review.summary event ->
desktop transcript / Ink TUI line / gateway message / CLI print.
2026-06-18 13:22:12 -05:00
Brooklyn Nicholson
07e785d60a fix(prompt): dedupe parallel-tool-call steer; correct its rationale
The universal PARALLEL_TOOL_CALL_GUIDANCE block already lives on main, but it
shipped with two rough edges this change cleans up:

- It duplicated the batching steer for Google models. The
  GOOGLE_MODEL_OPERATIONAL_GUIDANCE block still carried its own
  "Parallel tool calls" bullet, so Gemini/Gemma received the instruction
  twice in one prompt. Drop the redundant bullet — the universal block is now
  the single source.
- Its comment claimed "nothing in the open-source system prompt encouraged
  batching," which was wrong: the steer existed for Google models only. Reword
  to say the gap was that every *other* model got nothing.
- Tighten the test that asserts the steer (precedence-correct), and add an
  invariant guarding against re-introducing the Google duplicate.
2026-06-18 13:22:12 -05:00
Teknium
0fa7d6f660
fix(desktop): never persist or restore a named custom provider as bare "custom" (#48547)
* Port from cline/cline#11514: encourage parallel tool calls

Add a universal system-prompt guidance block telling the model to batch
independent tool calls (reads, searches, web fetches, read-only commands)
into a single assistant turn instead of one call per turn. The runtime
already executes independent batches concurrently (read-only tools always;
non-overlapping path-scoped file ops); the open-source system prompt had
nothing steering the model to PRODUCE the batch. Fewer round-trips means
less resent context, which compounds over a long conversation.

- prompt_builder.py: new PARALLEL_TOOL_CALL_GUIDANCE block (short, static,
  cache-amortised) modeled on TASK_COMPLETION_GUIDANCE.
- system_prompt.py: inject right after the task-completion block, gated by
  agent.valid_tool_names + the new toggle.
- agent_init.py: read agent.parallel_tool_call_guidance (default True).
- config.py: add the default under the agent section.
- test_prompt_builder.py: behavior-contract tests (batching steer, dependent
  carve-out, length bound) — invariants, not wording snapshots.

Adapted from Cline's TypeScript tool-surface guidance to hermes-agent's
Python prompt-assembly architecture and config-over-env conventions.

* fix(desktop): never persist or restore a named custom provider as bare "custom"

Custom providers vanish from the Desktop/TUI model picker with
"No LLM provider configured" — repeatedly fixed (#44062, #44109, #45578)
and repeatedly regressed (#44022, #47714) because every fix only recovered
the entry identity from a persisted base_url. When a session is
persisted/restored with the resolved provider "custom" and NO base_url, bare
"custom" leaked through verbatim; resolve_runtime_provider("custom") routes to
the OpenRouter default URL with no api_key, so the next turn/resume dies.

Bare "custom" is the resolved billing class shared by every named providers:/
custom_providers: entry — it is not a routable identity. Centralize the
"never let bare custom escape" invariant in one helper,
runtime_provider.canonical_custom_identity(), and apply it at all four leak
sites in tui_gateway/server.py:

- _ensure_session_db_row  — the ORIGIN: first DB write seeds the bad row
- _runtime_model_config   — live persist
- _stored_session_runtime_overrides — resume restore (heals old rows; drops
  unrecoverable bare custom so resume falls back to config default)
- _make_agent             — rebuild / per-turn

The helper recovers custom:<name> from the endpoint URL when present, else
from config.model.provider (the durable identity left when no base_url
survived). Regression tests in test_custom_provider_session_persistence.py
lock the no-base_url vector at every site so it cannot regress again.
2026-06-18 11:11:51 -07:00
Teknium
38c8a9c10f
feat(memory): batch operations for single-turn memory updates (#48507)
The memory tool was strictly one-op-per-call. With the store running near
its char limit by design, a new add that would overflow gets rejected with
'consolidate now, then retry' -- but the model could not consolidate and add
in one call. It had to remove/replace across several turns, then retry the
add, each turn re-sending the whole conversation context. Expensive thrash.

Add an 'operations' array: a list of add/replace/remove ops applied
atomically against the FINAL char budget. The model frees space and adds new
entries in ONE call, even when an add alone would overflow. All-or-nothing:
any bad op aborts the whole batch, nothing written.

Root-cause note: the two agent-level memory interception sites
(agent_runtime_helpers.py, tool_executor.py) silently dropped any param not
in their explicit kwarg list, so 'operations' never reached the handler and
batch calls failed with 'Unknown action None'. Both now pass it through and
bridge each add/replace op to external memory providers.

Also: success response is now terminal (done=true + 'do not repeat' note,
no full-entries echo that invited re-edits); schema rewritten to lead with
the batch mechanism and an explicit one-shot stop rule (2138 -> 1476 chars).

Live-verified: near-full consolidate-and-add went 7 calls -> 1 call,
stable across 3 reps. 103 memory/approval tests + 398 background-review/
run_agent tests green; 6 new batch tests added.
2026-06-18 10:19:33 -07:00
kshitij
2fa16ec2d2
Merge pull request #48529 from kshitijk4poor/salvage-48372-eap
fix(install): relax EAP=Stop around native git/uv calls + fail-fast on uv venv failure (#48352, salvage of #48372)
2026-06-18 22:17:53 +05:30
kshitijk4poor
fd12e59e6b fix(install): fail fast when uv venv genuinely fails under relaxed EAP
PR #48372 relaxes EAP=Stop around the uv venv call so PowerShell 5.1
doesn't mistake uv's 'Using CPython ...' stderr for a terminating
NativeCommandError. But relaxing EAP also means a *genuine* uv venv
failure (exit != 0) no longer aborts on its own — Install-Venv would
continue and print 'Virtual environment ready', and in stage mode
Invoke-Stage would report ok=true, even though no venv was created.

Capture $LASTEXITCODE immediately after the relaxed call and throw on
non-zero (Pop-Location first, matching the function's other exit paths),
so the venv stage fails fast instead of falsely succeeding. This is the
explicit guard originally proposed in #48463 (devorun), composed on top
of #48372's reusable helper + regression test.

Adds a regression test asserting the uv venv exit-code capture + throw.
2026-06-18 22:11:35 +05:30
Teknium
c37fdec2d9
feat(dashboard): surface full per-MCP catalog detail; fix pip-install doc (#48520)
The dashboard MCP catalog only showed name/description/transport and a
non-clickable source. Users couldn't see what an entry connects to or runs
before installing — the exact detail the docs trust model tells them to vet.

- /api/mcp/catalog now returns transport target (url, or command+args),
  auth_type, git install source/ref + bootstrap commands, default-enabled
  tool hint, and post-install guidance per entry.
- McpPage renders the endpoint URL (http) or command+args (stdio), the git
  install source/ref, a collapsible bootstrap-commands list, setup notes,
  and the source as a clickable link when it's a URL.
- Docs: drop the 'uv pip install -e .[mcp]' quick-start step (Hermes does
  not support pip installs; MCP ships with the standard install) and note
  the dashboard now surfaces this detail.
- Strengthen the catalog endpoint test to assert the new inspection fields.
2026-06-18 09:40:56 -07:00
kshitij
4af16b5da2
Merge pull request #48206 from ehz0ah/fix/openviking-current-api-rebased
fix(openviking): adapt memory provider for current api
2026-06-18 21:53:42 +05:30
teknium1
5ffbfed193 feat(mcp-catalog): add official Unreal Engine 5.8 MCP server
Epic's experimental Unreal MCP plugin embeds an MCP server inside the
Unreal Editor process, served over local HTTP (127.0.0.1:8000/mcp by
default). HTTP transport, no auth, no install block — the user enables
the plugin in-editor and Hermes connects to the URL.

Also drops test_optional_mcps_manifests_ship_in_both_wheel_and_sdist:
it asserted wheel/sdist packaging targets for pip/Homebrew/Nix installs,
which Hermes does not support — installs run from the repo checkout, where
the catalog is discovered by directory iteration with no packaging step.
2026-06-18 09:16:40 -07:00
xxxigm
58ad6942d9
fix(tui): don't make Enter swallow trailing-space-only slash completions (#48425)
* fix(tui): don't make Enter swallow trailing-space-only slash completions

Submitting a slash command in the TUI took three Enter presses: one to
complete the name (/ex → /exit), a second that only appended the trailing
space the gateway adds to keep the classic-CLI prompt_toolkit dropdown open
(/exit → "/exit "), and a third to actually submit.

The composer's submit handler accepted the highlighted completion whenever
applying it changed the input at all, so the whitespace-only delta ate an
extra keypress. Treat a completion whose only change is trailing whitespace
on an already-complete token as "already complete" and fall through to
submit. Partial-name and argument completions (a real token change) still
accept on Enter as before.

The replace/accept logic is extracted into pure helpers (applyCompletion,
completionToApplyOnSubmit) in domain/slash.ts.

* test(tui): cover Enter/completion trailing-space behavior and isolate poller queue

- completionApply.test.ts asserts completionToApplyOnSubmit accepts real
  token completions (partial command name, argument) but returns null for a
  trailing-space-only delta on an already-complete command, so Enter submits
  instead of needing extra presses.
- test_notification_poller_delivers_completion / _skips_consumed previously
  shared the process-global process_registry.completion_queue. Their events
  carry no session_key, so a leaked/concurrent poller could dequeue and
  dispatch them to a fixture agent without run_conversation, flaking CI
  ("AttributeError: '_FakeAgent' object has no attribute 'run_conversation'").
  Isolate the queue per test (fresh queue.Queue via monkeypatch), matching the
  sibling poller tests that already do this.
2026-06-18 11:04:59 -05:00
Teknium
25c590ccd0 fix(skills): refuse SKILLS_DIR root in rmtree guard, not just outside-tree
The salvaged guard allowed _rmtree_writable(SKILLS_DIR) itself. No call
site ever passes the root — every site passes a skill subdir or its .bak
sibling — so allowing the root only preserves the #48200 footgun (a dest
that collapses to the root wipes every installed skill). Require a strict
strict-child relationship and update the test that documented the
nonexistent 'full reset' capability.
2026-06-18 08:53:35 -07:00
Kewe63
f1254c8eaf fix(skills): rmtree scope guard + default pre_update_backup to true (#48200)
Defense-in-depth fix for the silent wipe of ~/.hermes/ documented in
#48200. A `hermes update --yes` run silently destroyed a user's
.env, MEMORY.md, kanban.db, custom skills, and scripts. Two changes:

1. `_rmtree_writable` in tools/skills_sync.py now refuses to rmtree
   anything outside SKILLS_DIR (the HERMES_HOME/skills/ root).
   All five call sites pass paths under SKILLS_DIR, so the guard is
   a no-op for current code and a loud, recoverable failure for
   any future regression (bad path join, malicious bundled
   manifest, stale path in scope after an exception).

2. The default `updates.pre_update_backup` flips from false to
   true in hermes_cli/config.py. A few minutes of zip per update
   is negligible compared to silent total data loss. Still
   overridable; --no-backup still works for one-off opt-out.

Five new tests in TestRmtreeWritableScopeGuard (root path,
hermes home, sibling dir, skills root itself, subdir) plus a
flipped `test_default_enabled_creates_backup` in test_backup.py.
178/178 tests pass in the two affected files. Public method
signatures unchanged, no test-stub blast radius.

Closes #48200
2026-06-18 08:53:35 -07:00
Teknium
41babc702e chore(release): map iamlukethedev to AUTHOR_MAP 2026-06-18 08:53:31 -07:00
Luke The Dev
3c3ac19d9c fix(#37878): Address review feedback — fix trailing whitespace and add ANTHROPIC_API_KEY test
Review feedback from egilewski:
1. Remove trailing whitespace from test docstring and mock patches (lines 1430, 1469, 1476, 1482)
2. Expand test coverage: also verify ANTHROPIC_API_KEY is stripped (not just OPENAI_API_KEY)

Changes:
- Remove trailing whitespace from test file
- Add ANTHROPIC_API_KEY to test environment
- Add assertion verifying ANTHROPIC_API_KEY is stripped from cua-driver subprocess env
- Syntax verified: python3 -m py_compile tests/tools/test_computer_use.py ✓
2026-06-18 08:53:31 -07:00
Luke The Dev
2e5c04aaf7 fix(#37878): scrub operator environment before launching cua-driver MCP
- Use _sanitize_subprocess_env() to filter Hermes-managed credentials
  from the cua-driver subprocess environment (issue #37878)
- Prevents credential exfiltration to the third-party cua-driver binary
- Aligns with existing pattern used by browser-tool and other tools
- Add regression test to verify environment sanitization

The cua-driver is a lower-trust MCP subprocess per SECURITY.md §2.3.
Its inherited environment is now scrubbed by default, removing provider
API keys, gateway tokens, and platform credentials that should not leak
to third-party binaries.

Fixes #37878
2026-06-18 08:53:31 -07:00
kshitij
b39ec2fc37
Merge pull request #48341 from xxxigm/fix/install-ps1-powershell-host-resolution
fix(install): resolve PowerShell host instead of bare `powershell` for uv install
2026-06-18 21:09:50 +05:30
Siddharth Balyan
646cd1b43e
fix(nix): refresh npmDepsHash after the Electron 40.10.2 pin (#47792) (#48457)
PR #47792 pinned Electron to an exact 40.10.2 and regenerated the root
package-lock.json (dropping @electron/get@5 + @electron-internal/extract-zip,
restoring @electron/get@2 + extract-zip@2 + yauzl), but did not refresh the
shared npmDepsHash in nix/lib.nix. The hash still described the previous
40.10.3 lockfile, so npmConfigHook fails on every Nix build with
"npmDepsHash is out of date" for hermes-tui / hermes-web / hermes-desktop.

Regenerate the single shared hash to match the current lockfile.

Verified with fetchNpmDeps (authoritative, not prefetch-npm-deps):
  nix build .#tui.npmDeps  -> builds clean
  nix build .#tui          -> Validating consistency -> Installing dependencies
                              -> Finished npmConfigHook (no hash error)
2026-06-18 15:00:08 +00:00
teknium1
ef4b897a18 chore(release): map srojk34 author email 2026-06-18 05:55:17 -07:00
srojk34
92e6d8c858 fix(desktop): dispose open PTY sessions in before-quit handler
The `before-quit` handler tears down the bootstrap controller, preview
watchers, and the Python backend but never disposes live PTY sessions.
When `app.quit()` proceeds to `FreeEnvironment()`, node-pty's
`ThreadSafeFunction::CallJS` callback fires on a half-torn-down
environment, throws a C++ exception that can no longer be caught, and
the process aborts (microsoft/node-pty#904).

Iterate `terminalSessions` and call `disposeTerminalSession()` (which
already calls `pty.kill()` + deletes the map entry) before killing the
backend, so the ThreadSafeFunctions are removed before teardown begins.

Closes #48335
2026-06-18 05:55:17 -07:00
Teknium
2f7c4858a7
fix(tui): refresh tool snapshot when MCP discovery lands after agent build (#48403)
The TUI banner reported fewer tools than the classic CLI for the same
config (e.g. 32 vs 38) when an MCP server connected slowly. Root cause:
the agent snapshots `agent.tools` once at build time and never re-reads
the registry. `_make_agent` briefly joins the background MCP discovery
thread (`wait_for_mcp_discovery`, ~0.75s) so fast servers land in that
snapshot, but a server slower than the bound — common for an HTTP MCP
server on first connect — lands *after* the agent is built. Its tools are
then absent from both the agent (uncallable until `/reload-mcp`) and the
banner for the whole session.

The classic CLI doesn't hit this because it re-derives
`get_tool_definitions()` at banner render time (which re-waits for
discovery), so it picks the late tools up.

Fix: after a fresh agent is built and its first `session.info` emitted,
if discovery is still in flight, schedule an off-critical-path daemon that
waits for it to finish, then rebuilds the tool snapshot and re-emits
`session.info` — the same rebuild `/reload-mcp` performs, but automatic.
Both the agent's callable tools and the banner count catch up.

Cache safety: the rebuild runs only while the session is still
pre-first-turn (`_user_turn_count`/`_api_call_count` both 0 → nothing
cached to invalidate). Once the user has sent a message we leave the
snapshot frozen rather than break the cached prompt prefix mid-conversation;
late tools then require an explicit `/reload-mcp` (user-consented), exactly
as today. No-op when discovery finished before the agent build, when the
join times out, when the registry was unchanged, or when the session was
swapped/closed while waiting.

Adds entry.mcp_discovery_in_flight() / join_mcp_discovery() accessors and
covers the matrix (added/none/post-turn/timeout/unchanged/replaced) with
unit tests.
2026-06-18 05:41:23 -07:00
Teknium
8abdab24c9
fix(tui): MCP headline counts connected servers, not disabled ones (#48402)
The TUI banner footer used the raw `info.mcp_servers.length`, so a
configured-but-disabled server (e.g. `linear`) was counted alongside
connected ones. With a disabled `linear` and a connected `nous-support`,
the TUI reported "2 MCP" while the classic CLI correctly reported "1 MCP"
(`mcp_connected = sum(1 for s in mcp_status if s["connected"])` in
hermes_cli/banner.py).

The collapse toggle even labels the count "connected", which was wrong
for the same reason.

Count connected servers for both the toggle and the footer segment, and
drop the `· N MCP` segment entirely when none are connected (matching the
classic banner, which only appends it when the count is > 0). The
expandable MCP section still lists every configured server, including
disabled ones.

Invariant test renders SessionPanel and asserts the headline equals the
connected count, never the configured total.
2026-06-18 05:41:19 -07:00
Tranquil-Flow
67316fdc94 fix(install): relax native stderr handling in install.ps1 (#48352) 2026-06-18 12:06:29 +02:00
xxxigm
feff283e17 test(install): lock uv installer to a resolved PowerShell host
Source-level guard (install.ps1 only runs on Windows, so there's no Linux CI
runner to execute it): the astral uv install line must be invoked via the call
operator on a resolved host variable, the bare-`powershell` literal that
produced the field-reported "The term 'powershell' is not recognized" must be
gone, and the resolver must be PATH-independent (Get-Process -Id $PID) and
pwsh-aware.
2026-06-18 16:26:34 +07:00
xxxigm
a14bae6bcc fix(install): resolve PowerShell host instead of bare powershell for uv
The Windows installer's Install-Uv spawned the astral uv installer with a
hardcoded bare `powershell -ExecutionPolicy ByPass -c "irm .../uv | iex"`.
That name resolves only to Windows PowerShell, and only when its System32
directory is on PATH. Run under PowerShell 7+ (`pwsh`) — or any session where
`powershell` isn't on PATH — the spawn dies with "The term 'powershell' is not
recognized", and uv installation aborts (the installer then appears stuck).

Add Get-PowerShellHostExe, which prefers the absolute path of the host we're
already running in (PATH-independent), then falls back to powershell/pwsh via
Get-Command, then to the bare name. Install-Uv now invokes that resolved exe.
2026-06-18 16:26:34 +07:00
qin-ctx
2a5d51c16e fix(openviking): adapt memory provider for current api
(cherry picked from commit cbb87389f33583518975fbf72671de3fd224bb28)
2026-06-18 16:58:11 +08:00
kshitij
426f321e84
Merge pull request #48299 from NousResearch/chore/author-map-infinitycrew39
chore(release): map infinitycrew39 author email
2026-06-18 13:09:59 +05:30
kshitijk4poor
ca28c630c7 chore(release): map infinitycrew39 author email
Add infinitycrew39@gmail.com -> infinitycrew39 to AUTHOR_MAP so the
contributor audit resolves the two cherry-picked commits from the #47945
langfuse trace-scope salvage (merged as #48292) to a GitHub handle instead
of flagging them as an unmapped author email.
2026-06-18 13:09:34 +05:30
kshitij
9b2f7d2cb1
Merge pull request #48292 from NousResearch/fix/langfuse-trace-scope-salvage
fix(langfuse): scope trace state by turn/request ids (salvage #47945)
2026-06-18 13:08:17 +05:30
kshitijk4poor
0787ea07c8 test(langfuse): pin exact surviving key in turn-isolation test
The prior assertion `all("turn1" in k or "turn2" in k for k in keys)` was
weak on two counts: it passes vacuously when keys is empty (a regression
that lost all state would slip through), and after turn 2 finalizes only
turn 1 lingers, so it only ever inspected turn 1 anyway. Replace it with an
exact check that one key survives, it is turn 1, and turn 2 never merged
into it — the real isolation invariant the test name claims.
2026-06-18 13:00:01 +05:30
kshitijk4poor
f4fbaa6cda fix(langfuse): bound _TRACE_STATE growth from non-finalizing turns
Scoping the trace key by turn_id (the prior commit) fixed cross-turn
collisions but introduced a slow leak: _finish_trace only pops a key when a
turn ends cleanly (final response has content and no tool calls), so any
turn that is interrupted, ends on a tool call, or has empty final content
now leaves its uniquely-keyed entry in _TRACE_STATE forever. Previously the
constant per-session key was overwritten by the next turn, capping growth at
~1 entry per session.

Add an LRU cap (_MAX_TRACE_STATE) enforced by _evict_stale_locked, called
under _STATE_LOCK immediately before each insert. It evicts the
least-recently-updated entries (using the previously-dead last_updated_at
field) and ends their root span so nothing dangles. Regression test drives
50 non-finalizing turns against a cap of 8 and asserts the dict stays bounded
with the most-recent turns surviving.
2026-06-18 12:59:41 +05:30
kshitijk4poor
e1d10ec1ed refactor(langfuse): extract _scope_prefix from _trace_key
The turn- and api-scoped branches each repeated the same
task/session/thread fallback ladder with only the infix differing. Extract
the shared prefix into _scope_prefix so a future scope dimension touches one
ladder instead of three. The legacy branch still returns a bare task_id (not
the task: prefix) for backward compatibility, so it stays separate.

Output key strings are unchanged; a new test pins them across every
task/session/turn/api combination since the keys are matched across hooks
and any drift would silently break trace finalization.
2026-06-18 12:58:24 +05:30
kshitij
860cf5133a
Merge pull request #48293 from kshitijk4poor/chore/skills-diff-cleanup
refactor(skills): dedupe file-listing + share user-modified predicate (follow-up to #48286)
2026-06-18 12:49:53 +05:30
kshitijk4poor
f6fac60e66 refactor(skills): dedupe file-listing, share user-modified predicate, trim diff contract
Cleanup pass on the salvage (behavior-preserving):

- diff_bundled_skill now uses the existing _skill_file_list() helper
  instead of reimplementing the rglob/is_file/relative_to file-set
  enumeration inline (twice).
- Extract _is_tracked_user_modification(origin_hash, user_hash) and use
  it in BOTH the sync loop and list_user_modified_bundled_skills() so the
  'kept user edit' rule can't drift between the two sites.
- _read_text_for_diff -> _read_for_diff returns (bytes, text); the binary
  branch now compares the bytes it already read instead of re-reading
  both files from disk.
- Drop the unused 'user_present' key from diff_bundled_skill's return
  contract (no consumer or test ever read it).
- test_update_modified_notice: drop the brittle '>= 2 sites' count-floor
  so consolidating the two print paths into a shared helper stays a
  welcome refactor; keep the per-site 'count notice => discovery hint'
  invariant (still mutation-tested).
2026-06-18 12:42:58 +05:30
kshitijk4poor
b4356135f2 test(langfuse): add end-to-end turn-isolation regression
The PR added helper-level tests for _trace_key but nothing exercised the
keys through the real hooks. This adds TestTurnTraceIsolation, which drives
on_pre_llm_request / on_post_llm_call across two turns of one gateway
session (task_id == session_id, unique turn_id, api_call_count reset per
turn) and asserts each turn opens its own root trace when the first turn
fails to finalize (tool-only final step). This test fails on the pre-fix
code (only one trace opened, turn 2 absorbed into turn 1) and passes with
the scoping fix.

Also pins the turn_id-over-api_request_id key precedence: the turn-scoped
post_llm_call carries no api_request_id, so it must still resolve to the
same key as the request-scoped hooks or finalization breaks.
2026-06-18 12:38:44 +05:30
infinitycrew39
40ed67ccfe test(langfuse): cover turn/api trace-key scoping 2026-06-18 12:36:35 +05:30
infinitycrew39
0b54a33a34 fix(langfuse): scope trace state by turn/request ids 2026-06-18 12:36:35 +05:30
kshitij
737007e335
Merge pull request #48286 from kshitijk4poor/salvage/skills-list-modified-diff
feat(skills): find & diff user-modified bundled skills (salvage of #47802)
2026-06-18 12:33:28 +05:30
kshitijk4poor
6777916068 fix(skills): surface list-modified hint on both update paths + disambiguate diff
Salvage follow-up to the cherry-picked feat/test commits:

- W1: the unpack/install update path in main.py printed the
  '~ N user-modified (kept)' notice without the new
  'hermes skills list-modified' hint that the git-pull path got.
  Mirror the hint to both sites so the count is actionable
  regardless of which update path runs.
- W2: 'hermes skills diff <name>' (bundled-vs-stock) now shares the
  verb with the gateway write-approval 'diff <id>'. The gateway
  handler's docstring + truncation message pointed users to
  '/skills diff <id>' on the CLI, which now resolves a bundled skill
  by that name instead. Point at the pending JSON file and note the
  two diff commands are distinct.
- Add an invariant test asserting every 'user-modified (kept)' notice
  in main.py carries the discovery hint (guards sibling drift).
2026-06-18 12:28:11 +05:30
xxxigm
481f0417d8 test(skills): cover list-modified + diff for bundled skills
Exercises the real sync pipeline (no mocked comparison logic): a pristine
synced skill is not flagged; an edited one is listed and diffed (modified +
added files); an unknown skill returns not-ok; and `reset --restore` clears
the modified state so revert and discovery stay consistent.
2026-06-18 12:26:20 +05:30
xxxigm
085fc5d001 feat(skills): find & diff user-modified bundled skills
`hermes update` keeps (won't overwrite) bundled skills the user edited
locally, but only printed a count — "~ N user-modified (kept)" — with no way
to learn which skills, or see what changed. Reverting already existed
(`hermes skills reset <name> [--restore]`); discovery and inspection did not.

Add two CLI commands (zero model-tool footprint), reusing the manifest
origin-hash that sync already maintains:

- `hermes skills list-modified [--json]` — list the bundled skills whose
  on-disk copy diverges from the last-synced origin hash (the exact test the
  sync loop uses to decide what to skip).
- `hermes skills diff <name>` — unified diff between the user's copy and the
  current bundled (stock) version, so the user can confirm what changed
  before reverting.

Both are mirrored as `/skills list-modified` and `/skills diff`. The
`hermes update` notice now points at `hermes skills list-modified`. Core
helpers `list_user_modified_bundled_skills()` and `diff_bundled_skill()` live
in tools/skills_sync.py alongside the existing reset logic.
2026-06-18 12:26:20 +05:30
kshitij
edcde6b26f
Merge pull request #48265 from kshitijk4poor/chore/ov-atomic-json-write
refactor(openviking): reuse atomic_json_write for ovcli config; drop dead constants
2026-06-18 11:45:30 +05:30
kshitijk4poor
5494c1e9b6 refactor(openviking): reuse atomic_json_write for ovcli config; drop dead constants
Follow-up cleanup on the OpenViking setup path merged in #48262:

- _write_ovcli_config now uses utils.atomic_json_write(path, data, mode=0o600)
  instead of the local _precreate_secret_file + write_text + chmod sequence.
  The shared helper (already used by honcho/mem0/supermemory/hindsight) writes
  via temp-file + fchmod(0600) + fsync + os.replace, so the ovcli.conf is
  written atomically (no half-written secret file on crash) and with no
  chmod-after-write TOCTOU window. _precreate_secret_file stays for the .env
  writer path.
- Remove dead _DEFAULT_ACCOUNT/_DEFAULT_USER constants (0 references; the
  empty->'default' tenant fallback lives in the _VikingClient constructor).

Tests: tests/plugins/memory/test_openviking_provider.py + test_memory_setup.py
+ openviking_plugin/test_openviking.py -> 130 passed; ruff clean.
2026-06-18 11:40:11 +05:30
kshitij
832d5967f8
Merge pull request #48262 from kshitijk4poor/salvage-32445
feat(memory): improve OpenViking setup UX (salvage #32445)
2026-06-18 11:34:11 +05:30
Ben Barclay
eaa0984210
chore: drop committed PR-infographic assets from the repo (#48261)
PR infographics are decorative visual hooks for a PR body, not repo
artifacts. The established convention (commit 5772e638c, "chore: drop
in-repo infographic/ directory; keep PR-body URLs only", #30854) is to
hotlink an externally-hosted image so GitHub camo-proxies it inline,
leaving zero binary footprint in the tree.

Two such assets had been committed anyway and are referenced nowhere in
the codebase:

- docs/assets/ns504-chat-session-reconnect.png (1024-equiv, NS-504 PR
  infographic, added in #47674 alongside the ChatPage.tsx fix)
- infographic/kanban-db-corruption-defense/infographic.png (re-added a
  directory #30854 had explicitly removed, in #30952)

Both are unreferenced decorative infographics, so removing them has no
effect on docs, website, or app builds. Removing the latter also clears
the stray top-level infographic/ directory that #30854 had retired.

These blobs remain in history (the commits that introduced them are
already on main and bundled with real code, so they can't be dropped);
this just removes them from the working tree going forward.
2026-06-18 16:03:29 +10:00
kshitijk4poor
1153b42b24 Merge upstream/main into OpenViking setup-UX (salvage #32445)
Resolves conflicts from the OpenViking churn that merged after #32445 was
opened (#48042/#47662 session-switch + write hardening, #47311/#47973):

- plugins/memory/openviking/__init__.py: keep both __init__ field groups
  (the PR's _runtime_start_* alongside main's _prefetch_threads/_shutting_down).
- tests/plugins/memory/test_openviking_provider.py: keep BOTH the PR's new
  setup-validation tests and main's session-switch/concurrency tests (disjoint
  additions to the same region).

Two fixes layered while reconciling (contributor work otherwise preserved):

- Restore the merged tenant-header contract (#22414/#21232). The PR had changed
  _VikingClient defaults to '' and made empty account/user OMIT the tenant
  headers; main's contract is that empty falls back to 'default' and the
  X-OpenViking-Account/User headers are ALWAYS sent (ROOT API keys need them).
  Reverted the constructor to 'account or os.environ.get(..., "default")' and
  updated the two PR tests that asserted the omit-when-empty behavior.

- Close a secret-file TOCTOU in the setup writers. _write_env_vars and
  _write_ovcli_config wrote the api_key/root_api_key file and chmod 0600
  AFTERWARD, leaving a world-readable window on newly-created files. Added
  _precreate_secret_file() to create with 0600 before any secret bytes land.
2026-06-18 11:28:51 +05:30
Ben Barclay
c661634537
fix(dashboard): stream file uploads via multipart instead of base64 JSON (NS-501) (#47663)
* fix(dashboard): stream file uploads via multipart instead of base64 JSON

The dashboard file manager uploaded files (including backup/restore zip
archives) by reading them client-side with FileReader.readAsDataURL and
POSTing a base64 data URL inside a JSON body to /api/files/upload. For a
large backup this (a) inflates the payload ~33%, (b) buffers the whole
file plus its decoded copy in memory, and (c) reliably trips an upstream
proxy body-size/timeout limit, surfacing as a 502 with the upload
appearing to hang indefinitely (NS-501). Dashboard-only hosted users have
no shell fallback to place the archive, so backup restore was unusable.

Add a streaming multipart endpoint POST /api/files/upload-stream
(UploadFile + Form) that reads the request body in 1 MiB chunks straight
to a sibling temp file, enforces the existing 100 MB size cap as it
streams (413 on overflow, before buffering the whole file), and
atomically renames into place so a partial/aborted/over-limit upload
never clobbers an existing file. The frontend api.uploadFile now sends
multipart/form-data (raw bytes, no base64, browser-set boundary) and
FilesPage passes the File object directly; the dead readAsDataUrl helper
is removed. The legacy base64 JSON endpoint stays for backward compat.

FastAPI's UploadFile/Form require python-multipart, which is NOT pulled in
by fastapi itself, so it is added to the base deps, the [web] extra, and
the tool.dashboard lazy-install set (kept in sync).

Validated: 5 new endpoint tests (roundtrip, multi-chunk >1 MiB,
over-limit 413 without clobbering + no temp-file leak, overwrite=false
conflict, forced-root traversal containment); existing base64 tests still
pass; web typecheck + vite build clean; and a real uvicorn server E2E
(5 MB multipart upload -> HTTP 200 in 0.21s, exact byte match) plus a
30 MB TestClient roundtrip confirm constant-memory streaming end to end.

Reported via beta (NS-501).

* build(deps): regenerate uv.lock for python-multipart (NS-501)

CI ran uv lock --check / uv sync --locked which failed because the
python-multipart dependency add was not reflected in uv.lock. Regenerate
the lockfile (resolves to 0.0.20, matching the [web] extra pin) after
merging current main.
2026-06-18 15:54:32 +10:00
Ben Barclay
9c3c5da356
fix(backup): hermes import never overwrites volatile gateway runtime state (NS-501) (#48243)
Importing a backup wrote every file from the zip over the target home
wholesale. On a hosted instance this clobbered gateway_state.json with the
source machine's last recorded run/desired state — driving the container-boot
reconciler (container_boot._read_desired_state, which only auto-starts a
gateway whose state is "running") off stale/foreign state and leaving the
gateway stuck "starting", disconnected from the Nous portal.

Add _IMPORT_SKIP_NAMES (gateway_state.json, gateway.pid, cron.pid,
gateway.lock, processes.json) and skip them by basename in run_import, so both
the root profile and named profiles preserve the target's own runtime state.
This mirrors what container_boot._STALE_RUNTIME_FILES already sweeps on every
container boot, and protects against older backups that predate the
backup-side exclusions. The import summary reports which files were preserved.

This is the second half of NS-501 (filed separately as NS-508): the upload
502 was fixed in #47663; this fixes the import-breaks-the-instance half.
2026-06-18 15:27:45 +10:00
Ben Barclay
0ddd21c74e
feat(relay): managed-boot self-provision client (Phase 3, gateway side) (#48242)
The gateway half of relay Phase 3. On a MANAGED boot with relay configured and
no secret pinned, the runtime self-provisions its relay credentials IN-PROCESS:
resolve the agent's own Nous access token (resolve_nous_access_token) -> POST
the connector's /relay/provision asserting its own endpoint + route keys ->
set GATEWAY_RELAY_ID/SECRET/DELIVERY_KEY into os.environ so the immediately-
following register_relay_adapter() reads them and dials out authenticated.

No human, no enrollment token, no disk write — the creds live only in process
memory (save_env_value refuses under managed anyway, and keeping the secret off
any volume is the stronger posture). Stateless: process-env creds don't survive
a restart, so a managed container re-provisions every boot; the connector's
rotation window covers a still-connected prior instance. An explicitly-pinned
GATEWAY_RELAY_SECRET is respected (skip). Self-hosted is unchanged: humans keep
using `hermes gateway enroll`.

Endpoint provenance is gateway-asserted (GATEWAY_RELAY_ENDPOINT +
GATEWAY_RELAY_ROUTE_KEYS, env or gateway.relay_* config) — uniform code path
whether the operator sets it (self-hosted) or NAS stamps it (hosted, the only
case NAS knows the public URL). Both absent -> outbound-only provisioning
(credentials, no inbound routes). The connector scopes the asserted endpoint to
the verified tenant, so it stays within the security model.

- gateway/relay/__init__.py: relay_endpoint(), relay_route_keys(),
  _provision_url(), _post_provision(), self_provision_if_managed() (never
  raises — a provision failure logs and boots without relay auth).
- gateway/run.py: call self_provision_if_managed() immediately before
  register_relay_adapter() in the startup path.

Tests: 12 unit (trigger logic, respect-pinned-secret, in-process env wiring,
endpoint+routes vs outbound-only, fail-soft on token/connector failure);
mutation-checked (drop is_managed guard / pinned-secret guard -> tests fail).
Cross-repo live E2E driver lands on the connector side (depends on this).

EXPERIMENTAL: relay auth scheme may change until >=2 Class-1 platforms validate.
2026-06-18 15:25:29 +10:00
Ben Barclay
4440d77bf3
fix(update): scope install-method stamp to the code tree, not $HERMES_HOME (#48188)
The install method (docker/git/pip/...) describes the *running binary*, but
detect_install_method() read it from $HERMES_HOME/.install_method — a shared
DATA directory. The Docker docs deliberately bind-mount $HERMES_HOME
(~/.hermes:/opt/data) so config/sessions/memory persist and can be shared with
a host-side Desktop/CLI install.

When a containerized gateway and a host install share one $HERMES_HOME, the
home-scoped stamp is a single slot describing two installs: the published image
stamps 'docker' on every boot, the host install then reads 'docker' and the
in-app updater refuses to run 'hermes update' ("doesn't apply inside the Docker
container"). Reinstalling the Desktop app from the DMG doesn't help because the
contaminated stamp is re-read every time.

Fix (option 1 — code-scoped stamp):
- detect_install_method() reads <install tree>/.install_method first (next to
  the running code, immune to the shared data dir). It falls back to the legacy
  $HERMES_HOME stamp for back-compat, but IGNORES a 'docker' home stamp when
  not actually containerized — so already-poisoned shared homes self-heal.
- stamp_install_method() writes the code-scoped stamp.
- install.sh stamps $INSTALL_DIR instead of $HERMES_HOME.
- Dockerfile bakes 'docker' into /opt/hermes/.install_method at build time
  (inside the immutable block); stage2-hook.sh no longer writes the home stamp
  and proactively removes a stale 'docker' one to heal existing shared homes.

Genuine containers still resolve to 'docker' (baked stamp, or legacy home stamp
honored when containerized). Unstamped installs in generic containers still fall
through to git/pip (preserves the #34397 fix).
2026-06-18 14:14:41 +10:00
Gille
3769dff5dd
fix(approval): honor glob command allowlist entries (#43051)
* fix(approval): honor glob command allowlist entries

* fix(approval): guard allowlist globs from shell chaining
2026-06-18 12:48:36 +10:00
Ben Barclay
c276b017ad
feat(relay): connector⇄gateway channel auth + signed-HTTP inbound receiver + enroll CLI (#48147)
* feat(relay): authenticate the connector⇄gateway WS channel

The relay gateway may be customer-managed and internet-exposed, so the
connector⇄gateway channel is itself authenticated (distinct from the
platform crypto the relay path sheds). Add gateway/relay/auth.py — a
Python port of the connector's HMAC token + delivery-signature schemes
(relayAuthToken.ts / deliverySigning.ts), verified byte-for-byte against
the connector's compiled TypeScript via cross-language test vectors.

Present an Authorization bearer on the /relay WS upgrade keyed by the
per-gateway secret (resolved from GATEWAY_RELAY_ID / GATEWAY_RELAY_SECRET
in env or config). The connector rejects an unauthenticated/invalid/
revoked upgrade with close 4401.

* feat(relay): signed-HTTP inbound delivery receiver

The connector delivers normalized inbound events to a tenant's gateway
over a signed HTTP POST, not the outbound /relay WS: the connector
instance owning a platform socket is generally not the instance a given
gateway dialed out to, so inbound targets a tenant endpoint that may
load-balance across gateway instances.

Add gateway/relay/inbound_receiver.py — verifies x-relay-signature /
x-relay-timestamp over the EXACT raw request bytes (re-serializing would
break the HMAC: JS JSON.stringify is compact, Python json.dumps spaces)
against the per-tenant delivery key verify list within a 300s replay
window, then dispatches messages to handle_message and interrupts to the
interrupt handler. Wire it into the adapter lifecycle (start in connect()
when a delivery key + bind port are configured, tear down in disconnect();
a purely-outbound dev gateway runs without it).

Refine test_relay_sheds_crypto to distinguish PLATFORM crypto (Discord
ed25519, Twilio/WeCom HMAC — still shed) from the connector⇄gateway
CHANNEL auth (intended): auth.py / inbound_receiver.py are exempt from
the platform-symbol scan but still banned from importing platform-crypto
modules, plus a positive guard that auth.py uses only stdlib hmac/hashlib.

* feat(relay): hermes gateway enroll CLI

Add the gateway half of zero-touch enrollment. `hermes gateway enroll`
resolves a fresh Nous Portal access token (the tenant-proving identity),
POSTs {enrollmentToken, gatewayId} to the connector's /relay/enroll, and
persists GATEWAY_RELAY_ID / GATEWAY_RELAY_SECRET / GATEWAY_RELAY_DELIVERY_KEY
to ~/.hermes/.env. The per-gateway secret authenticates the WS upgrade;
the per-tenant delivery key verifies signed inbound deliveries.

Refuses under is_managed() (hosted installs get the secret stamped in by
the orchestrator). Added as an 'enroll' subcommand on the existing
gateway subparser — not a new top-level command.

* docs(relay): inbound is signed HTTP, not WS; document channel auth

Fix the stale contract: §3/§5 said inbound rode the WS socket (single-
instance only, predates the multi-instance socket-ownership + channel-auth
model). Inbound + connector→gateway interrupt are signed HTTP POSTs to the
tenant endpoint. Add §6.1 documenting the two channel-auth schemes (per-
gateway WS-upgrade secret, per-tenant inbound delivery key) and how they
differ from the platform crypto the relay path sheds.

* test(relay): update build_gateway_parser callers for cmd_gateway_enroll

The enroll subcommand added cmd_gateway_enroll as a required keyword-only
arg to build_gateway_parser, but two existing parser-extraction tests still
called it with only cmd_gateway/cmd_proxy — failing CI with TypeError.
Thread the new handler through both call sites and add a test asserting
`gateway enroll` dispatches to cmd_gateway_enroll with its flags parsed.
2026-06-18 12:01:54 +10:00
Ben Barclay
fcf6cb3d73
fix(docker): supervised gateway uses --replace to take over stale holder (NS-505) (#47555)
* fix(docker): supervised gateway uses --replace to take over stale holder

Inside the s6 container image the per-profile gateway service rendered a
bare `hermes gateway run` (no --replace). When a gateway is started
OUTSIDE s6 — a stray shell `hermes gateway run`, an agent action, or the
Open WebUI helper (scripts/setup_open_webui.sh) — it grabs the
per-HERMES_HOME PID lock first. The supervised slot then execs the bare
`gateway run`, hits the "Another gateway instance is already running"
guard, exits non-zero, and s6 restarts it: a restart loop that floods the
log every ~12s and never binds. The container looks up but the gateway is
permanently down, and dashboard-only users (no shell) cannot recover.

Render the supervised run script as `gateway run --replace` so s6 is
authoritative for its slot: it reaps the stale holder via the hardened
takeover path (takeover marker + SIGTERM->SIGKILL-with-confirmation +
scoped-lock cleanup in gateway/run.py) and binds. This matches the
systemd service path, which already builds its argv with --replace
(_build_gateway_argv / 'nohup hermes gateway run --replace'), and the
intent already documented in _maybe_redirect_run_to_s6_supervision. The
existing HERMES_S6_SUPERVISED_CHILD sentinel still prevents the
run->start->run redirect recursion. Each profile is scoped to its own
HERMES_HOME and s6 guarantees one supervised instance per slot, so there
is no legitimate supervised sibling for --replace to clobber.

Reported via beta (NS-505): gateway.log showed PID 17907 'running
(manual process)' with the guard error repeating every ~12s on
v2026.6.5.

Adds a regression test asserting every gateway-run exec line in the
rendered script (default + named profile, both privilege branches)
carries --replace, and updates the existing render-script assertion.

* fix(ci): remove stray .venv symlink committed into repo

The PR's commit accidentally tracked a .venv symlink pointing at the
developer's local venv (mode 120000 -> /home/ben/nous/hermes-agent/.venv).
The CI test/e2e/build jobs run `uv venv` to create .venv and failed with
`failed to create directory .venv: File exists (os error 17)` because the
checkout already contained the symlink. All test shards aborted in <15s
during setup, before any test ran.

Untrack the symlink and add a bare `.venv` entry to .gitignore (the
existing `.venv/` rule only matches a directory, so a symlink slipped
through).
2026-06-18 10:49:02 +10:00
teknium1
c5eb64b9f7 fix(xai): scope native web_search to swap-only + reconcile composer ctx to 200k
Salvage corrections on top of @XVVH's #44341:
- Make native web_search injection a 1:1 swap for an already-present client
  web_search function, NOT an additive grant. The original unconditionally
  appended {"type":"web_search"} on every is_xai_responses turn with any
  tools, force-enabling Grok server-side search even when the user never
  enabled the web toolset (bypassing Hermes web-provider config + tool-trace
  plumbing). Now gated on a client web_search actually being present.
- Reconcile grok-composer context to 200000 (merged in #47908) rather than
  262144; 200k is xAI's published usable context window for Composer 2.5,
  262144 is the /v1/responses input+output budget.
- Update tests to match scoped behavior + add a no-web-toolset guard test.
- AUTHOR_MAP entry for #44341 salvage.

Incomplete-guard (server-side *_call items at in_progress no longer flip
has_incomplete_items) and preflight built-in-tool allowlist kept as-is.
2026-06-17 17:33:32 -07:00
XVVH
6f89e17a33 fix(xai): OAuth Responses native web_search, incomplete guard, grok-composer context
- model_metadata: grok-composer-2.5-fast → 262144 (OAuth slug not in /v1/models)
- codex transport: inject native {"type":"web_search"} for is_xai_responses;
  drop client web_search to avoid duplicate-name 400s
- codex adapter: do not treat in-progress server-side *_call items as incomplete
- tests: adapter, transport build_kwargs, model_metadata, oauth recovery
2026-06-17 17:33:32 -07:00
brooklyn!
4b7a186003
fix(desktop): retry the self-update rebuild once so the app relaunches (#48122)
The desktop self-update runs `hermes update` then `hermes desktop
--build-only`, and only relaunches if the rebuild returns 0. The first
`--build-only` can exit nonzero on a still-settling post-update tree or a
network-blocked Electron fetch that the installer's self-heal repaired
mid-run — so both updaters (the Tauri setup binary and the in-app POSIX
path) bailed before the relaunch step. The update landed but the app
never restarted; a manual launch worked because the heal had completed.

Retry `--build-only` once in both paths before failing, mirroring the
retry-once `hermes update` already does (and the CLI `hermes update`'s
own desktop rebuild). A second run builds clean off the healed dist and
is a near-no-op when the first actually succeeded (content-hash stamp).

- update.rs: retry stage 2; add rebuild_needs_retry() + test
- main.cjs: retry via new update-rebuild.cjs helper (behavior-tested)
2026-06-17 19:33:27 -05:00
Teknium
020e59d3cf
fix(agent): dampen empty-name phantom tool-call loop (#47967) (#48109)
Weak open models (mimo, nemotron-class) that see tool-call XML/JSON sitting in
file contents or tool output get primed and emit their own structured tool
calls mimicking the payload — usually with an empty/whitespace name. Those
calls can't be fuzzy-repaired toward a real tool, so the dispatch loop returns
an error and the model retries. Before this fix, every empty-name error dumped
the full tool catalog back to the model, which fed the priming loop more names
to mimic and inflated context 3-4x across the retry budget.

A blank/whitespace-only tool name now gets a terse anti-priming error that
tells the model in-context tool-call syntax is DATA, with no catalog dump. A
genuinely-wrong-but-nonempty name (a real typo) still gets the full catalog so
the model can self-correct.

Not a sandbox/auth boundary issue: Hermes never parses tool-call text from
content into executable calls (structured tool_calls only; the lone text->call
parser is the Copilot ACP transport and it also rejects empty names). The
reporter's own debug dump confirms the injection never executed.

Behavior-contract test added: empty-name -> terse error, no catalog; nonempty
unknown -> catalog preserved. Exercised end-to-end via run_conversation against
an in-process mock provider.
2026-06-17 17:32:14 -07:00
Ben Barclay
86f2946fbe
fix(dashboard): recover the Chat tab when the agent session ends (NS-504) (#47674)
* fix(dashboard): recover the Chat tab when the agent session ends (NS-504)

In the dashboard Chat tab, when the agent process exits — the user types
`/exit`, or starts a new session that ends the current PTY child — the
`/api/pty` WebSocket closes with a normal code (not one of the
4401/4403/4404/4408/1011 rejection codes the server emits). The frontend
handled only those rejection codes; the normal-exit fallback just printed
"[session ended]" into the dead terminal and stopped, with `wsRef` nulled
and no respawn path. The only recovery was a full page refresh — exactly
the beta report ("typing /exit breaks functionality, no way to restart
without refreshing"; "starting a new session completely breaks the
agent").

On a clean/normal close the Chat tab now flips `sessionEnded` and renders
an in-place "Start new session" overlay (mirroring ChatSidebar's existing
reconnect affordance). Clicking it bumps a `reconnectNonce` that is a
dependency of the connect effect, so the effect tears down and re-runs,
spawning a fresh PTY in place — no page refresh. `onopen` clears the
flag so a successful reconnect dismisses the overlay.

An explicit button (rather than auto-respawn) is deliberate: if the agent
is crash-looping, auto-respawn would hide the failure and spin; the user
stays in control.

Verified against a live uvicorn `/api/pty` socket: a child that exits
closes with a non-rejection code (client sees close_code None / 1000-class),
which is precisely the branch that now sets sessionEnded=true. web
typecheck + vite build clean.

Reported via beta (NS-504).

* docs(assets): add NS-504 chat session recovery infographic
2026-06-18 10:05:26 +10:00
Teknium
9ba4615db2
fix(dump): show commit date instead of release date in hermes debug (#48104)
* feat(mcp): raise default tool-call timeout 120s -> 300s

Port from openai/codex#28234. Long-running MCP tools (web fetches,
sandboxed builds, deep-research servers) routinely exceed 120s, causing
spurious timeout failures. Codex bumped its default MCP tool timeout from
120 to 300 for the same reason.

- _DEFAULT_TOOL_TIMEOUT 120 -> 300 in tools/mcp_tool.py (per-server
  'timeout' config override unchanged)
- update test_default_timeout assertion
- document the default in mcp-config-reference.md

* fix(dump): show commit date instead of release date in hermes dump

The version line in `hermes dump` (the top of the /debug report) appended
the package release date in parentheses, which reads like a wall-clock
"generated at" timestamp and confuses support triage. Replace it with the
date the HEAD commit was actually made, resolved live via
`git log -1 --format=%cd --date=short`, kept next to the commit SHA.

On Docker/wheel installs with no .git the date resolves to '' and the
suffix is simply omitted (the baked SHA still identifies the build).
2026-06-17 16:53:42 -07:00
brooklyn!
c1f9eb0ec4
fix(desktop): resolve electronDist dynamically + self-heal blocked installs (supersedes #48081/#48082) (#48091)
* fix(desktop): resolve electronDist dynamically + self-heal blocked installs

Supersedes the static-path approach (#48081) and the install-step self-heal
(#48082) with a fix that removes the whole failure class instead of chasing each
symptom. Three distinct faults converged into the June desktop-build outage; this
closes all three.

Root cause (the part #48081 left open — "Gap B"):
  build.electronDist was a static relative path in apps/desktop/package.json, but
  npm workspace hoisting is NOT deterministic — depending on the npm version and
  what else is installed, npm nests the workspace-only electron devDep under
  apps/desktop/node_modules/electron OR hoists it to the repo root. A static path
  matches only one layout, so a clean install intermittently fails with "The
  specified electronDist does not exist". #48081 re-pointed the path at the
  nested layout (correct today) but electron-builder reads electronDist
  STATICALLY, so any future hoist change silently breaks it again — only caught
  by a CI invariant, never self-corrected.

Fix:
- scripts/run-electron-builder.cjs: resolve electron the way Node's runtime does
  — require.resolve("electron/package.json") walks node_modules from the desktop
  project upward and finds electron wherever npm actually put it. The path can
  never drift out of sync with the install layout again, on any OS/npm version.
    * dist present -> pass -c.electronDist=<abs>/dist so electron-builder reuses
      the unpacked runtime (keeps the #38673 fast path that dodges the 26.8.x
      missing-binary re-unpack bug).
    * dist absent  -> omit electronDist; electron-builder fetches Electron itself
      via @electron/get honoring electronVersion + ELECTRON_MIRROR.
  package.json: builder script now runs the wrapper; the static build.electronDist
  is removed (the resolver owns it).
- main.py / install.sh / install.ps1: on a dependency-install failure where the
  electron package staged but its dist is missing (electron's install.js
  process.exit(1) on a blocked/throttled binary download — #47266/#47917/#48021),
  repopulate the dist via electron's downloader (canonical, then npmmirror.com)
  and CONTINUE to the build instead of aborting. npm runs postinstall LAST, so
  the only casualty is electron/dist; bailing here is what made the pack-time
  mirror self-heal unreachable on a blocked network. Hard-fail only when electron
  never staged at all (a genuine dependency error).
- The pack-time mirror fallback now retries the build even when the pre-fetch
  can't populate the dist: the wrapper lets electron-builder download Electron
  itself via the mirror, so the retry is no longer a no-op (it was, when
  electronDist was a static path).

The exact 40.10.2 pin (already on main) keeps the third mode — the native
@electron-internal/extract-zip win32 binding that 40.10.3/40.10.4 ship without a
published prebuild — from recurring.

Tests:
- test_desktop_electron_pin.py: replace the static-path-matches-lockfile
  invariant with contracts that there is no hardcoded electronDist to drift, the
  builder script routes through the resolver, and the resolver uses Node module
  resolution + injects -c.electronDist.
- test_gui_command.py: install-failure self-heal continues to build; genuine
  (electron-never-staged) install failure still hard-fails; pack retries under
  the mirror even when the pre-fetch is blocked.

Salvages/supersedes the overlapping community work in #48003 (sitkarev),
#48012 (omegazheng), #48033 (james47kjv), and #48082.

Co-authored-by: sitkarev <59806492+sitkarev@users.noreply.github.com>
Co-authored-by: omegazheng <zheng@omegasys.eu>
Co-authored-by: james47kjv <220877172+james47kjv@users.noreply.github.com>

* fix(desktop): narrow Electron self-heal to real missing-dist failures

Follow-up on #48091 to remove the remaining misdiagnosis risk from the
installer/build fallback path (#46785 concern): only take the Electron
repair/retry path when Electron's package files are staged and dist is actually
missing/corrupt.

- main.py: add _electron_pkg_staged_missing_dist() and use it to gate install
  failure recovery; fail fast for unrelated npm install errors.
- main.py/install.sh/install.ps1: run cache purge + retry only when dist is
  missing; do not retry unrelated tsc/vite/build failures under an
  Electron-specific narrative.
- install.sh/install.ps1: tighten install-stage self-heal guard to require both
  package.json + install.js and missing dist.
- tests: add coverage that install failure hard-fails when Electron dist already
  exists, and update retry test to reflect the tightened recovery condition.

Validation:
- Python tests: 64 passed
- install.sh-related tests included in the run
- Real mac build on this machine:
  - npm ci at repo root: success
  - cd apps/desktop && npm run pack: success
  - electron-builder packaged darwin arm64 and used custom unpacked Electron dist

* refactor(desktop): trim electron self-heal helpers and comments

Deduplicate mirror-retry into _try_redownload_electron_dist / shell
counterparts; shorten wrapper and install-script commentary without
changing recovery semantics.

---------

Co-authored-by: sitkarev <59806492+sitkarev@users.noreply.github.com>
Co-authored-by: omegazheng <zheng@omegasys.eu>
Co-authored-by: james47kjv <220877172+james47kjv@users.noreply.github.com>
2026-06-17 18:48:35 -05:00
Ben
acc8916ac7 test(gateway): live ws-transport round-trip + config-driven registration
- test_ws_transport.py: drives WebSocketRelayTransport against a REAL in-process
  websockets server (not a mock socket): handshake (hello->descriptor), inbound
  frame -> handler, outbound request/response correlation, follow_up routing,
  and clean disconnect failing pending waiters. Skips if websockets is absent.
- test_relay_registration.py: rewritten for the config-driven gate — registers
  when GATEWAY_RELAY_URL is set / an explicit url is passed / force=True; no-op
  without a URL; trailing slash stripped; adapter constructs through the registry.

Full relay suite: 57 passed.
2026-06-17 16:37:45 -07:00
Ben
237fa7d29c feat(gateway): register relay adapter from config; drop HERMES_GATEWAY_RELAY gate
Wire the relay adapter into gateway startup and make activation config-driven
instead of a dark-launch flag.

- gateway/relay/__init__.py: replace relay_enabled()/HERMES_GATEWAY_RELAY with
  relay_url() (GATEWAY_RELAY_URL env or gateway.relay_url in config.yaml) — the
  same shape as gateway.proxy_url. register_relay_adapter() registers when a URL
  is configured and builds a live WebSocketRelayTransport; with no URL it's a
  no-op (direct/single-tenant deployments unaffected). force=True keeps the
  transport-less adapter for unit tests. relay_platform_identity() reads the
  hello platform/botId from GATEWAY_RELAY_PLATFORM/GATEWAY_RELAY_BOT_ID.
- gateway/run.py: call register_relay_adapter() during GatewayRunner.start(),
  right after plugin discovery, so a configured connector relay is registered
  on every boot. Failures are logged, never block startup.

This removes the dark-launch posture: the relay is on whenever it's configured,
shipping the production end state rather than hiding it behind a flag.
2026-06-17 16:37:45 -07:00
Ben
6b03874d07 feat(gateway): production WebSocketRelayTransport + descriptor negotiation
Adds the concrete transport behind the RelayTransport Protocol — the missing
'later-phase work' the relay scaffold deferred. The gateway dials OUT to the
connector over a WebSocket and speaks the newline-delimited JSON frame protocol
(docs/relay-connector-contract.md; connector src/relay/protocol.ts):

- connect(): opens the ws, sends hello{platform,botId}, starts a background
  read loop, and resolves handshake() when the connector's descriptor frame
  arrives.
- inbound frames -> the registered InboundHandler (rebuilt into a MessageEvent
  via _event_from_wire, mapping the snake_case SessionSource wire form back
  onto the gateway dataclasses).
- send_outbound / send_follow_up / get_chat_info: request/response correlated
  by a uuid requestId against a per-request future, with a timeout so a caller
  never hangs; send_interrupt is fire-and-forget.
- disconnect(): cancels the reader, closes the ws, and fails any in-flight
  outbound waiters with a structured error.

RelayAdapter.connect() now negotiates the real CapabilityDescriptor from the
transport and adopts it (_apply_descriptor updates MAX_MESSAGE_LENGTH +
markdown surface), replacing the construction-time placeholder. Lazy
'import websockets' mirrors gateway/platforms/feishu.py; WEBSOCKETS_AVAILABLE
gates construction.
2026-06-17 16:37:45 -07:00
Ben
6e20c1992f docs(gateway): rewrite contract §6 to the A2 trust-boundary model
The contract's §6 still said the connector 'forwards the signed body
byte-for-byte so the gateway's existing crypto validates against unmodified
bytes.' That model is incoherent under an untrusted, disposable tenant
gateway on a shared bot:

- re-validating Twilio HMAC / WeCom crypto needs the shared signing secret
  (handing it over IS the cross-tenant leak),
- WeCom payloads are encrypted with that secret (the connector must decrypt
  at the edge just to route),
- a Discord interaction token lives inside the signed body — you can't both
  preserve the bytes and strip the credential.

Rewrites §6 to the actual model: the connector is the SOLE crypto/identity
boundary — verifies/decrypts at the edge, normalizes to a tenant-scoped
MessageEvent, strips shared-identity capabilities into its vault, and
forwards only the sanitized event. The gateway re-validates nothing (the
invariant test from the crypto-shed commit enforces this). Notes that this
unifies the passthrough + relay planes and points to the connector repo's
capability-trust-boundary.md.

Also documents the follow_up op in §4 (token-less capability action added
in the previous commit). The conformance test (§2/§3 tables) stays green;
contract is unpublished/EXPERIMENTAL so no version-bump ceremony. 55 passed.
2026-06-17 16:37:45 -07:00
Ben
3db9b3e616 feat(gateway): token-less follow_up outbound op (A2 capability action)
The relay outbound surface had send/edit/typing but no way to act on a
SHARED-identity capability (e.g. a Discord interaction follow-up token,
~15min) that the connector captured + stripped at the edge. Under A2 that
credential never reaches the gateway, so the gateway can't just 'send with
the token' — it needs a semantic op naming the session it's already in.

Adds the follow_up op end to end on the gateway side:
- RelayTransport.send_follow_up(action): protocol method. Action carries
  op='follow_up' + session_key + kind + content (+ metadata) and NO token.
- RelayAdapter.send_follow_up(session_key, kind, content, metadata): builds
  that action and returns a SendResult. The connector resolves the real
  capability (its resolveOutboundCapability), enforces the tenant match so
  tenant B can't wield tenant A's capability, and egresses; success=False
  when the capability is absent/expired/mismatched (nothing to retry — a
  leaked gateway holds zero capability material).
- StubConnector records follow_ups + a canned next_follow_up_result.

Tests: round-trips without a token; the wire action carries only session
refs (no credential value field — the 'kind' string is a type ref, not the
secret); failure surfaces when the connector can't resolve; no-transport
fails cleanly. 55 passed. §4 doc entry follows in the contract-rewrite commit.
2026-06-17 16:37:45 -07:00
Ben
c28a02b49d test(gateway): shed platform crypto from the relay path (A2 invariant)
Under the A2 trust model the connector is the SOLE crypto/identity
boundary: it verifies/decrypts every inbound platform payload at the edge
(it holds the tenant secrets), normalizes to a tenant-scoped MessageEvent,
and forwards only the sanitized event. The gateway re-validates nothing —
it cannot without being handed the shared signing secret, which on a
shared bot is itself the cross-tenant leak.

The relay path already imports no platform-crypto today; this locks that
in as an enforced invariant so nobody bolts re-validation (Discord
ed25519, Twilio HMAC, WeCom BizMsgCrypt, generic webhook signature checks)
onto the relay later and silently re-couples the gateway to platform
secrets it must never hold. Verification stays in the direct platform
adapters (gateway/platforms/*) which serve non-relay deployments.

- test_relay_package_imports_no_platform_crypto: AST-walks gateway/relay/*
  and fails on any import of a platform-crypto/verification module.
- test_relay_package_calls_no_signature_verification: fails on any
  verification-symbol reference (ed25519/hmac/bizmsg/verify_*).

Invariants (assert the relation 'relay re-validates nothing'), not frozen
snapshots. Verified the guard bites: injecting a wecom_crypto import makes
it fail, removing it goes green. docs §6 rewrite follows in a later commit.
2026-06-17 16:37:45 -07:00
Ben
e74577ed0f test(gateway): Telegram relay round-trip (Phase 1 generalization proof)
The Phase 1 exit gate requires BOTH Discord and Telegram to round-trip
through the relay stub, but test_relay_roundtrip.py only covered Discord.
Add the Telegram companion exercising its distinct discriminator profile:

- no guild_id — two chats isolate on chat_id alone
- forum topics share one chat_id and isolate by thread_id (the Telegram
  analog of Discord per-guild isolation), shared across participants by
  default (thread_sessions_per_user=False)
- DM isolation by chat_id
- utf16 len_unit + markdown_v2 dialect round-trip and configure the adapter
- outbound send round-trips through the stub

Proves the CapabilityDescriptor + build_session_key generalize beyond
Discord, not just the struct (which the descriptor unit tests already
covered).
2026-06-17 16:37:45 -07:00
Ben
5feec8b4cf test(gateway): enforce relay contract-doc ⟷ Python conformance
Add an invariant test pinning docs/relay-connector-contract.md to the
Python source of truth so the doc (which the connector repo mirrors by
hand) cannot silently drift:

- CapabilityDescriptor §2 table ⟷ dataclass fields + required/optional
- SessionSource wire keys (to_dict output) ⟷ §3 documented fields
- per-platform discriminator columns exist as real SessionSource fields
- guard that is_bot stays off the wire until deliberately promoted

Writing the test surfaced a real gap: §3 only enumerated 5 discriminators
in its per-platform table while to_dict() emits 12 keys. Seven wire keys
the connector must populate (chat_name, chat_topic, user_id_alt,
chat_id_alt, parent_chat_id, message_id, user_name) were undocumented —
a connector author reading the doc would never know to set them. Added a
complete SessionSource wire-field table to §3. The connector's existing
contract.ts already carries all 12, so no connector change is needed; the
doc was the lagging artifact.
2026-06-17 16:37:45 -07:00
Ben
c803661cec fix(gateway): register relay connection checker
The platform-connected-checker invariant test requires every built-in
Platform enum member to have either a generic token path or a bespoke
entry in _PLATFORM_CONNECTED_CHECKERS. Platform.RELAY was added without
one, so test_all_builtins_have_checker_or_generic_token_path failed.

Relay dials OUT to a connector and is 'connected' once an endpoint URL
is configured (extra['relay_url'] or extra['url']); the capability
descriptor is negotiated at handshake time, so the URL is the only
config-level signal in the experimental phase. Add the checker plus a
synthetic-config case exercising its True path.
2026-06-17 16:37:45 -07:00
Ben
c366466d70 test(relay): assert connector stub never leaks into production paths
CI guard: fails if gateway/ or plugins/ ever imports the test-only stub
connector or defines StubConnector. Matches code leaks (imports / class defs),
not prose mentions, so the transport.py docstring reference to the stub's path
is allowed.

Phase 1 complete. Task 1.6 of the gateway-relay plan.
2026-06-17 16:37:45 -07:00
Ben
ab1a42fcea docs: relay<->connector cross-repo contract (v1, experimental)
Formal interface between the Hermes gateway (RelayAdapter) and the Node
connector repo: handshake, CapabilityDescriptor field table, MessageEvent
inbound envelope with per-platform SessionSource discriminators (Discord
guild_id is REQUIRED for server isolation), outbound action set, /stop
interrupt routing, signed-body verify-at-edge/byte-preserving rule, and the
additive-only contract_version policy. Documents bot-identity-vs-tenant
separation so single-bot consolidation (Phase 6) stays open. Read-first
artifact for the connector implementer.

Phase 1, Task 1.5 of the gateway-relay plan.
2026-06-17 16:37:45 -07:00
Ben
a3cdd8c39d feat(relay): route mid-turn /stop over relay interrupt channel
RelayAdapter.on_interrupt(session_key, chat_id) bridges a connector-delivered
mid-turn /stop into the existing interrupt_session_activity path, setting the
per-session _active_sessions Event and clearing typing — cancelling exactly the
targeted session's turn without touching siblings (mirrors test_stop_thread_
sibling isolation). Transport.send_interrupt carries the gateway-side egress to
the connector for socket-owner routing.

Phase 1, Task 1.4 of the gateway-relay plan.
2026-06-17 16:37:45 -07:00
Ben
d0133fd8e4 feat(relay): register RelayAdapter through platform registry (flagged off by default)
register_relay_adapter() registers the generic 'relay' platform via the same
PlatformRegistry path as plugin adapters — no core dispatch changes. OFF by
default (dark-launch): only registers when HERMES_GATEWAY_RELAY is truthy (or
force=True for tests), so existing single-tenant/direct deployments are
unaffected. Factory builds a transport-less RelayAdapter with a placeholder
descriptor; the real descriptor is negotiated at handshake.

Phase 1, Task 1.3 of the gateway-relay plan.
2026-06-17 16:37:45 -07:00
Ben
259e78e175 feat(relay): transport protocol + test-only stub connector
Defines RelayTransport (lifecycle/handshake/inbound/outbound/interrupt) as the
gateway<->connector wire contract; RelayAdapter.connect now registers an inbound
handler that bridges connector-delivered MessageEvents into handle_message.
Adds an in-memory StubConnector under tests/ and an E2E round-trip proving:
connect registers the handler, inbound events reach the adapter, guild_id drives
build_session_key isolation (two guilds -> two keys; same guild/channel/user ->
one), outbound send round-trips, get_chat_info is proxied.

Phase 1, Task 1.2 of the gateway-relay plan.
2026-06-17 16:37:45 -07:00
Ben
b0999c82f3 feat(relay): generic RelayAdapter advertising negotiated capabilities
One BasePlatformAdapter subclass that reads its capability profile from a
CapabilityDescriptor: MAX_MESSAGE_LENGTH attribute, message_len_fn (table-driven
by len_unit: chars=len, utf16=Telegram-style code units), supports_draft_streaming.
Implements the four abstract methods (connect/disconnect/send/get_chat_info) by
delegating to an injected RelayTransport (full protocol lands in Task 1.2). Adds
Platform.RELAY enum member. No per-platform gateway code.

Phase 1, Task 1.1 of the gateway-relay plan.
2026-06-17 16:37:45 -07:00
Ben
3db49381d6 feat(relay): derive descriptor from PlatformEntry
CapabilityDescriptor.from_platform_entry() projects an existing PlatformEntry
(label, max_message_length, emoji, platform_hint, pii_safe, name) into a
descriptor, proving the descriptor is a projection of existing config rather
than a parallel concept. Runtime-only capabilities (len_unit, draft/edit/
thread/markdown) are caller-supplied. max_message_length==0 ('no limit') maps
to the stream_consumer 4096 default.

Phase 0 complete. Task 0.3 of the gateway-relay plan.
2026-06-17 16:37:45 -07:00
Ben
53d9b98305 feat(relay): experimental CapabilityDescriptor schema
Frozen, JSON-serializable handshake payload the connector hands the future
RelayAdapter: char limit, draft-streaming/edit/threading flags, markdown
dialect, len_unit. Mostly a wire projection of PlatformEntry + the adapter
capability methods. contract_version gates additive-only evolution; declared
EXPERIMENTAL until >=2 Class-1 platforms validate it. from_json ignores
unknown keys (forward-compat) and fills optional defaults.

Phase 0, Task 0.2 of the gateway-relay plan.
2026-06-17 16:37:45 -07:00
Ben
e9a2ce6585 test: lock gateway adapter capability surface (relay phase 0)
Behavioral regression harness locking the capability surface that the future
RelayAdapter must reproduce: the abstract-method set (connect/disconnect/send/
get_chat_info), message_len_fn default, supports_draft_streaming default, and
the stream_consumer MAX_MESSAGE_LENGTH attribute read. Passes on main before
any RelayAdapter exists.

Phase 0, Task 0.1 of the gateway-relay plan.
2026-06-17 16:37:45 -07:00
shannonsands
6092be413d
Harden hosted Docker install tree against self-modification (#47490)
* Harden hosted Docker install tree

* Document hosted Docker immutable install tree
2026-06-18 09:09:21 +10:00
Teknium
f8098c6b6f
fix(desktop): resolve electronDist to the actual electron install location (#48081)
After the June lockfile regeneration (#46652) floated electron and reshuffled
npm workspace hoisting, the desktop pack fails with "The specified electronDist
does not exist". apps/desktop/package.json pointed electronDist at the repo
root (../../node_modules/electron/dist) while npm now installs electron nested
under apps/desktop/node_modules/electron. The two contradict, so a clean
install can never package the app (Windows + macOS).

- electronDist -> node_modules/electron/dist (resolved relative to apps/desktop,
  i.e. the workspace-local install npm actually produces).
- hermes_cli/main.py, scripts/install.sh, scripts/install.ps1: add a runtime
  electron-dir resolver that prefers apps/desktop/node_modules/electron and
  falls back to the root hoist, so dist checks + the mirror re-download work
  under either npm layout.
- patch-electron-builder-mac-binary.cjs: try the workspace-local Electron.app
  before the root hoist in the macOS binary-restore fallback (sibling site no
  PR touched).
- test: assert build.electronDist resolves to where the lockfile installs
  electron, so a future hoist change (root <-> nested) can't silently break it.

Salvages the overlapping work in #48003 (sitkarev), #48012 (omegazheng), and
#48033 (james47kjv).

Co-authored-by: sitkarev <59806492+sitkarev@users.noreply.github.com>
Co-authored-by: omegazheng <zheng@omegasys.eu>
Co-authored-by: james47kjv <220877172+james47kjv@users.noreply.github.com>
2026-06-17 18:08:01 -05:00
Austin Pickett
016bce1a09
fix(desktop): recover stranded session windows when resume fails (#47655)
* fix(desktop): recover stranded session windows when resume fails

Opening a session in a new window (or any routed resume) could latch the
thread loader on "session" forever — the reported "stays stuck loading,
even after a nap" bug. Two compounding causes:

1. use-session-actions.resumeSession's catch ran the REST transcript
   fallback OUTSIDE its own try. When session.resume rejected AND the
   fallback also threw (the common case on a wedged/unreachable backend),
   the throw skipped setMessages and left activeSessionId null with an
   empty transcript — exactly the state the loader gates on
   (messagesEmpty && !activeSessionId), with no terminal/error state.

2. use-route-resume's self-heal could never re-fire: resumeSession sets
   selectedStoredSessionIdRef synchronously at entry (before failing), so
   stuckOnRoutedSession stays false, and on an already-open idle window
   neither pathnameChanged nor gatewayBecameOpen fire again. The window
   never retried — naps, focus, nothing recovered it.

Fix:
- Wrap the REST fallback in its own try so a fallback failure can't strand
  the loader.
- Add $resumeFailedSessionId: armed on terminal resume failure, cleared at
  the next resume's entry (and left clear on success).
- use-route-resume gains a bounded backoff auto-retry (4 attempts, 1s→8s)
  that re-resumes while the routed session matches the failure flag, with a
  fire-time liveness recheck so a recovered session isn't double-resumed.

Regression tests cover: fallback-wrap arming the flag without throwing,
flag cleared on success, retry fires on backoff, no retry for a
non-routed/recovered session, and the retry cap.

* feat(desktop): show error + manual Retry when resume retries exhaust

When a stranded session window's bounded auto-retry gives up (gateway
resume RPC + REST fallback fail through all MAX_RESUME_RETRIES attempts),
the loader latched forever. Add a $resumeExhaustedSessionId atom armed at
the give-up point so the chat view swaps the perpetual spinner for an
explicit error state + manual Retry button. Retry / reconnect / reselect
clears the latch and resets the auto-retry counter for a fresh cycle; a
route-change away from the stranded session also clears it.

Distinct from $resumeFailedSessionId (armed during the backoff window) so
the error UI only appears once auto-recovery has actually given up, not
mid-retry. Adds i18n strings across en/ja/zh/zh-hant and 3 tests covering
latch-arms-on-exhaustion, stays-clear-while-retries-remain, and
clears-on-route-change.

* fix(desktop): address review on stranded-resume recovery layer

Follow-up to review on #47655 (PR head 253bfc0e3). Four issues on the
recovery layer:

1. (blocking) Arm $resumeFailedSessionId only when the transcript is still
   empty after the REST fallback ($messages.get().length === 0), matching the
   atom's documented contract and the loader's messagesEmpty gate. Previously
   armed on any resume-RPC reject regardless of fallback outcome, so a window
   that recovered its history via REST still auto-retried and, on exhaustion,
   blanked the visible transcript behind the error overlay.

2. Reset the bounded-retry attempt counter on the $resumeExhaustedSessionId
   armed->cleared edge so a manual Retry / reconnect / reselect on the SAME
   stranded session gets a fresh backoff cycle, not a single one-shot attempt
   that immediately re-arms the error. (Keyed on the exhausted latch rather
   than the resumeFailedSessionId null->value transition the review suggested:
   the auto-retry loop itself toggles resumeFailedSessionId every cycle, so
   keying the reset there would defeat the MAX_RESUME_RETRIES cap. Only
   resumeSession clears the exhausted latch, making its clear edge the
   unambiguous manual-retry signal.)

3. Advance retryAttemptRef only when the timer actually dispatches a resume,
   not at schedule time. Prevents unrelated dep changes during the 1s-8s
   backoff window (transient gatewayState flip, non-stable resumeSession) from
   burning attempts and hitting MAX with fewer than 4 real resume attempts.

4. Drop unrelated blank-line-only insertions in store/session.ts and
   use-session-actions.ts to keep the diff tight.

Tests: +3 (RPC-fails-REST-succeeds-no-arm; manual-retry-fresh-cycle;
no-attempts-burned-on-dep-churn). All 19 resume tests + full session-hook
suite (65) pass; tsc --noEmit clean.

---------

Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
2026-06-17 17:33:53 -04:00
Austin Pickett
fd674af47f
fix(photon): preserve text in mixed iMessage attachments (salvage #46513) (#46818)
* fix(photon): preserve text in mixed iMessage attachments

When an iMessage bubble carried both text and an attachment, spectrum-ts'
inbound mapper returned only buildAttachmentMessage(...), dropping the user's
typed text before Hermes could see it. The Photon adapter then had no 'group'
content path, so the text was lost entirely.

- adapter.py: handle a new 'group' content type that flattens text + attachment
  items, preserving the typed text alongside cached media (extracted shared
  _normalize_binary_payload helper).
- sidecar: emit 'group' content in normalizeContent, and ship
  patch-spectrum-mixed-attachments.mjs which patches spectrum-ts' pinned mapper
  (at npm postinstall AND at sidecar startup, so existing installs self-heal).

Windows robustness fixes on top of the original PR:
- The patcher's CLI guard used 'import.meta.url === file://${argv[1]}', which
  never matches on Windows (file:/// + drive letter) — it silently no-opped.
  Switched to pathToFileURL(argv[1]).href.
- The patcher matched \n-joined strings, so a CRLF checkout (Windows git
  autocrlf) defeated every replacement. It now normalizes CRLF->LF for matching
  and restores the original EOL style on write.

Co-authored-by: Yuhang Lin <yuhanglin@YuhangdeMac-mini.local>

* chore: map YuhangLin contributor email for attribution (#46513)

---------

Co-authored-by: Yuhang Lin <yuhanglin@YuhangdeMac-mini.local>
Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
2026-06-17 16:14:24 -05:00
kshitij
7fbb8c9df5
Merge pull request #48042 from kshitijk4poor/salvage-47662
fix(openviking): implement on_session_switch hook + harden session writes (salvage #47662)
2026-06-18 02:34:27 +05:30
Austin Pickett
ee41aa0c1a
feat(desktop): add dismiss control to chat error banners (#47985)
A failed turn leaves a red error banner inline in the transcript. These
errors are renderer-local state (never persisted) and stay pinned to the
message until the session is reloaded, so a stale, no-longer-relevant
error (e.g. a transient provider/inference error) lingers with no way to
clear it.

Add an 'x' dismiss button inside the existing MessagePrimitive.Error
block. Clicking it clears the error from BOTH the live $messages view
and the per-runtime session cache — the view first, because
preserveLocalAssistantErrors re-grafts any still-errored message it finds
in the view onto the next session.info flush, so clearing only the cache
would let the heartbeat resurrect the banner. A bare error placeholder
(no streamed content) is dropped entirely; a turn that streamed partial
output before failing keeps its text and just sheds the error.

The control only renders when an onDismissError handler is wired, so
secondary/embedded Thread usages are unaffected. Adds the dismissError
string to all four locales (en/ja/zh/zh-hant) and two behavior tests.

Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
2026-06-17 16:46:43 -04:00
Austin Pickett
5a00bd1518
fix(desktop): persist /title set before the first message instead of queuing (#47987)
A /title typed before any message in a fresh desktop chat could be silently
lost: the session DB row is deferred to the first prompt, so session.title
found no row, only stashed pending_title, and returned pending:true. It then
relied on a post-turn apply block to write the title. When that turn never
landed under the same session_key (or the apply path didn't fire), the title
was dropped and the sidebar fell back to the first-message preview — e.g.
"/title my-custom-name" then "hello" left the session titled "hello".

Mirror the messaging gateway's _handle_title_command: an explicit /title is
clear user intent, not an abandoned draft, so create the row up front
(_ensure_session_db_row) and set the title immediately via the profile-aware
_session_db handle, returning pending:false. This also fixes the frontend
symptom for free — the desktop handler's immediate refreshSessions() now pulls
the correct persisted title instead of clobbering the optimistic value with a
still-NULL row.

If row creation can't take (DB unavailable / racing writer), fall back to the
existing pending_title queue so the post-turn apply block remains a recovery
path. The sidebar's min-messages filter keeps a titled 0-message row hidden, so
a /title'd-but-never-used draft still doesn't clutter the list.

Updates the test that asserted the old queue-on-missing-row behavior and adds a
fallback-to-queue regression test.

Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
2026-06-17 16:46:21 -04:00
Teknium
22b6942fc2
feat(search_files): headroom compression evaluation report + lossless densification (#47866)
* feat(search_files): path-grouped lossless densification of content matches

Content-mode search_files results repeat the {path,line,content} JSON keys
and the full path string for every match. Group consecutive same-path matches
under one path header with indented '<line>: <content>' rows — lossless (every
path/line/content byte preserved), self-describing (matches_format key), and
readable by the model with no decode step.

57.8% mean token reduction on real search_files content outputs (422-output
corpus), fires on 97% of them. Gated at >=5 matches; below that the verbose
array is left untouched. Default to_dict(densify=False) is unchanged, so no
other caller is affected.

ripgrep emits matches path-ordered, so consecutive grouping never reorders
results.

* test: accept densify kwarg in _FakeSearchResult.to_dict

The search loop-detection tests stub SearchResult with a fake whose
to_dict() must mirror the real signature now that it takes densify=.

* test(search_files): edge-case losslessness battery for densification

Adversarial single-line content (colons, indentation, unicode/emoji, empty,
trailing whitespace, quotes+commas), paths with spaces, and an explicit
one-line-per-match invariant documenting the ripgrep contract the format
relies on (0/6775 real match contents contained a newline).
2026-06-17 13:45:25 -07:00
Austin Pickett
394cdf48ce
fix(logging): alias RotatingFileHandler to concurrent-log-handler (salvage #44921) (#46794)
* fix(logging): alias RotatingFileHandler to concurrent-log-handler

On Windows, stdlib RotatingFileHandler.doRollover() uses os.rename(), which
fails with PermissionError [WinError 32] whenever another process holds an
append-mode handle on agent.log — essentially always in Hermes (TUI, gateway,
hy_memory server, MCP servers, and on-demand CLI commands all log from separate
processes). This pinned agent.log at the 5 MiB threshold and spammed stderr
with a traceback on every emit (#44873).

Add concurrent-log-handler==0.9.29 as a core dep and alias its
ConcurrentRotatingFileHandler as RotatingFileHandler in hermes_logging.py. It
wraps the rename in a cross-process file lock (via portalocker: pywin32 on
Windows, fcntl on POSIX) so only one process rotates at a time. Aliasing keeps
every existing isinstance/class-declaration reference working unchanged.

Co-authored-by: tuancookiez-hub <tuancookiez@gmail.com>

* fix(logging): gate concurrent-log-handler swap to Windows only

The initial salvage aliased RotatingFileHandler -> ConcurrentRotatingFileHandler
unconditionally, which regressed POSIX: CLH opens lazily and rotates via its own
lock path, breaking managed-mode (NixOS) group-writable perms and eager file
creation that _ManagedRotatingFileHandler depends on. CI caught it as 2 failures
in test_managed_mode_*_group_writable on Linux.

The WinError 32 bug (#44873) is Windows-specific — POSIX renames an open file
fine, so stdlib already works on Linux/macOS. Gate the swap behind
sys.platform == 'win32': Windows uses CLH, POSIX keeps stdlib RotatingFileHandler.

- hermes_logging.py: platform-conditional import.
- tests/test_hermes_logging.py: import RotatingFileHandler from hermes_logging
  (single source of truth) so the autouse fixture's isinstance checks match the
  real handler class on both platforms.
- pyproject.toml/uv.lock: mark the dep 'sys_platform == "win32"' so portalocker
  /pywin32 only ship where used.

---------

Co-authored-by: tuancookiez-hub <tuancookiez@gmail.com>
Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
2026-06-17 15:39:04 -05:00
kshitijk4poor
c835448908 fix(openviking): don't block the command thread on session switch; lock turn state
Follow-up hardening on @ehz0ah / @harshitAgr's session-switch work (#28296):

- on_session_switch no longer runs the old-session writer-drain + pending-token
  GET + commit POST inline on the caller's command thread. /new, /branch,
  /resume, /undo call it synchronously, so a slow drain (up to 10s) or wedged
  commit blocked the user-facing command — the same hazard #41945 fixed for
  end-of-turn sync. State now rotates synchronously (cheap) and the old-session
  commit is offloaded to a daemon finalizer (generalized _finalize_session_async).
- Guard the (_session_id, _turn_count) pair with _session_state_lock: sync_turn
  runs on the memory-manager executor thread while the session hooks run on the
  command thread, so the snapshot+reset vs increment was a cross-thread race.
- _session_needs_commit checks the committed-session guard BEFORE the
  turn_count>0 shortcut, closing a double-commit window when a racing sync_turn
  re-increments after commit+reset.
- Add a _shutting_down flag so deferred finalizers stop POSTing against a
  torn-down client; track all prefetch threads in a set so invalidate/shutdown
  join every one, not just the latest slot.

Tests: regression for the non-blocking switch (asserts the caller returns while
a slow drain is parked off-thread) and the committed-guard ordering; updated the
deferred-commit test to the unified finalizer contract.
2026-06-18 00:21:21 +05:30
xxxigm
33b1d14459
fix(desktop): pin Electron below the broken native extract-zip install (#47792)
* fix(desktop): pin Electron below the broken native extract-zip install

The Windows desktop install fails at "Building desktop app": Electron's
postinstall aborts with `ERR_DLOPEN_FAILED loading
index.win32-x64-msvc.node` / "Cannot find native binding" from
`@electron-internal/extract-zip`.

Root cause is a dependency drift, not the user's machine. Electron changed
its install mechanism mid-patch-series:

  electron 40.9.3 .. 40.10.2  -> @electron/get@^2 + extract-zip@^2 (pure JS)
  electron 40.10.3 / 40.10.4  -> @electron/get@^5 + @electron-internal/extract-zip@^1 (native napi)

apps/desktop declares `electronVersion: 40.9.3` (the tested, JS-extract
build) but pinned the dependency as `electron: ^40.9.3`, so `npm ci`/`npm
install` silently resolved 40.10.3/40.10.4 — onto the brand-new native
extract-zip whose win32-x64 binding fails to dlopen on some Windows hosts.
The committed lockfile already carried 40.10.3, and the installer's mirror
fallback can't help (it re-runs Electron's own `install.js`, which uses the
same broken native module).

Fix:
- Pin `electron` to an exact `40.10.2` — the newest build before the native
  extract-zip switch — and align `build.electronVersion` to match (Electron
  Builder needs electronVersion/electronDist to match the installed binary).
- Add a root `yauzl: ^3.3.1` override so the (re-introduced) JS extract-zip
  path also works on Node >= 24.16 / >= 26.1, where the old yauzl hangs.
  This is the same workaround the wider Electron ecosystem adopted.
- Regenerate package-lock.json: drops @electron-internal/extract-zip and
  @electron/get@5, restores @electron/get@2 + extract-zip@2 + yauzl@3.4.0.

* test(desktop): lock the Electron pin/version/lockfile consistency contract

Guards against the dependency drift that broke the Windows desktop install:
the Electron dependency must be an exact version, must equal
build.electronVersion, and the lockfile must resolve to that same version so
`npm ci` installs exactly what electron-builder packages. Asserts the
relationships, not a specific version number.
2026-06-17 14:42:30 -04:00
xxxigm
b07b7894ec
fix(desktop): keep streaming painting in unfocused secondary chat windows (#47919)
* fix(desktop): keep streaming painting in unfocused secondary chat windows

The chat transcript streams to screen through a requestAnimationFrame-gated
flush, which Chromium pauses for blurred/occluded windows. The primary window
opted out with `backgroundThrottling: false`, but the secondary "session
windows" (cmd-click pop-out, new-session, subagent-watch) hand-copied their
webPreferences and silently lost that flag — so a streamed answer in one of them
stalled until the window regained focus (reported on Windows 11). The primary
window's own comment even claimed it was "matching the secondary windows," which
was no longer true.

Hoist the chat-window webPreferences into a single shared factory
(`chatWindowWebPreferences`) in session-windows.cjs and use it for BOTH windows,
so they can never drift on this flag again.

* test(desktop): assert chat windows disable background throttling

Cover chatWindowWebPreferences: it must set backgroundThrottling=false (so the
streaming transcript paints while the window is blurred) and pass the preload
path through while keeping the hardened defaults (contextIsolation, sandbox,
nodeIntegration=false).
2026-06-17 14:40:13 -04:00
kshitijk4poor
0c1e8d0ba9 Merge remote-tracking branch 'upstream/main' into salvage-47662
# Conflicts:
#	tests/openviking_plugin/test_openviking.py
2026-06-17 23:59:24 +05:30
kshitij
1e6c4ba74f
Merge pull request #47973 from kshitijk4poor/fix/ov-skill-scaffolding
fix(tests): type-correct OpenViking skill-scaffolding test sentinels
2026-06-17 23:49:25 +05:30
kshitijk4poor
4de4a4e2da fix(tests): type-correct OpenViking skill-scaffolding test sentinels 2026-06-17 23:44:31 +05:30
kshitij
49d7481dfb
Merge pull request #47706 from NousResearch/fix/cli-login-deprecation-graceful
fix(cli): deprecated `hermes login` fails gracefully for any provider
2026-06-17 23:02:32 +05:30
teknium1
aa6f77596b chore: add AUTHOR_MAP entry for #47904 salvage 2026-06-17 09:49:46 -07:00
definitelynotguru
eaddeaf2e6 feat(xai): add grok-composer-2.5-fast to xAI OAuth model picker
The model is callable via xAI OAuth but omitted from models.dev and
/v1/models listings. Merge it into the curated xAI catalog so it appears
in `hermes model` without requiring a custom model name.
2026-06-17 09:49:46 -07:00
teknium1
cc9f37e77c chore: map Rivuza to AUTHOR_MAP for #44249 salvage 2026-06-17 09:49:39 -07:00
Reiji Kisaragi
3d21666b2f fix: preserve multimodal user content during persistence
Avoid applying text-only persist_user_message overrides to multimodal current-turn user messages. Early crash-resilience persistence mutates the same messages list later used for the API call, so clobbering list content drops ACP image blocks before model dispatch.\n\nAdd regression coverage for both text override behavior and multimodal preservation.\n\nCloses #44242
2026-06-17 09:49:39 -07:00
xxxigm
c2fa302e93
Merge pull request #47913 from xxxigm/fix/desktop-backend-skew-toast-nag
fix(desktop): stop the "Backend out of date" toast nagging on every session open
2026-06-17 10:04:34 -05:00
Teknium
c6c8abbadb
refactor: remove agent-callable send_message tool (#47856)
* feat(mcp): raise default tool-call timeout 120s -> 300s

Port from openai/codex#28234. Long-running MCP tools (web fetches,
sandboxed builds, deep-research servers) routinely exceed 120s, causing
spurious timeout failures. Codex bumped its default MCP tool timeout from
120 to 300 for the same reason.

- _DEFAULT_TOOL_TIMEOUT 120 -> 300 in tools/mcp_tool.py (per-server
  'timeout' config override unchanged)
- update test_default_timeout assertion
- document the default in mcp-config-reference.md

* refactor: remove agent-callable send_message tool

The agent should not decide on its own to fire off cross-platform
messages or reactions. Outbound platform messaging is handled outside
the agent loop — cron delivery, the gateway kanban notifier
(dashboard-toggled), and the `hermes send` CLI.

Removes the model-tool registration only; the send engine in
send_message_tool.py (_send_to_platform, _send_via_adapter,
_parse_target_ref, per-platform _send_* helpers) is kept intact for
those non-agent callers. Drops the now-empty 'messaging' toolset and
its `hermes tools` toggle. Yuanbao DM guidance now points at the
native yb_send_dm tool.
2026-06-17 07:11:23 -07:00
brooklyn!
f10f7114f9
Merge pull request #47664 from NousResearch/bb/desktop-markdown-spread-overflow
fix(desktop): stop a single message from crashing or freezing the chat
2026-06-17 08:37:06 -05:00
Brooklyn Nicholson
0138282f97 perf(desktop): keep oversized messages from freezing the chat
A multi-MB message (logged bundle, huge tool dump) froze the renderer
before any paint: Streamdown runs `preprocess` + `marked` lex over the
whole string synchronously in a useMemo, an uninterruptible long task
that no try/catch or content-visibility can help (our JS runs before the
browser ever skips layout). Tiered fix:

- Message gate: past 200KB, bypass markdown entirely and render the raw
  text in `content-visibility:auto` line-chunks — synchronous work is
  bounded to a string split, the browser virtualizes layout natively,
  and every line stays in the DOM (selectable, find-in-page).
- Code-block budget: past 3k lines / 150KB, skip Shiki (which emits a
  span per token) and render plain, chunked the same way.
- Collapse/expand: a reusable ExpandableBlock clamps code blocks and the
  huge-text fallback to a 120px preview with a gradient + chevron,
  expanding to 300px. The inner element is always a scroll container so
  the content-visibility chunks stay lazily laid out in both states.

No content is ever dropped; the copy button (card header) always yields
the full block.
2026-06-17 08:25:52 -05:00
Max Freedom Pollard
992b922389 fix(curator): stop restore from matching unrelated skills by name prefix
restore_skill() falls back to p.name.startswith(f"{skill_name}-") when no
archive directory matches the requested name exactly. That fallback is meant
to catch the timestamped duplicate archive_skill() writes on a name collision
(<skill>-YYYYMMDDHHMMSS), but the bare prefix also matches any unrelated
archived skill named <name>-something. So restoring "git" can pull an archived
"git-helpers" out of .archive/, rename it to "git", and report success: the
requested skill is not restored and the sibling is gone from the archive.

Constrain the fallback to the exact suffix archive_skill() produces, a 14 digit
timestamp. The exact-name match and the recursive nested-archive walk are
unchanged, so nested and timestamped restores still work; unrelated siblings no
longer match.

Fixes #47647
2026-06-17 06:04:03 -07:00
Teknium
cbfa018aef
fix(auth): retry Codex device-code login on 429 with clear rate-limit message (#47860)
The OpenAI device-code login (POST auth.openai.com/.../deviceauth/usercode)
had no retry or 429 handling — a transient throttle from OpenAI surfaced as
a bare "Device code request returned status 429" with no guidance, reading
as a hard login failure.

- Retry the device-code request with capped exponential backoff (honoring
  Retry-After), up to 4 attempts.
- On persistent 429, raise a clear AuthError tagged CODEX_RATE_LIMITED_CODE
  (classified transient, not a credential problem) with a wait hint.
- Apply the same 429 classification to the token-exchange step (same bug
  class).

Unrelated to PR #47399 (Responses-API cache headers); this is the OAuth
device-code path in hermes_cli/auth.py.
2026-06-17 05:48:35 -07:00
teknium1
06d907dc4e fix(dashboard): only run runtime-pid liveness fallback against local status
get_runtime_status_running_pid() validates liveness with a local
os.kill(pid, 0) probe. In /api/status the runtime record can be the
REMOTE health-probe body (cross-container), whose PID belongs to another
host and is display-only — probing it locally is wrong and trips the
test live-system guard (os.kill on a PID outside the test subtree).
Run the fallback only against the local read_runtime_status() record.
2026-06-17 05:40:57 -07:00
teknium1
dc86d48a3e fix(dashboard): use await-safe config-only scope for /api/status profile
_profile_scope swaps process-global skills_tool/skill_manager module
attrs under an RLock; /api/status holds that scope across the
run_in_executor remote-health probe await, so a concurrent
/api/skills?profile=X request can cross-restore the status profile's
skill dir on its finally. Add _config_profile_scope (contextvar-only,
task-local, await-safe) and use it for status, which only resolves
get_hermes_home() at call time for config/env/gateway state and never
needs the skills-module globals.
2026-06-17 05:40:57 -07:00
Shannon Sands
674e8b098a Fix dashboard gateway profile scoping 2026-06-17 05:40:57 -07:00
Teknium
f80381c456
feat(prompt): scale context-file cap to model window + point agent at truncated file (#47846)
Context files (AGENTS.md, CLAUDE.md, .hermes.md, .cursorrules, SOUL.md) were
hard-capped at a flat 20K chars before head/tail truncation. Among the agent
harnesses we track, only Codex caps project docs at all (32 KiB); Claude Code,
OpenCode, and Cline load them whole. The flat 20K predates large context
windows and silently truncates real-world AGENTS.md files.

B — dynamic cap: when context_file_max_chars is unset (now the shipped
default), the cap scales with the model's context window
(ctx_tokens * 4 * 0.06, floor 20K, ceiling 500K). Small-context models stay at
the historical 20K; a 200K model gets 48K; large models stop truncating real
docs. An explicit context_file_max_chars still wins. Context length is resolved
once per conversation (stable -> prompt cache untouched).

C — when truncation does happen, the marker now names the concrete file path
and tells the agent to read_file it for the full content.

Validation: 154 targeted tests + full agent/ + hermes_cli/ + test_config
(0 failures); E2E against a real 60K AGENTS.md confirms small windows truncate
with the path-bearing marker, large windows load whole, and the system prompt
is byte-stable across rebuilds.
2026-06-17 05:40:26 -07:00
teknium1
49ef0241eb chore(release): map Adolanium author email for PR #44628 salvage 2026-06-17 05:40:15 -07:00
Adolanium
f4100f4394 fix(desktop): list markers and quote border follow RTL message direction
unicode-bidi:plaintext (#44596) resolves text direction per line, but
list markers and the blockquote border are box chrome driven by the CSS
direction property, which plaintext never sets, so an RTL list renders
its numbers stranded at the far left edge. CSS cannot close this gap
(:dir() only reads the dir attribute, never plaintext resolution), so
ul/ol/blockquote carry dir="auto" and the browser resolves their box
direction natively while the plaintext rules keep owning the text.
Inline code carries dir="ltr", which HTML's auto algorithm skips,
matching the no-vote contract the CSS isolate already gives it.
2026-06-17 05:40:15 -07:00
Max Freedom Pollard
fc1119ca66 fix(curator): stop the rollback safety snapshot from pruning its target
Rolling back to the oldest curator snapshot failed and deleted that
snapshot. rollback() takes a safety snapshot first, and snapshot_skills()
ends by pruning the backups directory down to keep (5 by default). At the
steady keep limit that prune removed the oldest snapshot, which is the very
one being restored, so the extract found no skills.tar.gz and the rollback
stopped with "snapshot extract failed (state restored)".

Thread an optional protect set through snapshot_skills() into _prune_old()
so the pre rollback safety snapshot can never evict the snapshot being
restored. Add two regression tests covering restore of the oldest snapshot
at the keep limit.

Fixes #47612
2026-06-17 05:40:05 -07:00
Teknium
7bbffceb9c
feat(curator): make skill consolidation opt-in (prune stays default-on) (#47840)
The curator now defaults to prune-only: the deterministic inactivity pass
(mark stale / archive long-unused skills) still runs whenever the curator is
enabled, but the opinionated LLM umbrella-building consolidation fork is OFF
by default.

- agent/curator.py: add DEFAULT_CONSOLIDATE=False + get_consolidate(); gate
  the forked aux-model review in run_curator_review behind it (new consolidate
  param, None=read config). When off, the LLM pass is skipped entirely (no
  aux-model cost); the run is still recorded and reported.
- config.py: add curator.consolidate (default false); v29->v30 migration seeds
  the key for existing installs without clobbering a user-set value.
- hermes_cli/curator.py: 'hermes curator run --consolidate' override; status
  shows consolidate state; prune-only notice on run.
- docs + tests.
2026-06-17 05:20:32 -07:00
Teknium
e48803daec
fix(gateway): defer macOS launchd reload when run inside the gateway tree (#47842)
When refresh_launchd_plist_if_needed() runs from inside the gateway's own
launchd process tree (agent-initiated self-update via the terminal tool), a
direct launchctl bootout tears down the service's process group — including
the CLI doing the refresh — before the follow-up bootstrap can run. The
gateway is left unloaded and KeepAlive can't revive it (#43842).

Detect in-service execution via gateway.status.get_running_pid() +
_is_pid_ancestor_of_current_process(), and delegate the bootout->bootstrap to
a detached (start_new_session=True) helper that survives the process-group
teardown. The normal out-of-tree CLI path is unchanged.

Fixes #43842.
2026-06-17 05:19:21 -07:00
kyssta-exe
4d39a603d1 fix(codex): restore session_id/x-client-request-id HTTP headers for cache routing (#47335) 2026-06-17 05:13:12 -07:00
Brooklyn Nicholson
435c706e8e fix(desktop): stop a failed turn leaking into every other thread
A turn that ends in an error (e.g. an out-of-funds state) was being
re-rendered in unrelated threads. On a warm thread switch the on-screen
`$messages` still belongs to the previously viewed thread, and
`flushPendingViewState` fed it into `preserveLocalAssistantErrors`, which
grafted the prior thread's failed turn onto the newly opened one. Because
the polluted view then became the next switch's baseline, the error
cascaded into every thread the user visited.

Only carry local errors across a view flush when the on-screen baseline is
the same session being flushed; the cached state we publish already retains
that session's own errors. Also surface the turn error as a global toast
even when the failing turn ran in a background thread, since the error
blocks all subsequent interactions until the user acts.
2026-06-17 05:07:48 -07:00
kshitij
f9c8d95e43
Merge pull request #47723 from NousResearch/salvage/oauth-mcp-prefix
fix(anthropic): no single-underscore mcp_ tool names on the OAuth wire (plan-limit billing)
2026-06-17 13:26:02 +05:30
kshitijk4poor
b70a4e7533 fix(anthropic): also normalize MCP-server tool names to mcp__ on OAuth wire
The double-underscore prefix swap fixed bare native tools but SKIPPED tools
already named mcp_<server>_<tool> (real MCP servers, e.g. mcp_linear_get_issue):
they went on the OAuth wire single-underscore and still tripped Anthropic's
third-party billing classifier -> HTTP 400 'extra usage, not plan limits'.
Verified empirically against a live Max subscription: a single mcp_ tool flips
the whole request to the extra-usage lane; mcp__ is accepted.

- build_anthropic_kwargs: promote ANY leading single-underscore mcp_ to mcp__
  (bare names -> mcp__name; mcp_<server>_<tool> -> mcp__<server>_<tool>),
  never double-prefixing an already-mcp__ name. Same for tool_use blocks in
  history.
- normalize_response: reverse the mcp__ wire name back to whichever original
  the registry knows — the single-underscore mcp_<server>_<tool> form for MCP
  server tools, or the bare name for native tools — preferring a name that
  already resolves natively.
- Tests rewritten to assert the invariant: ZERO single-underscore mcp_ names
  reach the OAuth wire, and the mcp__ round-trip resolves back to the
  registered name for both native and MCP-server tools.

Builds on liuhao1024's mcp__ prefix commit (cherry-picked). Closes the
MCP-server gap that left any session with an MCP server configured still
billing to extra usage.
2026-06-17 13:20:29 +05:30
liuhao1024
3d37869295 fix(anthropic): use double-underscore mcp__ prefix for OAuth tool names
Anthropic's Claude-Code request classifier treats tool names with a
single-underscore `mcp_<x>` prefix as non-Claude-Code / third-party,
routing the request to extra-usage billing (HTTP 400). Real Claude Code
uses double underscores: `mcp__<server>__<tool>`.

Change the tool-name prefix from `mcp_` to `mcp__` in both the outgoing
path (build_anthropic_kwargs) and the incoming path
(normalize_response). Update the skip-guard to check for both `mcp_`
and `mcp__` prefixes so native MCP server tools (which use the legacy
single-underscore format) are not double-prefixed.

Fixes #46675
2026-06-17 13:12:23 +05:30
kshitijk4poor
a7ec334448 fix(cli): deprecated hermes login fails gracefully for any provider
`hermes login` was removed in favor of `hermes auth` / `hermes model`, but
the subparser still validated `--provider` against a hardcoded choices list
(nous, openai-codex, xai-oauth). Running `hermes login --provider anthropic`
therefore crashed in argparse with `invalid choice: 'anthropic'` *before* the
deprecation handler could print the redirect to `hermes model` — so a user
trying to authenticate a perfectly valid provider just saw a hard error and
assumed the feature was broken rather than relocated.

- Drop the restrictive `choices=` so every `--provider` value reaches the
  deprecation handler (which ignores the value and prints guidance).
- Omit the subparser `help=` kwarg so the dead command no longer advertises
  itself in `hermes --help` (#24756). Avoids the `==SUPPRESS==` placeholder
  leak that `help=argparse.SUPPRESS` emits for a top-level subparser on 3.12+.
- `hermes login [--flags]` still reaches the actionable deprecation message
  for old scripts/aliases; `hermes login --help` shows the redirect.

Picks up the intent of the inactivity-closed #24902, rebased onto the
post-refactor parser location (hermes_cli/subcommands/login.py) and extended
to fix the whole bug class (any provider value), not just hiding from --help.

Tests: parametrized provider acceptance + help-suppression (no SUPPRESS leak).
2026-06-17 12:55:40 +05:30
kshitij
9901141d64
Merge pull request #47701 from kshitijk4poor/salvage/cli-completer-keystroke-latency
fix(cli): keep typing responsive by running completion off the UI event loop
2026-06-17 12:42:50 +05:30
kshitijk4poor
ca6542f602 docs(cli): note URL exclusion in _extract_path_word docstring
The docstring described a token as path-like when it contains a "/"
separator, but the keystroke-latency fix now excludes "://" scheme tokens
(URLs) even though they contain "/". Document the exclusion so the contract
matches the behavior.
2026-06-17 12:36:01 +05:30
Hao Zhe
99a20f8d9a test(openviking): update plugin expectations 2026-06-17 15:05:51 +08:00
kshitijk4poor
fbaad3031a test(cli): URL tokens must not trigger filesystem path completion
Regression coverage for the keystroke-latency fix: a URL token contains
"/", so the bare-slash path heuristic used to return it as a path word and
run os.listdir on every keystroke. Assert _extract_path_word rejects
http/https/ssh scheme tokens, that ordinary paths (incl. a bare colon) are
unaffected, and that the completer never touches the filesystem for a URL
under the cursor.
2026-06-17 12:33:56 +05:30
xxxigm
f48b312037 fix(cli): keep typing responsive by not blocking the keystroke loop
The interactive CLI input box runs its completer with
`complete_while_typing=True`, so `SlashCommandCompleter.get_completions`
is invoked on *every* keystroke. That completer does blocking I/O:
fuzzy `@`-file indexing shells out to `rg`/`fd` (up to a 2s timeout) and
file-path completion calls `os.listdir` + `stat`. Because the completer
was passed inline (never wrapped in `ThreadedCompleter`), all of this ran
synchronously on the prompt_toolkit event loop, stalling the render after
each key — very noticeable on WSL2 and other slow-filesystem setups
("typing in the prompt box being very latent").

Two fixes:

- Wrap the input completer in `ThreadedCompleter` so completion work runs
  off the UI event loop and never blocks rendering between keystrokes.
- Stop treating URLs as file paths in `_extract_path_word`: a token like
  `https://example.com/x` contains `/`, so it triggered `os.listdir` on
  every keystroke while typing/pasting a link (listing a bogus `https:`
  dir) for a completion that can never be useful. Skip any token with a
  `://` scheme separator.

(cherry picked from commit b5be2ba276c29cc12fce1d1a580bc782cd557353)
2026-06-17 12:32:38 +05:30
Hao Zhe
3ac6551ba3 fix(openviking): handle rewound session switches 2026-06-17 14:46:06 +08:00
Brooklyn Nicholson
b82eca2beb fix(desktop): isolate message render crashes from the root boundary
Streamdown runs our `preprocess` inside its own useMemo, and the user
bubble runs `extractEmbeddedImages`/directive parsing inside theirs — so
anything thrown while rendering one message (a regex/stack overflow on
adversarial content) escapes to the ROOT error boundary and takes down
the entire app, as seen in a reported `RangeError: Maximum call stack
size exceeded` from a single message.

Wrap both the assistant preprocess pipeline and the user-message
directive passes in try/catch that degrade to the raw text. One bad
message now renders plain instead of nuking the transcript.
2026-06-17 00:46:17 -05:00
Brooklyn Nicholson
547a014e7e fix(desktop): avoid stack overflow rendering huge fenced blocks
`normalizeFenceBlocks`/`pushProseFence` appended block bodies with
`out.push(...lines)`, which spreads every line as a separate call
argument. A single message carrying a large fenced block (a logged
minified bundle, base64 blob, or big tool dump — common in long
sessions) overflows V8's argument-count limit and throws
`RangeError: Maximum call stack size exceeded`, breaking the transcript
render. Compression doesn't save us: it gates on tokens vs. window, not
a single message's line count, and the protected recent tail renders
verbatim regardless.

Append iteratively via a small `extend()` helper. Behavior is identical
for normal-sized blocks.
2026-06-17 00:34:59 -05:00
Hao Zhe
00c045b43f fix(openviking): harden session writes and switch commits 2026-06-17 13:16:03 +08:00
Hao Zhe
f3b813c027 test(openviking): preserve content/write memory writes 2026-06-17 12:58:14 +08:00
harshitAgr
91e9459e10 fix(openviking): track writers per-session so commit waits for all
sync_turn's bounded join could drop a still-alive previous worker by
replacing the single _sync_thread slot. The dropped worker kept POSTing
under the old sid but was no longer visible to on_session_end /
on_session_switch, so the commit could fire while orphaned writes were
still in flight — those writes landed past the commit boundary and were
never extracted.

Replace the single _sync_thread slot with _inflight_writers:
Dict[sid, Set[Thread]]. Writers self-register on spawn (sync_turn,
on_memory_write) and self-deregister on exit. The commit path drains
_drain_writers(sid, 10.0) and skips the commit if any writer for that
sid is still alive after the bounded budget.

Also trim inline review-rationale comments to short invariants per
reviewer style ask: "commit only after session writes drain" and
"drop prefetch results from older switch generations."

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
(cherry picked from commit 7537ee6f5b9ffa4c0f7b79af053aab449caf5af5)
2026-06-17 12:55:37 +08:00
harshitAgr
eddbf291a4 fix(openviking): close remaining session-boundary races on switch
Three follow-ups from review on #28296:

1. Sync worker outliving the bounded join. Each sync_turn POST has
   _TIMEOUT=30s and there are two per turn, but on_session_end and
   on_session_switch only join for 10s. If the worker is still alive
   after the join, committing the old session orphans the worker's
   late writes past the commit boundary — they land in an already-
   committed session and never get extracted. Both hooks now re-check
   is_alive() after the join and skip the commit when the worker
   hasn't drained.

2. on_memory_write late session_id capture. Same shape as the
   pre-fix sync_turn: f-string for the post path read self._session_id
   inside the worker, so a switch between thread spawn and post call
   landed the memory note in the new session. Snapshot sid at call
   time, same pattern as sync_turn.

3. Stale prefetch repopulating the new session. The pre-switch
   drain+clear only protects against workers that finish before the
   join completes; one finishing after the clear would write its
   result into the new generation's slot. Added a monotonic
   _prefetch_generation; workers capture it at spawn and refuse to
   write if it has advanced.

Tests: existing in-flight-sync test updated to drain (it tested the
join-before-commit happy path); four new tests cover hung-writer skip
on end + switch, on_memory_write sid capture, and prefetch generation
gating. 177/177 memory tests pass.

(cherry picked from commit 3791a87dbea518b06fc9e2e8e2da69e21a11cb41)
2026-06-17 12:54:44 +08:00
harshitAgr
a30b40c73a fix(openviking): close session-boundary races on sync_turn and on_session_end
Two hardening fixes prompted by review on #28296:

1. sync_turn() now snapshots the target session id before spawning the
   worker. The previous code read self._session_id inside the worker, so
   a worker delayed past on_session_switch's bounded join could read the
   rotated-in NEW id and write the OLD turn's messages into the wrong
   session.

2. on_session_end() resets _turn_count to 0 after a successful commit,
   making the old-session commit path idempotent with the new switch
   hook. /new and compression call commit_memory_session() (which fires
   on_session_end) immediately before on_session_switch; without this,
   the old session would be committed twice. On commit failure we leave
   _turn_count > 0 so on_session_switch retries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
(cherry picked from commit 2ea8d5c537bccad814c483d36ddd79904bf6b55c)
2026-06-17 12:54:15 +08:00
harshitAgr
813a4e3838 fix(openviking): implement on_session_switch hook (#28296)
OpenVikingMemoryProvider only overrides on_session_end and inherits the
base-class no-op for on_session_switch. When the agent rotates session_id
(via /new, /branch, /reset, /resume, or context compression), the
provider's cached _session_id stays at the value initialize() captured.
All subsequent sync_turn writes then land in the already-closed old
session, and on_session_end tries to commit it a second time — the new
session never accumulates messages and never triggers memory extraction.

The fix mirrors the pattern Hindsight uses (#17508):

  1. Wait for any in-flight sync thread to drain under the OLD _session_id
     before we mutate it, otherwise the commit below races the last
     message write.
  2. Commit the old session if it accumulated turns — same extraction
     semantics as on_session_end. Skip if empty (nothing to extract).
  3. Drain in-flight prefetch from the old session and clear its cached
     result so the new session doesn't see stale recall.
  4. Rotate _session_id to the new value and reset _turn_count.

Commit failures are swallowed (logged at WARN) so a flaky server can't
strand the provider on the old session forever — same posture as the
existing on_session_end commit.

(cherry picked from commit a1e7185e8aea978e76163a288ac0cd5ee911290b)
2026-06-17 12:53:54 +08:00
Bartok
5e01a5dbf1
fix(cli): detect containerd/CRI cgroup-v2 containers in is_container() (#47131)
Closes #47111

is_container() only recognized Docker (/.dockerenv), Podman
(/run/.containerenv), and docker/podman/lxc markers in /proc/1/cgroup.
Under cgroup v2 (Kubernetes/k3s on containerd or CRI-O) /proc/1/cgroup
collapses to a single "0::/" line with no runtime marker, so
is_container() returned False on every containerd/CRI pod.

That false negative bypassed container-aware behavior across the CLI.
The most damaging case (reported): even after #46290 fixed
detect_service_manager() to gate on _s6_running() alone, other
is_container() call sites (profile home resolution, gateway behaviors,
config, doctor) still misbehave on containerd.

Broaden detection conservatively:
- KUBERNETES_SERVICE_HOST env var (present in every k8s pod).
- kubepods/containerd/crio markers in /proc/1/cgroup (cgroup v1 nested).
- same markers in /proc/self/mountinfo as a cgroup-v2 fallback.

Tests: 3 new (k8s env, kubepods cgroup, cgroup-v2-via-mountinfo) plus the
existing negative case hardened to stub mountinfo + env; 108 constants +
service_manager tests pass.
2026-06-17 12:11:31 +10:00
teknium
36ae958473 feat(gateway): gate message timestamps behind opt-in (default off)
Follow-up to salvaged PR #41633: the timestamp prefix injection was
unconditional. Gate the in-context render behind
gateway.message_timestamps.enabled (default false) at both the live-message
and history-replay sites; timestamp metadata is still captured + persisted
regardless so the toggle can be flipped on later. Add DEFAULT_CONFIG entry,
docs, and gate tests.
2026-06-16 15:49:59 -07:00
Wolfram Ravenwolf
bd7fc8fdcd feat(gateway): inject stable human-readable message timestamps
Consolidates these related Amy fork patches:
- 429830f39 feat(gateway): inject message timestamps into user messages for LLM context
- 3c3d6fac0 fix: handle both ISO string and epoch float timestamps in history replay
- 2874f7725 feat: human-friendly timestamp format with weekday and timezone name
- 3735f4c8b fix: render gateway message timestamps once
2026-06-16 15:49:59 -07:00
brooklyn!
b7f0c9cd52
fix(desktop): honor pre-session model pick + restore global reasoning/speed defaults (#47447)
* fix(desktop): keep the pre-session model pick selected in the picker

The composer picker derived its "current" row from `model.options ?? store`,
so model.options always won. Pre-session that query returns the PROFILE
DEFAULT, not the sticky composer pick — so selecting a model before a session
exists left the checkmark (and the picker's "current" line) on the default,
making the pick look ignored even though the pill updated.

Add `currentPickerSelection()`: with a live session the gateway's model.options
is authoritative; pre-session the sticky `$currentModel`/`$currentProvider`
wins, falling back to options. Wire it into ModelMenuPanel and ModelPickerDialog.

* feat(desktop): global reasoning/speed defaults in Settings → Model

The composer picker is now sticky-UI/per-session only and never writes the
profile default (#46959), but Settings → Model had no reasoning/speed control
and `agent.reasoning_effort` wasn't in the curated config surface at all
(`service_tier` was buried in Advanced) — so there was nowhere to set the
profile default that crons/subagents/messaging resolve from.

Add capability-gated Reasoning (effort) + Fast controls beside the main model,
gated by the applied model's reported capabilities (reasoning defaults on, fast
off when unreported — same as the composer). They read/write `agent.reasoning_effort`
and `agent.service_tier` by round-tripping the config record, matching the
gateway's value semantics (service_tier "fast"/"priority"/"on" ⇒ fast).

* refactor(desktop): don't open the reasoning select from its row label

A <label> wrapping the Select forwarded text clicks to the trigger, opening
the dropdown unexpectedly. Plain row for reasoning; Fast stays a <label> so
clicking its text toggles the switch (expected for a checkbox-like control).
2026-06-16 16:22:09 -05:00
xxxigm
d1ecebcbfd
fix(desktop): re-download Electron binary via mirror when pack fails (#47266) (#47276)
* fix(desktop): re-download Electron binary via mirror when pack fails (#47266)

Since #38673 pinned build.electronDist to node_modules/electron/dist,
electron-builder reads the Electron binary straight from there and never
downloads it during `npm run pack`. That dist tree is only produced by the
electron package's postinstall (install.js) during `npm ci`. When that
download is blocked or throttled (GitHub's release host is unreachable in
some regions), the dist is missing and the build dies with:

    The specified electronDist does not exist: .../node_modules/electron/dist

The existing ELECTRON_MIRROR fallback in all three desktop-build paths
(scripts/install.ps1, scripts/install.sh, and `hermes desktop` in
hermes_cli/main.py) re-ran `npm run pack` with ELECTRON_MIRROR set — but
pack never downloads Electron anymore, so the mirror was never used and the
retry re-read the same missing dist. The fallback was effectively dead.

Drive the mirror through electron's own downloader instead:

- Add a dist-presence check + a downloader helper (Test-ElectronDist /
  Restore-ElectronDist, _electron_dist_ok / _restore_electron_dist,
  _electron_dist_ok / _redownload_electron_dist) that wipes a partial dist
  + the path.txt version marker (electron's install.js short-circuits on it)
  and re-runs `node install.js`, optionally via a mirror.
- On the first retry, repopulate a missing dist from the canonical source;
  on the mirror retry, re-fetch through npmmirror.com, then pack.
- Gate the re-download on the dist check so an unrelated build failure
  (tsc/vite) doesn't trigger a pointless ~200 MB refetch, and skip the final
  pack when the binary still can't be fetched instead of failing the same way.

* test(desktop): cover Electron dist re-download mirror fallback (#47266)

Add behavior coverage for the electronDist re-download fix:

- _electron_dist_ok across linux/win32/darwin, including the partial-dist
  case (dir present but binary missing) that makes the pinned electronDist
  fail.
- _redownload_electron_dist: no-op when the binary is present, bail when
  install.js is absent, wipe a stale dist + path.txt marker and run
  electron's downloader with ELECTRON_MIRROR injected, and report failure
  when the download still produces no binary.
- `hermes desktop`: the mirror fallback now drives electron's own downloader
  before re-running pack, and skips the final pack entirely when the binary
  can't be fetched.

Replaces the old mirror test that asserted the (now-fixed) dead behavior of
re-running `npm run pack` with ELECTRON_MIRROR set — pack never downloads
Electron under the pinned electronDist, so that retry could never help.
2026-06-16 15:40:55 -05:00
teknium1
db44af004c test(model-picker): cover two overlapping user-defined custom providers
Guards that two user-defined custom endpoints exposing an overlapping
model each keep their full catalog — the dedup must never cross-filter
two user-defined rows against each other.
2026-06-16 13:09:40 -07:00
liuhao1024
1b962f001e fix(models): pass model.base_url to fetch_models in /model picker
The /model interactive picker resolved a base_url from user credentials
but never passed it to ProviderProfile.fetch_models(), causing the
picker to always query the provider's hardcoded default endpoint
instead of the user's custom URL (e.g. a company litellm proxy).

- providers/base.py: add optional base_url parameter to fetch_models()
- hermes_cli/models.py: pass resolved base_url to fetch_models()
- Update all subclass overrides for signature compatibility
- Add 6 regression tests covering override, fallback, and integration
2026-06-16 13:09:40 -07:00
Wolfram Ravenwolf
9137b86a52 fix(skills): ignore support docs in skill discovery
Support files under references/, templates/, assets/, and scripts/ are progressive-disclosure data loaded through skill_view(..., file_path=...). They should not be treated as standalone skills during discovery or collision checks.

This prevents archived skill packages or support markdown files inside a real skill from shadowing active skills with the same name while still allowing top-level categories named scripts/templates/assets/references.

Tests cover:
- pruning nested SKILL.md files inside skill support directories
- preserving support-named top-level categories
- avoiding skill_view collisions from support markdown
- keeping archived package SKILL.md files accessible only through file_path
2026-06-16 13:08:34 -07:00
teknium1
7493de7fc3 test(model-switch): cover section-3 no-auth probe; map chimpera author
Salvage follow-up for PR #29575: add regression tests for the section-3
no-api_key /v1/models probe (probes bare endpoints, skips when explicit
models set) and add the contributor AUTHOR_MAP entry.
2026-06-16 13:07:52 -07:00
chimpera
1039e90b5e fix(model-switch): probe /v1/models for providers without api_key
Section 3 of list_authenticated_providers (user-defined endpoints from
the providers: config section) required an api_key before probing the
endpoint's /v1/models for live model discovery. This broke local
self-hosted backends (llama.cpp, Ollama, vLLM, etc.) that don't require
authentication — they would only ever show the single default_model
from config instead of the full model catalog.

Section 4 (custom_providers list) already handled this correctly with
the policy: probe when api_key is set OR when no explicit models are
configured. Apply the same logic to Section 3 so local backends get
full model discovery without requiring a placeholder api_key workaround.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 13:07:52 -07:00
teknium1
8ed16a7a0c test(telegram): rich-reply recovery via send-time index
Cover #47375 fix: record-on-rich-send + lookup-on-reply round trip,
lookup miss leaving reply_to_text None, and precedence (native quote
and echoed caption both win over the index fallback).
2026-06-16 13:04:20 -07:00
teknium1
3f80bcac56 chore(release): AUTHOR_MAP entry for x1erra (Sierra) 2026-06-16 13:04:20 -07:00
Sierra (Hermes Agent)
01ae9b853e fix(telegram): resolve replies to rich (sendRichMessage) messages
Telegram does not echo a sendRichMessage's content back in
reply_to_message (.text/.caption empty, .api_kwargs None), so replies
to rich sends (briefings, the gateway's own rich finals) arrived with
no quotable text and the [Replying to: ...] injection was skipped.

Remember message_id -> text at send time in a best-effort JSON index
(gateway/rich_sent_store.py), and recover it on inbound when text and
caption are both empty. Best-effort and no-throw throughout: any
failure degrades to prior behavior and never breaks a send or message.

Salvaged from #47375 by @x1erra. Dropped the cross-platform run.py
reply-prefix rewrite (out of scope; bloated every reply on every
platform) and scrubbed a docstring reference to an out-of-repo script.
Kept the inbound reply_to logging enrichment used to verify the fix.
2026-06-16 13:04:20 -07:00
teknium1
db01910e3a chore(release): map cyb0rgk1tty noreply email for AUTHOR_MAP
Salvage follow-up for PR #46921 — CI matches contributor authorship on the
commit email, which is the GitHub noreply form.
2026-06-16 13:04:07 -07:00
cyb0rgk1tty
b7fa62c530 fix(inventory): keep user-defined custom providers in model dedup
The #45954 model-dedup builds `user_models` from every is_user_defined
row, then strips those model IDs from every row where is_aggregator(slug)
is True. But is_aggregator() returns True for *every* `custom:*` slug, and
list_authenticated_providers emits named custom providers with slug
`custom:<name>` and is_user_defined=True. So a user's own custom provider
is treated as an aggregator and filtered against user_models — which holds
exactly its own models (the row helped build that set). Every model is
removed, the row drops to zero, and the provider disappears from the model
picker.

Guard the dedup loop to skip is_user_defined rows: a user's configured
provider is never an aggregator duplicate of itself. Built-in aggregators
(openrouter, etc.) are still deduped as before. Adds a regression test.
2026-06-16 13:04:07 -07:00
Jaaneek
f4ef70f6fc docs(xai): update default model references to grok-build-0.1
Reflect the default-model change in the xAI Grok OAuth guide, the web
search docs (EN + zh-Hans), and the web provider docstring. grok-4.3 is
kept in the model tables as the previous default; the Nous/OpenRouter
aggregator catalog still lists grok-4.3 and is left unchanged.
2026-06-16 11:50:17 -07:00
Jaaneek
bbc842d31e feat(xai): default to grok-build-0.1
Switch the default model for the xAI/Grok provider and the xAI web
search backend from grok-4.3 to grok-build-0.1. grok-build-0.1 is
already recognized by the model metadata, so no new model definition
is required; grok-4.3 remains selectable.
2026-06-16 11:50:17 -07:00
teknium
28f92478e3 test(hooks): cover session:compress event; drop dead import
Follow-up to salvaged PR #41624:
- Remove stray urllib.parse import in run_agent.py (cherry-pick cruft, unused)
- Add tests: session:compress emits with correct context, no-callback is
  safe, and a callback exception does not break compression
2026-06-16 11:45:36 -07:00
Wolfram Ravenwolf
e76e7b5073 feat(hooks): session:compress event_callback for MemPalace sync 2026-06-16 11:45:36 -07:00
kshitij
8fa562a399
Merge pull request #47391 from kshitijk4poor/feat/add-glm-5.2
feat: add z-ai/glm-5.2 to OpenRouter and Nous model lists
2026-06-17 00:02:05 +05:30
brooklyn!
44e5848e74
feat(desktop): stream subagent activity into watch windows (#47060)
* feat(desktop): stream subagent replies into watch windows

A desktop watch window resumes a child session lazily (no full agent) and
mirrors the parent-relayed `subagent.*` events into native child-session
stream events. The child's streamed reply text was never relayed, so the
window sat blank while the subagent "talked".

- delegate_tool: forward the child's `run_conversation` stream tokens up the
  progress relay as `subagent.text` (inert under CLI/TUI — their progress
  handlers ignore non-tool event types; only a gateway watch window mirrors it).
- server: mirror `subagent.text` -> `message.delta` on the child sid only, and
  skip the parent emit (per-token frames are meaningless on the parent session,
  which shows the child via the spawn tree). Demote `subagent.start` to a
  one-time goal header and drop the noisy `subagent.progress` mirror — tools
  already mirror natively.
- server: guard `_start_agent_build` so a lazy watch session spectating an
  in-flight child stays lazy; incidental RPCs were upgrading it to a full
  agent mid-stream and silently killing the mirror.

* fix(desktop): keep watch-window chat clear of titlebar chrome

Secondary windows (new-session scratch, subagent watch, cmd-click pop-out)
hide the titlebar tool cluster + session header, so the transcript ran to the
window's top edge and streamed text slid up under the OS traffic lights.

- Gate the hidden chrome on `isSecondaryWindow()` everywhere (app-shell,
  chat header, thread list) instead of the narrower new-session flag.
- Add a fixed opaque drag-strip at the top of the secondary-window transcript:
  content padding alone scrolls away with the text, so the strip masks
  anything behind it and keeps the window draggable like the main header.

* fix: WSL subagent window

* fix: subagent window top padding

---------

Co-authored-by: Austin Pickett <pickett.austin@gmail.com>
Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
2026-06-16 14:30:11 -04:00
teknium
6ebc449915 fix(prompt): isolate truncation warnings per context
Follow-up to salvaged PR #41619: replace the module-global
_truncation_warnings list with a contextvars.ContextVar so concurrent
gateway-session prompt builds can't drain or clear each other's pending
warnings (cross-session leak). Adds a context-isolation test.
2026-06-16 11:28:35 -07:00
Wolfram Ravenwolf
f6a42b1acf feat(prompt): make context-file truncation limit configurable
PROBLEM: Automatic context files such as SOUL.md and AGENTS.md were capped by a hardcoded CONTEXT_FILE_MAX_CHARS value. Amy's local fork had raised that constant from 20K to 25K so a larger SOUL.md would not be silently truncated, but the hardcoded 25K value changed upstream default behavior and made the patch less generally useful.

SOLUTION: Restore the upstream-compatible 20K default, add a context_file_max_chars config setting for users who intentionally keep larger identity/project-context files, keep chat-visible truncation warnings, and document the new setting. Tests cover the default, config override, explicit max_chars precedence, and the warning text.
2026-06-16 11:28:35 -07:00
kshitijk4poor
b2da39a0f3 feat: add z-ai/glm-5.2 to OpenRouter and Nous model lists
Z.ai released GLM 5.2 on 2026-06-15, available on OpenRouter:
  - https://openrouter.ai/z-ai/glm-5.2

GLM-5.2 is Z.ai's flagship for long-horizon tasks, shipping a 1M-token
context window (up from 200K on GLM 5.1) and tool calling. Per the
OpenRouter API: text-only, context_length 1048576, tools supported.
No separate -fast variant exists.

The 1M context length, native zai picker entry, setup wizard, and Z.ai
coding-plan auth entries for glm-5.2 already landed on main. This fills
the remaining gap: the two aggregator surfaces where glm-5.1 appears but
glm-5.2 did not.

Changes:

  hermes_cli/models.py
    - Add z-ai/glm-5.2 to the OpenRouter fallback snapshot (OPENROUTER_MODELS)
      and the Nous Portal curated list (_PROVIDER_MODELS["nous"]), newest
      flagship first. Live catalogs surface it automatically when reachable;
      the fallback lists matter when the manifest fetch fails.

  website/static/api/model-catalog.json
    - Regenerated via scripts/build_model_catalog.py (not hand-edited) so the
      manifest stays in sync with the source lists; guarded by
      tests/hermes_cli/test_model_catalog.py.
2026-06-16 23:35:45 +05:30
kshitij
17251e865b
Merge pull request #46857 from liuhao1024/fix/model-picker-merge-live-static
fix(models): merge live API results with curated static catalog in generic provider path
2026-06-16 23:30:34 +05:30
kshitijk4poor
658ac1d866 fix(models): keep curated-first ordering in live+curated merge; use pure-catalog helper in validation
The generic live+curated merge (commit 630b438) seeded the merged list
from live results, demoting curated-only models below live ones. That
regressed #46309, which deliberately surfaces the newest curated model
(kimi-k2.7-code) FIRST in the native picker even when the live /models
listing lags. Restore curated-first ordering: curated entries lead (in
catalog order), live-only entries are appended for discovery. This keeps
the #46850 fix (zai glm-5.2 now appears) without the kimi regression.

Also switch the validate_requested_model curated fallback (commit
ee7b8a4) from provider_model_ids() — which triggers a second, uncached
live /models fetch with its own 8s timeout and may resolve different
credentials than the api_key/base_url just probed — to the pure-catalog
helper _model_in_provider_catalog(). Membership is checked against the
shipped catalog only, with no extra network call.

Tests: restore the curated-first assertion in
test_kimi_coding_live_catalog_does_not_hide_curated_k2_7_code; update
the new merge tests to curated-first semantics; de-circularize the
validation fallback tests to patch _PROVIDER_MODELS (the real source)
instead of mocking the function under test.
2026-06-16 23:25:07 +05:30
Teknium
c2c55c4443 fix(memory): strip skill scaffolding for all providers, not just openviking
Generalizes #32663 (@ehz0ah). The slash-skill scaffolding pollution
affected every auto-syncing memory provider — mem0, hindsight, retaindb,
byterover, honcho, supermemory all store/embed the raw user turn, so a
/skill invocation poisoned their stores with the full skill body, not just
openviking.

- Lift the contributor's parser into agent/skill_commands.py as the canonical
  extract_user_instruction_from_skill_message(), co-located with the message
  builders so the markers can't drift.
- Strip once in MemoryManager.{prefetch_all,queue_prefetch_all,sync_all} —
  fixes the whole provider fan-out, bare /skill turns are skipped entirely.
- OpenViking's _derive_openviking_user_text() now delegates to the shared
  helper as defense-in-depth (no duplicated marker literals).
- Marker-drift regression now asserts against the canonical skill_commands
  constants; add manager-level coverage proving every provider gets clean text.
2026-06-16 10:37:37 -07:00
Hao Zhe
e3adbb5ae9 fix(openviking): sanitize skill memory input 2026-06-16 10:37:37 -07:00
teknium1
e236bb87eb docs(skills): regenerate shop skill page after shop-app rename 2026-06-16 10:37:21 -07:00
teknium1
cf52370253 chore(release): AUTHOR_MAP entry for Joe Rinaldi Johnson 2026-06-16 10:37:21 -07:00
teknium1
d7668aaff5 chore(skills/shop): tighten description to ≤60 chars, credit contributor 2026-06-16 10:37:21 -07:00
Joe Rinaldi Johnson
5094325140 feat(skills): replace shop-app with CLI-based shop skill (v1.0.1)
Rewrites the Shop personal-shopping-assistant skill to use the
@shopify/shop-cli (with a full direct-API fallback in references/),
replacing the previous curl-only shop-app skill.

- Rename optional-skills/productivity/shop-app -> shop
- Add references/: catalog-mcp.md, direct-api.md, safety.md, legal.md
- Catalog discovery via Shopify Global Catalog MCP (search / lookup /
  get-product), device-authorization sign-in, UCP agent checkout with
  delegated spending budget, and order tracking / returns / reorder
- One-product-per-message presentation rules + per-channel overrides
- Expanded security, safety, and legal guidance

Website docs are auto-generated from SKILL.md by CI
(website/scripts/generate-skill-docs.py), so no docs are hand-edited here.
2026-06-16 10:37:21 -07:00
Hao Zhe
166d2457b2 fix(memory): avoid setup autostart for unhealthy OpenViking 2026-06-17 01:32:43 +08:00
Hao Zhe
315fdae5f8 fix(memory): tighten OpenViking local autostart 2026-06-17 01:23:05 +08:00
Hao Zhe
2c2ca0443b feat(memory): improve OpenViking setup UX 2026-06-17 01:04:26 +08:00
Hao Zhe
3c76dac4fd fix(memory): log OpenViking chmod failures 2026-06-17 01:02:39 +08:00
Hao Zhe
2b972472ce fix(memory): validate OpenViking manual setup steps 2026-06-17 01:02:39 +08:00
Hao Zhe
a893d77d8d fix(memory): separate setup option descriptions 2026-06-17 01:02:39 +08:00
Hao Zhe
94523764fc fix(memory): choose OpenViking key type before prompting 2026-06-17 01:02:39 +08:00
Hao Zhe
70f53f36cb feat(memory): add manual OpenViking setup path 2026-06-17 01:02:39 +08:00
Hao Zhe
7f76cf7195 fix(memory): smooth setup transition after provider selection 2026-06-17 01:02:39 +08:00
Hao Zhe
b0e25c9cb2 fix(memory): restrict OpenViking setup file permissions 2026-06-17 01:02:39 +08:00
Hao Zhe
2dace37f6b feat(memory): improve OpenViking setup UX
Support linking, copying, and creating ovcli.conf during OpenViking memory setup.

Make setup cancellation write nothing and cover OpenViking/Hindsight picker cancellation paths.
2026-06-17 01:02:38 +08:00
brooklyn!
c6e99ab375
Merge pull request #46959 from NousResearch/bb/composer-model-selector
feat(desktop): composer model selector, per-model presets & external-provider disconnect
2026-06-16 09:55:57 -05:00
Brooklyn Nicholson
80e4b8985e feat(desktop): tighten composer model picker interactions
Clicking a model row in the composer dropdown now commits and closes the menu
(via a close context); the hover-revealed reasoning/fast submenu stays open to
tweak. The pill shows a quiet braille loader instead of literal "No model"
until one resolves, and steer takes over the mic slot while typing into a
running agent.
2026-06-16 09:50:27 -05:00
Brooklyn Nicholson
7d938cc5c9 fix(desktop): keep live model switch metadata truthful
A live config.set model switch already moved the next API call to the new model,
but the conversation could still restore an old sessions.system_prompt snapshot
whose Model/Provider lines named the previous runtime. That made "what model are
you?" answer from stale metadata even while inference ran on the new model.

After a live switch we now refresh the stored system prompt and append a real
system-history pivot (not a fake user turn) so the transcript itself records the
new model/provider. Restore also rejects already-stale prompt snapshots when
their Model/Provider lines disagree with the runtime, so existing bad sessions
self-heal.
2026-06-16 09:50:17 -05:00
Brooklyn Nicholson
cb6b4127e7 refactor(desktop): make composer model picker sticky session state
The picker no longer touches the profile default. Model/effort/fast live as
plain UI state persisted in localStorage, so a pick follows across Cmd+N and
restarts instead of snapping back. New chats ship that state through
session.create as per-session overrides; live chats still scope switches to the
current session. Settings -> Model remains the only surface that writes the
profile default.

The gateway now accepts those session.create overrides, builds the agent with
them directly, reflects them in the immediate session.info payload, and writes
the chat's own model_config into the lazy DB row so reconnect/resume restores
that chat instead of the global default.
2026-06-16 09:50:07 -05:00
liuhao1024
ee7b8a4672 fix(models): validate_requested_model falls back to curated catalog when live API omits model
When live /v1/models responds but omits a model that exists in the
curated static catalog, validate_requested_model now accepts it with
a note instead of rejecting. This covers the /model slash-command path
(the picker path was already fixed in the parent commit).

Addresses review feedback from potatogim on #46857.
2026-06-16 16:24:11 +08:00
liuhao1024
630b43892d fix(models): merge live API results with curated static catalog in generic provider path
When a provider's live /v1/models endpoint returns a stale or incomplete
list (e.g. Z.AI missing glm-5.2), the generic profile-based code path
returned only the live results, silently dropping curated models.

Generalize the kimi-coding merge pattern to all providers: live entries
come first (provider's preferred order), then curated-only entries are
appended with case-insensitive dedup. This ensures models that the live
endpoint omits still appear in /model picker.

Fixes #46850
2026-06-16 16:21:01 +08:00
Brooklyn Nicholson
dd0e3e0a05 fix(desktop): tighten thread content top padding 2026-06-16 00:08:21 -05:00
Brooklyn Nicholson
a0ec4f52b9 feat(desktop): disconnect external (CLI-managed) providers
External providers (Claude Code) store creds outside Hermes, so the
disconnect API refuses them. The backend now hands the GUI a per-OS
`disconnect_command` that clears the credential the same way the CLI's
logout does (macOS Keychain entry + ~/.claude/.credentials.json), and
the misleading "use claude setup-token" hint is corrected.

Settings → Providers offers a Disconnect button for these: it confirms,
leaves Settings, and runs the removal command in the embedded terminal
via a new runInTerminal() (queues onto $terminalInjection; the terminal
pane flushes and clears it once its session is live). The expanded list
also gets its own "Other providers" header so it no longer reads as
grouped under "Connected". API-managed providers keep the one-click
(trash) disconnect.
2026-06-16 00:08:21 -05:00
Brooklyn Nicholson
0e81d2fb71 feat(desktop): per-model effort/fast presets in the picker
Each model remembers its own reasoning effort / fast mode (localStorage,
like model-visibility): editing a model's effort/fast in the submenu
writes its preset, and selecting a model restores its preset onto the
session (capability-gated, Hermes defaults when unset). Every row shows
its own remembered settings (grayed), and the row label and edit submenu
read the same effective value so they can't disagree.

Presets are desktop-client state only — applyModelPreset() no-ops without
a live session id, so selecting a model can't fall through to the
gateway's persistent agent.reasoning_effort / agent.service_tier writes.
Inactive variant `-fast` edits stay preset-only: toggleFast() records
{ fast } on the base model and only swaps models when the row is active,
and selectFamily() honors a saved variant-fast preset by selecting the
`-fast` sibling id.
2026-06-16 00:08:20 -05:00
Brooklyn Nicholson
989d5d0cb7 fix(desktop): declutter date-pinned model snapshots in the picker
Provider catalogs surface date-pinned snapshots (`…-20251101`) that the
picker rendered as standalone rows with the date baked into the name
("Opus 4 5 20251101"). Strip the trailing date from display names, and
fold a snapshot out of the list when its rolling alias is present so the
alias stays selectable/searchable while the exact dated id isn't shown
as its own row.
2026-06-15 23:53:41 -05:00
Brooklyn Nicholson
c92a95a130 feat(desktop): move model selector from statusbar to composer
Relocate the model pill to the composer, left of the mic. A new
ModelPill reuses the live ModelMenuPanel dropdown verbatim (single
click target) and the formatModelStatusLabel "Model · Fast Med" label,
anchored to its right edge so the menu doesn't drift with model-name
length. modelMenuContent now flows to ChatView instead of
useStatusbarItems, and the status-bar model-summary item is removed;
the pill subscribes to the model atoms directly and falls back to the
full picker when the gateway is closed.
2026-06-15 23:53:41 -05:00
530 changed files with 28213 additions and 41268 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

1
.gitignore vendored
View File

@ -5,6 +5,7 @@
*.pyc* *.pyc*
__pycache__/ __pycache__/
.venv/ .venv/
.venv
.vscode/ .vscode/
.env .env
.env.local .env.local

View File

@ -1,18 +1,19 @@
FROM ghcr.io/astral-sh/uv:0.11.6-python3.13-trixie@sha256:b3c543b6c4f23a5f2df22866bd7857e5d304b67a564f4feab6ac22044dde719b AS uv_source FROM ghcr.io/astral-sh/uv:0.11.6-python3.13-trixie@sha256:b3c543b6c4f23a5f2df22866bd7857e5d304b67a564f4feab6ac22044dde719b AS uv_source
# Node 26 source stage. Debian trixie's bundled nodejs is pinned to 20.x # Node 22 LTS source stage. Debian trixie's bundled nodejs is pinned to 20.x
# (EOL April 2026), so we copy node + npm + corepack from the upstream node:26 # which reached EOL in April 2026 — we copy node + npm + corepack from the
# image instead. Node 26 (Current; LTS promotion ~Oct 2026) is REQUIRED by the # upstream node:22 image instead so we can stay on a supported LTS without
# native OpenTUI TUI engine, which loads its renderer via the experimental # waiting for Debian 14 (forky, ~mid-2027). Bookworm-based slim image used
# `node:ffi` API that only exists on Node 26.3+ (the Ink engine + web build run # so the produced binary links against glibc 2.36, which runs cleanly on
# on it too). Bookworm-based slim image used so the produced binary links # our Debian 13 (trixie, glibc 2.41) runtime. Bumping to a new Node major
# against glibc 2.36, which runs cleanly on our Debian 13 (trixie, glibc 2.41) # is a one-line ARG change; see #4977.
# runtime. The pinned tag ships v26.3.0. Bumping Node is a one-line change here. FROM node:22-bookworm-slim@sha256:7af03b14a13c8cdd38e45058fd957bf00a72bbe17feac43b1c15a689c029c732 AS node_source
# NOTE: verify the full image build + Ink/web/Playwright on Node 26 in CI.
FROM node:26-bookworm-slim@sha256:79723b41edbedf595f62e943a9f8b0ba9af5b1e61045c5f8f59c2c02c1212a16 AS node_source
FROM debian:13.4 FROM debian:13.4
# Disable Python stdout buffering to ensure logs are printed immediately # Disable Python stdout buffering to ensure logs are printed immediately.
# Do not write .pyc files at runtime: /opt/hermes is immutable in the
# published container and writable state belongs under /opt/data.
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
# Store Playwright browsers outside the volume mount so the build-time # Store Playwright browsers outside the volume mount so the build-time
# install survives the /opt/data volume overlay at runtime. # install survives the /opt/data volume overlay at runtime.
@ -92,7 +93,7 @@ RUN useradd -u 10000 -m -d /opt/data hermes
COPY --chmod=0755 --from=uv_source /usr/local/bin/uv /usr/local/bin/uvx /usr/local/bin/ COPY --chmod=0755 --from=uv_source /usr/local/bin/uv /usr/local/bin/uvx /usr/local/bin/
# Node 26: copy the node binary plus the bundled npm + corepack JS # Node 22 LTS: copy the node binary plus the bundled npm + corepack JS
# installs from the upstream image. npm and npx are recreated as symlinks # installs from the upstream image. npm and npx are recreated as symlinks
# because they're symlinks in the source image (and need to live on PATH). # because they're symlinks in the source image (and need to live on PATH).
# See node_source stage at the top of the file for the version-bump # See node_source stage at the top of the file for the version-bump
@ -121,7 +122,7 @@ COPY ui-tui/packages/hermes-ink/ ui-tui/packages/hermes-ink/
# `npm_config_install_links=false` forces npm to install `file:` deps as # `npm_config_install_links=false` forces npm to install `file:` deps as
# symlinks instead of copies. This is the default since npm 10+, which is # symlinks instead of copies. This is the default since npm 10+, which is
# what the image ships now (via the node:26 source stage). We set it # what the image ships now (via the node:22 source stage). We set it
# explicitly anyway as defense-in-depth: the previous Debian-bundled npm # explicitly anyway as defense-in-depth: the previous Debian-bundled npm
# 9.x defaulted to install-as-copy, which produced a hidden # 9.x defaulted to install-as-copy, which produced a hidden
# node_modules/.package-lock.json that permanently disagreed with the root # node_modules/.package-lock.json that permanently disagreed with the root
@ -183,49 +184,43 @@ RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra
# invalidate the (relatively slow) web + ui-tui build layer. # invalidate the (relatively slow) web + ui-tui build layer.
COPY web/ web/ COPY web/ web/
COPY ui-tui/ ui-tui/ COPY ui-tui/ ui-tui/
COPY ui-opentui/ ui-opentui/
# ui-opentui is the opt-in native OpenTUI engine (HERMES_TUI_ENGINE=opentui;
# default stays Ink). .dockerignore strips its node_modules/dist, so install +
# esbuild-build it here -> dist/main.js, then prune devDeps (esbuild/babel/
# vitest); the runtime only needs the prod deps (the external @opentui/core +
# its native blob -- the bundle inlines solid/effect). Build needs Node 26.3
# (node:ffi floor), which this image ships.
RUN cd web && npm run build && \ RUN cd web && npm run build && \
cd ../ui-tui && npm run build && \ cd ../ui-tui && npm run build
cd ../ui-opentui && npm install --no-audit --no-fund && npm run build && npm prune --omit=dev
# ---------- Source code ---------- # ---------- Source code ----------
# .dockerignore excludes node_modules, so the installs above survive. # .dockerignore excludes node_modules, so the installs above survive.
COPY --chown=hermes:hermes . . COPY . .
# ---------- Permissions ---------- # ---------- Permissions ----------
# Make install dir world-readable so any HERMES_UID can read it at runtime. # Link hermes-agent itself (editable). Deps are already installed in the
# The venv needs to be traversable too. # cached layer above; `--no-deps` makes this a fast egg-link creation with no
# node_modules trees additionally need to be writable by the hermes user # resolution or downloads.
# so the runtime `npm install` triggered by _tui_need_npm_install() in RUN uv pip install --no-cache-dir --no-deps -e "."
# hermes_cli/main.py succeeds (see #18800). /opt/hermes/web is build-time
# only (HERMES_WEB_DIST points at hermes_cli/web_dist) and is intentionally # Keep /opt/hermes immutable for the runtime hermes user. Hosted/container
# not chowned here. # instances must not be able to self-edit the installed source or venv; user
# /opt/hermes/gateway is runtime-writable: Python may create __pycache__ and # data, skills, plugins, config, logs, and dashboard uploads live under
# gateway state artifacts beneath the package after services drop privileges, # /opt/data instead. Root can still repair the image during build/boot, but
# especially when the hermes UID is remapped at boot (#27221). # supervised Hermes processes drop to the non-root hermes user.
# The .venv MUST remain hermes-writable so lazy_deps.py can install
# remaining optional platform packages and future pin bumps at first use.
# Without this, `uv pip install` fails with EACCES and adapters silently
# fail to load. See tools/lazy_deps.py.
USER root USER root
RUN chmod -R a+rX /opt/hermes && \ RUN mkdir -p /opt/hermes/bin && \
chown -R hermes:hermes /opt/hermes/.venv /opt/hermes/ui-tui /opt/hermes/gateway /opt/hermes/node_modules cp /opt/hermes/docker/hermes-exec-shim.sh /opt/hermes/bin/hermes && \
chmod 0755 /opt/hermes/bin/hermes && \
printf 'docker\n' > /opt/hermes/.install_method && \
chown -R root:root /opt/hermes && \
chmod -R a+rX /opt/hermes && \
chmod -R a-w /opt/hermes
# The ``.install_method`` stamp is baked next to the running code (the install
# tree), NOT into $HERMES_HOME. $HERMES_HOME (/opt/data) is a shared data
# volume that is commonly bind-mounted from the host and even shared with a
# host-side Desktop/CLI install; stamping it at boot used to clobber that
# host install's marker and wrongly block its ``hermes update``. A code-scoped
# stamp is read first by detect_install_method() and is immune to the share.
# Start as root so the s6-overlay stage2 hook can usermod/groupmod and chown # Start as root so the s6-overlay stage2 hook can usermod/groupmod and chown
# the data volume. Each supervised service then drops to the hermes user via # the data volume. Each supervised service then drops to the hermes user via
# `s6-setuidgid hermes` in its run script. If HERMES_UID is unset, services # `s6-setuidgid hermes` in its run script. If HERMES_UID is unset, services
# run as the default hermes user (UID 10000). # run as the default hermes user (UID 10000).
# ---------- Link hermes-agent itself (editable) ----------
# Deps are already installed in the cached layer above; `--no-deps` makes
# this a fast (~1s) egg-link creation with no resolution or downloads.
RUN uv pip install --no-cache-dir --no-deps -e "."
# ---------- Bake build-time git revision ---------- # ---------- Bake build-time git revision ----------
# .dockerignore excludes .git, so `git rev-parse HEAD` from inside the # .dockerignore excludes .git, so `git rev-parse HEAD` from inside the
# container always returns nothing — meaning `hermes dump` reports # container always returns nothing — meaning `hermes dump` reports
@ -245,8 +240,9 @@ RUN uv pip install --no-cache-dir --no-deps -e "."
# every published image has it. # every published image has it.
ARG HERMES_GIT_SHA= ARG HERMES_GIT_SHA=
RUN if [ -n "${HERMES_GIT_SHA}" ]; then \ RUN if [ -n "${HERMES_GIT_SHA}" ]; then \
chmod u+w /opt/hermes && \
printf '%s\n' "${HERMES_GIT_SHA}" > /opt/hermes/.hermes_build_sha && \ printf '%s\n' "${HERMES_GIT_SHA}" > /opt/hermes/.hermes_build_sha && \
chown hermes:hermes /opt/hermes/.hermes_build_sha; \ chmod a-w /opt/hermes /opt/hermes/.hermes_build_sha; \
fi fi
# ---------- s6-overlay service wiring ---------- # ---------- s6-overlay service wiring ----------
@ -292,6 +288,8 @@ ENV HERMES_WEB_DIST=/opt/hermes/hermes_cli/web_dist
# check. (A separate launcher hardening is tracked independently.) # check. (A separate launcher hardening is tracked independently.)
ENV HERMES_TUI_DIR=/opt/hermes/ui-tui ENV HERMES_TUI_DIR=/opt/hermes/ui-tui
ENV HERMES_HOME=/opt/data ENV HERMES_HOME=/opt/data
ENV HERMES_WRITE_SAFE_ROOT=/opt/data
ENV HERMES_DISABLE_LAZY_INSTALLS=1
# `docker exec` privilege-drop shim. When operators run # `docker exec` privilege-drop shim. When operators run
# `docker exec <c> hermes ...` they default to root, and any file the # `docker exec <c> hermes ...` they default to root, and any file the
@ -304,7 +302,6 @@ ENV HERMES_HOME=/opt/data
# Recursion is impossible because the shim exec's the venv binary by # Recursion is impossible because the shim exec's the venv binary by
# absolute path (/opt/hermes/.venv/bin/hermes). See the shim source for # absolute path (/opt/hermes/.venv/bin/hermes). See the shim source for
# the opt-out env var (HERMES_DOCKER_EXEC_AS_ROOT=1). # the opt-out env var (HERMES_DOCKER_EXEC_AS_ROOT=1).
COPY --chmod=0755 docker/hermes-exec-shim.sh /opt/hermes/bin/hermes
# Pre-s6 entrypoint.sh did `source .venv/bin/activate` which exported # Pre-s6 entrypoint.sh did `source .venv/bin/activate` which exported
# the venv bin onto PATH; Architecture B's main-wrapper.sh does the # the venv bin onto PATH; Architecture B's main-wrapper.sh does the

View File

@ -107,8 +107,6 @@ You can still bring your own keys per-tool whenever you want — the gateway is
Hermes has two entry points: start the terminal UI with `hermes`, or run the gateway and talk to it from Telegram, Discord, Slack, WhatsApp, Signal, or Email. Once you're in a conversation, many slash commands are shared across both interfaces. Hermes has two entry points: start the terminal UI with `hermes`, or run the gateway and talk to it from Telegram, Discord, Slack, WhatsApp, Signal, or Email. Once you're in a conversation, many slash commands are shared across both interfaces.
> **TUI engine:** On supported hosts (Linux/macOS with Node 26.3+), the terminal UI defaults to the native **OpenTUI** engine, which the installer provisions for you. The legacy **Ink** engine remains the fallback — it's used automatically on Windows, Termux, or when the native engine can't run, and you can select it explicitly with `HERMES_TUI_ENGINE=ink hermes`. Ink is not going away; it's the kept fallback.
| Action | CLI | Messaging platforms | | Action | CLI | Messaging platforms |
| ------------------------------ | --------------------------------------------- | -------------------------------------------------------------------------------- | | ------------------------------ | --------------------------------------------- | -------------------------------------------------------------------------------- |
| Start chatting | `hermes` | Run `hermes gateway setup` + `hermes gateway start`, then send the bot a message | | Start chatting | `hermes` | Run `hermes gateway setup` + `hermes gateway start`, then send the bot a message |

View File

@ -27,7 +27,7 @@ import threading
import time import time
import uuid import uuid
from datetime import datetime from datetime import datetime
from typing import Any, Dict, List, Optional from typing import Any, Callable, Dict, List, Optional
from urllib.parse import urlparse, parse_qs, urlunparse from urllib.parse import urlparse, parse_qs, urlunparse
from agent.context_compressor import ContextCompressor from agent.context_compressor import ContextCompressor
@ -195,6 +195,7 @@ def init_agent(
status_callback: callable = None, status_callback: callable = None,
notice_callback: callable = None, notice_callback: callable = None,
notice_clear_callback: callable = None, notice_clear_callback: callable = None,
event_callback: Optional[Callable[[str, dict], None]] = None,
max_tokens: int = None, max_tokens: int = None,
reasoning_config: Dict[str, Any] = None, reasoning_config: Dict[str, Any] = None,
service_tier: str = None, service_tier: str = None,
@ -426,6 +427,7 @@ def init_agent(
agent.status_callback = status_callback agent.status_callback = status_callback
agent.notice_callback = notice_callback agent.notice_callback = notice_callback
agent.notice_clear_callback = notice_clear_callback agent.notice_clear_callback = notice_clear_callback
agent.event_callback = event_callback
agent.tool_gen_callback = tool_gen_callback agent.tool_gen_callback = tool_gen_callback
@ -597,6 +599,7 @@ def init_agent(
# (e.g. CLI voice mode adds a temporary prefix for the live call only). # (e.g. CLI voice mode adds a temporary prefix for the live call only).
agent._persist_user_message_idx = None agent._persist_user_message_idx = None
agent._persist_user_message_override = None agent._persist_user_message_override = None
agent._persist_user_message_timestamp = None
# Cache anthropic image-to-text fallbacks per image payload/URL so a # Cache anthropic image-to-text fallbacks per image payload/URL so a
# single tool loop does not repeatedly re-run auxiliary vision on the # single tool loop does not repeatedly re-run auxiliary vision on the
@ -1153,6 +1156,9 @@ def init_agent(
"hermes_home": str(get_hermes_home()), "hermes_home": str(get_hermes_home()),
"agent_context": "primary", "agent_context": "primary",
} }
if _init_kwargs["platform"] == "cli":
_init_kwargs["warning_callback"] = agent._emit_warning
_init_kwargs["status_callback"] = agent._emit_status
# Thread session title for memory provider scoping # Thread session title for memory provider scoping
# (e.g. honcho uses this to derive chat-scoped session keys) # (e.g. honcho uses this to derive chat-scoped session keys)
if agent._session_db: if agent._session_db:
@ -1221,6 +1227,12 @@ def init_agent(
# targets. # targets.
agent._task_completion_guidance = bool(_agent_section.get("task_completion_guidance", True)) agent._task_completion_guidance = bool(_agent_section.get("task_completion_guidance", True))
# Universal parallel-tool-call guidance toggle. Default True. Separate
# flag from task_completion_guidance because a user may want one but not
# the other. Steers the model to batch independent tool calls into a
# single turn; the runtime already executes such batches concurrently.
agent._parallel_tool_call_guidance = bool(_agent_section.get("parallel_tool_call_guidance", True))
# Local Python toolchain probe toggle. Default True. When False, # Local Python toolchain probe toggle. Default True. When False,
# the probe is skipped entirely (no subprocess calls, no system-prompt # the probe is skipped entirely (no subprocess calls, no system-prompt
# line). Useful for users on exotic setups where the probe heuristics # line). Useful for users on exotic setups where the probe heuristics

View File

@ -1839,28 +1839,42 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
elif function_name == "memory": elif function_name == "memory":
def _execute(next_args: dict) -> Any: def _execute(next_args: dict) -> Any:
target = next_args.get("target", "memory") target = next_args.get("target", "memory")
operations = next_args.get("operations")
from tools.memory_tool import memory_tool as _memory_tool from tools.memory_tool import memory_tool as _memory_tool
result = _memory_tool( result = _memory_tool(
action=next_args.get("action"), action=next_args.get("action"),
target=target, target=target,
content=next_args.get("content"), content=next_args.get("content"),
old_text=next_args.get("old_text"), old_text=next_args.get("old_text"),
operations=operations,
store=agent._memory_store, store=agent._memory_store,
) )
# Bridge: notify external memory provider of built-in memory writes # Bridge: notify external memory provider of built-in memory writes.
if agent._memory_manager and next_args.get("action") in {"add", "replace"}: # Covers both the single-op shape and each add/replace inside a batch.
try: if agent._memory_manager:
agent._memory_manager.on_memory_write( if operations:
next_args.get("action", ""), _mem_ops = [
target, op for op in operations
next_args.get("content", ""), if isinstance(op, dict) and op.get("action") in {"add", "replace"}
metadata=agent._build_memory_write_metadata( ]
task_id=effective_task_id, else:
tool_call_id=tool_call_id, _mem_ops = (
), [{"action": next_args.get("action"), "content": next_args.get("content")}]
if next_args.get("action") in {"add", "replace"} else []
) )
except Exception: for _op in _mem_ops:
pass try:
agent._memory_manager.on_memory_write(
_op.get("action", ""),
target,
_op.get("content", "") or "",
metadata=agent._build_memory_write_metadata(
task_id=effective_task_id,
tool_call_id=tool_call_id,
),
)
except Exception:
pass
return _finish_agent_tool(result, next_args) return _finish_agent_tool(result, next_args)
elif agent._memory_manager and agent._memory_manager.has_tool(function_name): elif agent._memory_manager and agent._memory_manager.has_tool(function_name):
def _execute(next_args: dict) -> Any: def _execute(next_args: dict) -> Any:

View File

@ -372,7 +372,7 @@ def _detect_claude_code_version() -> str:
_CLAUDE_CODE_SYSTEM_PREFIX = "You are Claude Code, Anthropic's official CLI for Claude." _CLAUDE_CODE_SYSTEM_PREFIX = "You are Claude Code, Anthropic's official CLI for Claude."
_MCP_TOOL_PREFIX = "mcp_" _MCP_TOOL_PREFIX = "mcp__"
def _get_claude_code_version() -> str: def _get_claude_code_version() -> str:
@ -2349,25 +2349,46 @@ def build_anthropic_kwargs(
text = text.replace("Nous Research", "Anthropic") text = text.replace("Nous Research", "Anthropic")
block["text"] = text block["text"] = text
# 3. Prefix tool names with mcp_ (Claude Code convention) # 3. Normalize tool names so NOTHING goes on the OAuth wire with a
# Skip names that already begin with the marker — native MCP server # single-underscore ``mcp_`` prefix. Anthropic's subscription/OAuth
# tools (from mcp_servers: in config.yaml) are registered under their # billing classifier treats a single-underscore ``mcp_`` tool name as
# full mcp_<server>_<tool> name and would double-prefix otherwise, # a third-party-app fingerprint and rejects the request with HTTP 400
# breaking round-trip registry lookup in normalize_response. GH-25255. # "Third-party apps now draw from extra usage, not plan limits"
# (verified empirically: a single ``mcp_foo`` tool flips a request
# from plan-billing to the extra-usage lane; ``mcp__foo`` is accepted).
#
# Two cases, both must land on the double-underscore ``mcp__`` form:
# a) bare Hermes-native tools (``read_file``) -> ``mcp__read_file``
# b) native MCP server tools registered under their full
# single-underscore ``mcp_<server>_<tool>`` name
# (``mcp_linear_get_issue``) -> ``mcp__linear_get_issue``
# Case (b) is the gap that the bare ``mcp_``->``mcp__`` constant swap
# left open: those tools were *skipped* and stayed single-underscore,
# so any session with an MCP server configured still tripped the
# classifier. normalize_response reverses both forms via registry
# lookup so the dispatcher still sees the original name. GH-25255.
def _to_oauth_wire_name(name: str) -> str:
if name.startswith("mcp__"):
return name # already correct, don't double-prefix
if name.startswith("mcp_"):
# single-underscore native MCP tool -> promote to double
return "mcp__" + name[len("mcp_"):]
return _MCP_TOOL_PREFIX + name # bare name -> mcp__<name>
if anthropic_tools: if anthropic_tools:
for tool in anthropic_tools: for tool in anthropic_tools:
if "name" in tool and not tool["name"].startswith(_MCP_TOOL_PREFIX): if "name" in tool:
tool["name"] = _MCP_TOOL_PREFIX + tool["name"] tool["name"] = _to_oauth_wire_name(tool["name"])
# 4. Prefix tool names in message history (tool_use and tool_result blocks) # 4. Apply the same normalization to tool names in message history
# (tool_use blocks) so replayed turns match the wire names above.
for msg in anthropic_messages: for msg in anthropic_messages:
content = msg.get("content") content = msg.get("content")
if isinstance(content, list): if isinstance(content, list):
for block in content: for block in content:
if isinstance(block, dict): if isinstance(block, dict):
if block.get("type") == "tool_use" and "name" in block: if block.get("type") == "tool_use" and "name" in block:
if not block["name"].startswith(_MCP_TOOL_PREFIX): block["name"] = _to_oauth_wire_name(block["name"])
block["name"] = _MCP_TOOL_PREFIX + block["name"]
elif block.get("type") == "tool_result" and "tool_use_id" in block: elif block.get("type") == "tool_result" and "tool_use_id" in block:
pass # tool_result uses ID, not name pass # tool_result uses ID, not name

View File

@ -300,6 +300,7 @@ def summarize_background_review_actions(
"target": args.get("target", "memory"), "target": args.get("target", "memory"),
"content": args.get("content", ""), "content": args.get("content", ""),
"old_text": args.get("old_text", ""), "old_text": args.get("old_text", ""),
"operations": args.get("operations") or [],
"name": args.get("name", ""), "name": args.get("name", ""),
"old_string": args.get("old_string", ""), "old_string": args.get("old_string", ""),
"new_string": args.get("new_string", ""), "new_string": args.get("new_string", ""),
@ -353,6 +354,7 @@ def summarize_background_review_actions(
content = detail.get("content", "") content = detail.get("content", "")
old_text = detail.get("old_text", "") old_text = detail.get("old_text", "")
skill_name = detail.get("name", "") skill_name = detail.get("name", "")
operations = detail.get("operations") or []
max_preview = 120 max_preview = 120
if is_skill: if is_skill:
change = data.get("_change", {}) change = data.get("_change", {})
@ -376,6 +378,21 @@ def summarize_background_review_actions(
actions.append(f"📝 Skill '{skill_name}' rewritten: {description}") actions.append(f"📝 Skill '{skill_name}' rewritten: {description}")
else: else:
actions.append(f"📝 {message}" if message else f"Skill {action}") actions.append(f"📝 {message}" if message else f"Skill {action}")
elif operations:
for op in operations:
op = op or {}
op_act = op.get("action", "")
op_content = (op.get("content") or "")
op_old = (op.get("old_text") or "")
if op_act == "add" and op_content:
preview = op_content[:max_preview] + ("" if len(op_content) > max_preview else "")
actions.append(f"{label} {preview}")
elif op_act == "replace" and op_content:
preview = op_content[:max_preview] + ("" if len(op_content) > max_preview else "")
actions.append(f"{label} ✏️ {preview}")
elif op_act == "remove" and op_old:
preview = op_old[:60] + ("" if len(op_old) > 60 else "")
actions.append(f"{label} {preview}")
elif action == "add" and content: elif action == "add" and content:
preview = content[:max_preview] + ("" if len(content) > max_preview else "") preview = content[:max_preview] + ("" if len(content) > max_preview else "")
actions.append(f"{label} {preview}") actions.append(f"{label} {preview}")
@ -391,6 +408,7 @@ def summarize_background_review_actions(
"added" in message_lower "added" in message_lower
or "replaced" in message_lower or "replaced" in message_lower
or "removed" in message_lower or "removed" in message_lower
or "applied" in message_lower
or (target and "add" in message.lower()) or (target and "add" in message.lower())
or "Entry added" in message or "Entry added" in message
): ):

295
agent/billing_view.py Normal file
View File

@ -0,0 +1,295 @@
"""Surface-agnostic core for the Phase 2b terminal-billing screens.
One fetch/parse per concern, consumed identically by the CLI handler
(``cli.py::_show_billing``), the TUI JSON-RPC methods
(``tui_gateway/server.py``), and any other surface. Mirrors the proven
``agent/account_usage.py::build_credits_view`` pattern: parse the server payload
into a frozen dataclass; **fail open** 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 discipline: the server emits decimal STRINGS (``"142.5"``, not fixed 2dp).
We keep them as :class:`decimal.Decimal` end-to-end and only format for display.
"""
from __future__ import annotations
import logging
import uuid
from dataclasses import dataclass, field
from decimal import Decimal, InvalidOperation
from typing import Any, Optional
logger = logging.getLogger(__name__)
# =============================================================================
# Decimal money helpers
# =============================================================================
def parse_money(value: Any) -> Optional[Decimal]:
"""Parse a server money value (decimal string) into :class:`Decimal`.
Returns None for missing/invalid input. Never raises. Accepts str/int (and,
defensively, float though the server always sends strings).
"""
if value is None:
return None
try:
# Decimal(str(...)) avoids binary-float artifacts if a float ever sneaks in.
return Decimal(str(value).strip())
except (InvalidOperation, ValueError, TypeError):
return None
def format_money(value: Optional[Decimal]) -> str:
"""Format a Decimal as ``$X`` / ``$X.YY`` for display.
Whole dollars show no decimals; any fractional amount shows exactly 2dp:
``Decimal("142.5")`` ``"$142.50"``, ``Decimal("100")`` ``"$100"``,
``Decimal("0.01")`` ``"$0.01"``.
"""
if value is None:
return ""
if value == value.to_integral_value():
# Whole dollars — no decimal point. format(..., "f") avoids 1E+3 for 1000.
return f"${format(value.to_integral_value(), 'f')}"
# Fractional — always show 2dp.
return f"${format(value.quantize(Decimal('0.01')), 'f')}"
# =============================================================================
# Parsed sub-structures
# =============================================================================
@dataclass(frozen=True)
class CardInfo:
brand: str
last4: str
@property
def masked(self) -> str:
return f"{self.brand} ····{self.last4}"
@dataclass(frozen=True)
class MonthlyCap:
limit_usd: Optional[Decimal] = None
spent_this_month_usd: Optional[Decimal] = None
is_default_ceiling: bool = False
@dataclass(frozen=True)
class AutoReload:
enabled: bool = False
threshold_usd: Optional[Decimal] = None
reload_to_usd: Optional[Decimal] = None
@dataclass(frozen=True)
class BillingState:
"""Parsed ``GET /api/billing/state`` — 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_id: Optional[str] = None
org_slug: Optional[str] = None
org_name: Optional[str] = None
role: Optional[str] = None # "OWNER" | "ADMIN" | "MEMBER"
balance_usd: Optional[Decimal] = None
cli_billing_enabled: bool = False
charge_presets: tuple[Decimal, ...] = ()
min_usd: Optional[Decimal] = None
max_usd: Optional[Decimal] = None
card: Optional[CardInfo] = None
monthly_cap: Optional[MonthlyCap] = None
auto_reload: Optional[AutoReload] = None
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:
"""True for OWNER/ADMIN — the roles that can manage billing."""
return (self.role or "").upper() in ("OWNER", "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.)
"""
return self.is_admin and self.cli_billing_enabled
def _parse_card(raw: Any) -> Optional[CardInfo]:
if not isinstance(raw, dict):
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
def _parse_monthly_cap(raw: Any) -> Optional[MonthlyCap]:
if not isinstance(raw, dict):
return None
return MonthlyCap(
limit_usd=parse_money(raw.get("limitUsd")),
spent_this_month_usd=parse_money(raw.get("spentThisMonthUsd")),
is_default_ceiling=bool(raw.get("isDefaultCeiling")),
)
def _parse_auto_reload(raw: Any) -> Optional[AutoReload]:
if not isinstance(raw, dict):
return None
return AutoReload(
enabled=bool(raw.get("enabled")),
threshold_usd=parse_money(raw.get("thresholdUsd")),
reload_to_usd=parse_money(raw.get("reloadToUsd")),
)
def billing_state_from_payload(
payload: dict[str, Any], *, portal_url: Optional[str] = None
) -> BillingState:
"""Map a raw ``/api/billing/state`` JSON dict into :class:`BillingState`."""
raw_org = payload.get("org")
org: dict[str, Any] = raw_org if isinstance(raw_org, dict) else {}
raw_bounds = payload.get("bounds")
bounds: dict[str, Any] = raw_bounds if isinstance(raw_bounds, dict) else {}
presets: list[Decimal] = []
for item in payload.get("chargePresets") or ():
parsed = parse_money(item)
if parsed is not None:
presets.append(parsed)
return BillingState(
logged_in=True,
org_id=org.get("id"),
org_slug=org.get("slug"),
org_name=org.get("name"),
role=org.get("role"),
balance_usd=parse_money(payload.get("balanceUsd")),
cli_billing_enabled=bool(payload.get("cliBillingEnabled")),
charge_presets=tuple(presets),
min_usd=parse_money(bounds.get("minUsd")),
max_usd=parse_money(bounds.get("maxUsd")),
card=_parse_card(payload.get("card")),
monthly_cap=_parse_monthly_cap(payload.get("monthlyCap")),
auto_reload=_parse_auto_reload(payload.get("autoReload")),
portal_url=portal_url,
)
# =============================================================================
# Fail-open builders (the surface front doors)
# =============================================================================
def build_billing_state(*, timeout: float = 15.0) -> BillingState:
"""Fetch + parse ``/api/billing/state``. Fail-open.
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.
"""
try:
from hermes_cli.nous_billing import (
BillingAuthError,
BillingError,
_absolutize_portal_url,
get_billing_state,
resolve_portal_base_url,
)
except Exception:
return BillingState(logged_in=False, error="billing client unavailable")
try:
payload = get_billing_state(timeout=timeout)
except BillingAuthError:
return BillingState(logged_in=False)
except BillingError as exc:
logger.debug("billing ▸ /state fetch failed (fail-open)", exc_info=True)
return BillingState(logged_in=False, error=str(exc))
except Exception:
logger.debug("billing ▸ /state unexpected error (fail-open)", exc_info=True)
return BillingState(logged_in=False, error="could not load billing state")
# Prefer a server-supplied portalUrl if present (resolved to absolute in case
# it's relative); else build the standard one.
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 = _fallback_portal_url(resolve_portal_base_url())
except Exception:
portal_url = None
return billing_state_from_payload(payload, portal_url=portal_url)
def _fallback_portal_url(base: str) -> str:
"""Standard billing deep-link when the server omits ``portalUrl``."""
return f"{base.rstrip('/')}/billing?topup=open"
# =============================================================================
# Idempotency
# =============================================================================
def new_idempotency_key() -> str:
"""Fresh UUID for a user-confirmed purchase (reuse on retry of the SAME buy).
The ``Idempotency-Key`` header is mandatory on ``POST /charge``; generate one
per confirmed purchase and reuse it across retries so a double-submit collapses
to a single charge. Never reuse a key across different amounts (the server
returns 409 idempotency_conflict).
"""
return str(uuid.uuid4())
# =============================================================================
# Amount validation (Screen 3 custom input)
# =============================================================================
@dataclass(frozen=True)
class AmountValidation:
ok: bool
amount: Optional[Decimal] = None
error: Optional[str] = None
def validate_charge_amount(
raw: str, *, min_usd: Optional[Decimal], max_usd: Optional[Decimal]
) -> AmountValidation:
"""Validate a custom charge amount against bounds + 2dp (multipleOf 0.01).
Mirrors the server's accept/reject so the UI can give instant feedback rather
than round-tripping a sure-to-fail charge. The server is still authoritative.
"""
cleaned = (raw or "").strip().lstrip("$").strip()
amount = parse_money(cleaned)
if amount is None:
return AmountValidation(ok=False, error="Enter a dollar amount, e.g. 100")
if amount <= 0:
return AmountValidation(ok=False, error="Amount must be greater than $0")
# multipleOf 0.01 — reject sub-cent precision.
if amount != amount.quantize(Decimal("0.01")):
return AmountValidation(ok=False, error="Amount can't be smaller than a cent")
if min_usd is not None and amount < min_usd:
return AmountValidation(ok=False, error=f"Minimum is {format_money(min_usd)}")
if max_usd is not None and amount > max_usd:
return AmountValidation(ok=False, error=f"Maximum is {format_money(max_usd)}")
return AmountValidation(ok=True, amount=amount)

View File

@ -262,6 +262,26 @@ def _responses_tools(tools: Optional[List[Dict[str, Any]]] = None) -> Optional[L
return converted or None return converted or None
# Provider-executed built-in tool *declaration* types accepted on the
# Responses ``tools`` array. These are declared by ``type`` alone (no
# client-side name/parameters schema) and run server-side — the provider
# owns the implementation and reports progress via the matching ``*_call``
# output items. Hermes injects xAI's native ``web_search`` for the xAI
# transport (see agent/transports/codex.py); the rest are listed so the
# preflight validator passes them through rather than rejecting them as
# "unsupported type". Mirrors the ``*_call`` item-type set used in
# _normalize_codex_response.
_RESPONSES_BUILTIN_TOOL_TYPES = {
"web_search",
"web_search_preview",
"file_search",
"code_interpreter",
"image_generation",
"computer_use_preview",
"local_shell",
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Message format conversion # Message format conversion
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -802,7 +822,22 @@ def _preflight_codex_api_kwargs(
for idx, tool in enumerate(tools): for idx, tool in enumerate(tools):
if not isinstance(tool, dict): if not isinstance(tool, dict):
raise ValueError(f"Codex Responses tools[{idx}] must be an object.") raise ValueError(f"Codex Responses tools[{idx}] must be an object.")
if tool.get("type") != "function":
tool_type = tool.get("type")
# Provider-executed built-in tools (xAI native web_search, code
# interpreter, etc.) are declared by ``type`` alone and carry no
# ``name``/``parameters`` schema — the provider owns the
# implementation. Pass them through verbatim instead of forcing
# them through the function-tool validation below (which would
# otherwise reject them with "unsupported type"). See
# agent/transports/codex.py for where xAI's native web_search is
# injected.
if tool_type in _RESPONSES_BUILTIN_TOOL_TYPES:
normalized_tools.append(dict(tool))
continue
if tool_type != "function":
raise ValueError(f"Codex Responses tools[{idx}] has unsupported type {tool.get('type')!r}.") raise ValueError(f"Codex Responses tools[{idx}] has unsupported type {tool.get('type')!r}.")
name = tool.get("name") name = tool.get("name")
@ -1086,6 +1121,33 @@ def _normalize_codex_response(
saw_final_answer_phase = False saw_final_answer_phase = False
saw_reasoning_item = False saw_reasoning_item = False
# Server-side built-in tool calls (xAI's native web_search, code
# interpreter, etc.) are executed by the provider and reported as
# discrete ``*_call`` output items. xAI's /v1/responses surface
# (e.g. grok-composer-2.5-fast on SuperGrok OAuth) routinely leaves
# these items at ``status="in_progress"`` even when the overall
# ``response.status == "completed"`` — the search ran to completion
# server-side, the per-item status simply isn't reconciled. These
# are NOT a signal that the model's turn is unfinished, so they must
# not flip ``has_incomplete_items``. Only the response-level status
# and genuine model output items (message/reasoning/function_call)
# govern the incomplete verdict. Without this guard, any turn where
# grok-composer invokes server-side search is misclassified as
# ``finish_reason="incomplete"`` and burns 3 fruitless continuation
# retries before failing with "Codex response remained incomplete
# after 3 continuation attempts". client-side function/custom tool
# calls keep their own in_progress handling below (they are skipped,
# not awaited).
_SERVER_SIDE_TOOL_CALL_TYPES = {
"web_search_call",
"file_search_call",
"code_interpreter_call",
"image_generation_call",
"computer_call",
"local_shell_call",
"mcp_call",
}
for item in output: for item in output:
item_type = getattr(item, "type", None) item_type = getattr(item, "type", None)
item_status = getattr(item, "status", None) item_status = getattr(item, "status", None)
@ -1094,7 +1156,10 @@ def _normalize_codex_response(
else: else:
item_status = None item_status = None
if item_status in {"queued", "in_progress", "incomplete"}: if (
item_status in {"queued", "in_progress", "incomplete"}
and item_type not in _SERVER_SIDE_TOOL_CALL_TYPES
):
has_incomplete_items = True has_incomplete_items = True
saw_streaming_or_item_incomplete = True saw_streaming_or_item_incomplete = True

View File

@ -512,6 +512,16 @@ def compress_context(
old_title = agent._session_db.get_session_title(agent.session_id) old_title = agent._session_db.get_session_title(agent.session_id)
# Trigger memory extraction on the old session before it rotates. # Trigger memory extraction on the old session before it rotates.
agent.commit_memory_session(messages) agent.commit_memory_session(messages)
# Flush any un-persisted messages from the current turn to the
# old session *before* rotating. compress_context() can be
# called mid-turn (auto-compress when context exceeds threshold)
# at a point when _flush_messages_to_session_db() has not yet
# run. Without this, messages generated during the current turn
# are silently lost on session rotation (#47202).
try:
agent._flush_messages_to_session_db(messages)
except Exception:
pass # best-effort — don't block compression on a flush error
agent._session_db.end_session(agent.session_id, "compression") agent._session_db.end_session(agent.session_id, "compression")
old_session_id = agent.session_id old_session_id = agent.session_id
agent.session_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}" agent.session_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}"
@ -603,6 +613,20 @@ def compress_context(
force=True, force=True,
) )
# Emit session:compress event so hooks (e.g. MemPalace sync) can ingest
# the completed old session before its details are lost.
_old_sid_for_event = locals().get("old_session_id")
if getattr(agent, "event_callback", None):
try:
agent.event_callback("session:compress", {
"platform": agent.platform or "",
"session_id": agent.session_id,
"old_session_id": _old_sid_for_event or "",
"compression_count": agent.context_compressor.compression_count,
})
except Exception as e:
logger.debug("event_callback error on session:compress: %s", e)
# Keep the post-compression rough estimate for diagnostics, but do not # Keep the post-compression rough estimate for diagnostics, but do not
# treat it as provider-reported prompt usage. Schema-heavy rough estimates # treat it as provider-reported prompt usage. Schema-heavy rough estimates
# can remain above threshold even after the next real API request fits. # can remain above threshold even after the next real API request fits.

View File

@ -300,11 +300,20 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history)
agent.session_id, exc, agent.session_id, exc,
) )
if stored_prompt: if stored_prompt and _stored_prompt_matches_runtime(agent, stored_prompt):
# Continuing session — reuse the exact system prompt from the # Continuing session — reuse the exact system prompt from the
# previous turn so the Anthropic cache prefix matches. # previous turn so the Anthropic cache prefix matches.
agent._cached_system_prompt = stored_prompt agent._cached_system_prompt = stored_prompt
return return
if stored_prompt:
stored_state = "stale_runtime"
logger.info(
"Stored system prompt for session %s has stale runtime identity; "
"rebuilding for model=%s provider=%s.",
agent.session_id,
getattr(agent, "model", "") or "",
getattr(agent, "provider", "") or "",
)
if conversation_history and stored_state in ("null", "empty"): if conversation_history and stored_state in ("null", "empty"):
# Continuing session whose stored prompt is unusable. The # Continuing session whose stored prompt is unusable. The
@ -366,6 +375,30 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history)
) )
def _stored_prompt_matches_runtime(agent, prompt: str) -> bool:
"""Return False when the persisted Model/Provider lines are stale."""
def line_value(label: str) -> str:
prefix = f"{label}:"
value = ""
for line in prompt.splitlines():
if line.startswith(prefix):
value = line[len(prefix):].strip()
return value
stored_model = line_value("Model")
current_model = str(getattr(agent, "model", "") or "").strip()
if stored_model and current_model and stored_model != current_model:
return False
stored_provider = line_value("Provider")
current_provider = str(getattr(agent, "provider", "") or "").strip()
if stored_provider and current_provider and stored_provider != current_provider:
return False
return True
def _get_continuation_prompt(is_partial_stub: bool, dropped_tools: Optional[List[str]] = None) -> str: def _get_continuation_prompt(is_partial_stub: bool, dropped_tools: Optional[List[str]] = None) -> str:
if is_partial_stub and dropped_tools: if is_partial_stub and dropped_tools:
tool_list = ", ".join(dropped_tools[:3]) tool_list = ", ".join(dropped_tools[:3])
@ -441,6 +474,7 @@ def run_conversation(
task_id: str = None, task_id: str = None,
stream_callback: Optional[callable] = None, stream_callback: Optional[callable] = None,
persist_user_message: Optional[str] = None, persist_user_message: Optional[str] = None,
persist_user_timestamp: Optional[float] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Run a complete conversation with tool calling until completion. Run a complete conversation with tool calling until completion.
@ -456,6 +490,8 @@ def run_conversation(
persist_user_message: Optional clean user message to store in persist_user_message: Optional clean user message to store in
transcripts/history when user_message contains API-only transcripts/history when user_message contains API-only
synthetic prefixes. synthetic prefixes.
persist_user_timestamp: Optional platform event timestamp to store
as metadata on that persisted user message.
or queuing follow-up prefetch work. or queuing follow-up prefetch work.
Returns: Returns:
@ -477,6 +513,7 @@ def run_conversation(
task_id, task_id,
stream_callback, stream_callback,
persist_user_message, persist_user_message,
persist_user_timestamp,
restore_or_build_system_prompt=_restore_or_build_system_prompt, restore_or_build_system_prompt=_restore_or_build_system_prompt,
install_safe_stdio=_install_safe_stdio, install_safe_stdio=_install_safe_stdio,
sanitize_surrogates=_sanitize_surrogates, sanitize_surrogates=_sanitize_surrogates,
@ -3719,8 +3756,30 @@ def run_conversation(
assistant_msg = agent._build_assistant_message(assistant_message, finish_reason) assistant_msg = agent._build_assistant_message(assistant_message, finish_reason)
messages.append(assistant_msg) messages.append(assistant_msg)
for tc in assistant_message.tool_calls: for tc in assistant_message.tool_calls:
if tc.function.name not in agent.valid_tool_names: _tc_name = tc.function.name
content = f"Tool '{tc.function.name}' does not exist. Available tools: {available}" if _tc_name not in agent.valid_tool_names:
# A blank/whitespace-only name is not a typo the
# model can fuzzy-correct toward a real tool — it is
# almost always a weak open model echoing tool-call
# XML/JSON it saw in file or tool output (#47967:
# <tool_call>/<invoke name=...> payloads in a file
# prime mimo/nemotron-class models to emit empty
# structured calls). Dumping the full tool catalog
# in that case feeds the priming loop more names to
# mimic and inflates context 3-4x across retries, so
# send a terse error that tells the model in-context
# tool-call syntax is DATA, not a call to make.
if not (_tc_name or "").strip():
content = (
"Tool call rejected: the tool name was empty. "
"If tool-call XML or JSON appeared in file "
"contents or tool output, that is data — do "
"not re-emit it as a tool call. To call a "
"tool, use a valid name from your tool list; "
"otherwise reply in plain text."
)
else:
content = f"Tool '{_tc_name}' does not exist. Available tools: {available}"
else: else:
content = "Skipped: another tool call in this turn used an invalid name. Please retry this tool call." content = "Skipped: another tool call in this turn used an invalid name. Please retry this tool call."
messages.append({ messages.append({

View File

@ -57,6 +57,11 @@ DEFAULT_INTERVAL_HOURS = 24 * 7 # 7 days
DEFAULT_MIN_IDLE_HOURS = 2 DEFAULT_MIN_IDLE_HOURS = 2
DEFAULT_STALE_AFTER_DAYS = 30 DEFAULT_STALE_AFTER_DAYS = 30
DEFAULT_ARCHIVE_AFTER_DAYS = 90 DEFAULT_ARCHIVE_AFTER_DAYS = 90
# Consolidation (the LLM umbrella-building fork) is OFF by default. The
# deterministic inactivity prune (apply_automatic_transitions) still runs
# whenever the curator is enabled; only the opinionated, aux-model-cost
# consolidation pass is opt-in.
DEFAULT_CONSOLIDATE = False
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -182,6 +187,22 @@ def get_prune_builtins() -> bool:
return bool(cfg.get("prune_builtins", True)) return bool(cfg.get("prune_builtins", True))
def get_consolidate() -> bool:
"""Whether the curator runs its LLM consolidation (umbrella-building) pass.
OFF by default. When off, a curator run does ONLY the deterministic
inactivity prune (mark stale / archive long-unused skills) and skips the
forked aux-model review entirely no consolidation, no umbrella-building,
no aux-model cost. Set ``curator.consolidate: true`` to opt back into the
LLM pass that merges overlapping skills into class-level umbrellas.
The explicit ``hermes curator run --consolidate`` flag overrides this for
a single invocation regardless of the config value.
"""
cfg = _load_config()
return bool(cfg.get("consolidate", DEFAULT_CONSOLIDATE))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Idle / interval check # Idle / interval check
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -1408,25 +1429,38 @@ def run_curator_review(
on_summary: Optional[Callable[[str], None]] = None, on_summary: Optional[Callable[[str], None]] = None,
synchronous: bool = False, synchronous: bool = False,
dry_run: bool = False, dry_run: bool = False,
consolidate: Optional[bool] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Execute a single curator review pass. """Execute a single curator review pass.
Steps: Steps:
1. Apply automatic state transitions (pure, no LLM). 1. Apply automatic state transitions (pure, no LLM).
2. If there are agent-created skills, spawn a forked AIAgent that runs 2. If consolidation is enabled AND there are agent-created skills, spawn
the LLM review prompt against the current candidate list. a forked AIAgent that runs the LLM review prompt against the current
candidate list.
3. Update .curator_state with last_run_at and a one-line summary. 3. Update .curator_state with last_run_at and a one-line summary.
4. Invoke *on_summary* with a user-visible description. 4. Invoke *on_summary* with a user-visible description.
If *synchronous* is True, the LLM review runs in the calling thread; the If *synchronous* is True, the LLM review runs in the calling thread; the
default is to spawn a daemon thread so the caller returns immediately. default is to spawn a daemon thread so the caller returns immediately.
*consolidate* gates the LLM umbrella-building pass. ``None`` (the default)
reads ``curator.consolidate`` from config (OFF by default). Passing
``True``/``False`` overrides the config for this invocation used by the
``hermes curator run --consolidate`` flag. When consolidation is off, only
the deterministic inactivity prune runs and the forked aux-model review is
skipped entirely (no aux-model cost).
If *dry_run* is True, the automatic stale/archive transitions are SKIPPED If *dry_run* is True, the automatic stale/archive transitions are SKIPPED
and the LLM review pass is instructed to produce a report only no and the LLM review pass is instructed to produce a report only no
skill_manage mutations, no terminal archive moves. The REPORT.md still skill_manage mutations, no terminal archive moves. The REPORT.md still
gets written and ``state.last_report_path`` still records it so users gets written and ``state.last_report_path`` still records it so users
can read what the curator WOULD have done. can read what the curator WOULD have done. A dry-run also honors
*consolidate*: when consolidation is off, the preview only reports the
deterministic prune candidates.
""" """
if consolidate is None:
consolidate = get_consolidate()
start = datetime.now(timezone.utc) start = datetime.now(timezone.utc)
if dry_run: if dry_run:
# Count candidates without mutating state. # Count candidates without mutating state.
@ -1489,6 +1523,53 @@ def run_curator_review(
before_report = [] before_report = []
before_names = {r.get("name") for r in before_report if isinstance(r, dict)} before_names = {r.get("name") for r in before_report if isinstance(r, dict)}
# Consolidation gate. When off (the default), the curator does ONLY the
# deterministic inactivity prune above — no forked aux-model review, no
# umbrella-building, no aux-model cost. Record the run, write a report
# reflecting the prune-only outcome, and return without spawning a fork.
if not consolidate:
final_summary = (
f"{prefix}{auto_summary}; llm: skipped (consolidation off)"
)
llm_meta = {
"final": "",
"summary": "skipped (consolidation off)",
"model": "",
"provider": "",
"tool_calls": [],
"error": None,
}
elapsed = (datetime.now(timezone.utc) - start).total_seconds()
state2 = load_state()
state2["last_run_duration_seconds"] = elapsed
state2["last_run_summary"] = final_summary
try:
after_report = skill_usage.agent_created_report()
except Exception:
after_report = []
try:
report_path = _write_run_report(
started_at=start,
elapsed_seconds=elapsed,
auto_counts=counts,
auto_summary=auto_summary,
before_report=before_report,
before_names=before_names,
after_report=after_report,
llm_meta=llm_meta,
)
if report_path is not None:
state2["last_report_path"] = str(report_path)
except Exception as e:
logger.debug("Curator report write failed: %s", e, exc_info=True)
save_state(state2)
if on_summary:
try:
on_summary(f"curator: {final_summary}")
except Exception:
pass
return
llm_meta: Dict[str, Any] = {} llm_meta: Dict[str, Any] = {}
try: try:
candidate_list = _render_candidate_list() candidate_list = _render_candidate_list()

View File

@ -46,7 +46,7 @@ import shutil
import tarfile import tarfile
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Set, Tuple
from hermes_constants import get_hermes_home from hermes_constants import get_hermes_home
from agent.skill_utils import is_excluded_skill_path from agent.skill_utils import is_excluded_skill_path
@ -208,13 +208,17 @@ def _write_manifest(dest: Path, reason: str, archive_path: Path,
) )
def snapshot_skills(reason: str = "manual") -> Optional[Path]: def snapshot_skills(reason: str = "manual", *, protect_ids: Optional[Set[str]] = None) -> Optional[Path]:
"""Create a tar.gz snapshot of ``~/.hermes/skills/`` and prune old ones. """Create a tar.gz snapshot of ``~/.hermes/skills/`` and prune old ones.
Returns the snapshot directory path, or ``None`` if the snapshot was Returns the snapshot directory path, or ``None`` if the snapshot was
skipped (backup disabled, skills dir missing, or an IO error occurred skipped (backup disabled, skills dir missing, or an IO error occurred
in which case we log at debug and return None so the curator never in which case we log at debug and return None so the curator never
aborts a pass because of a backup failure). aborts a pass because of a backup failure).
``protect_ids`` is forwarded to the prune step so callers can guarantee
specific snapshot ids survive even when they fall outside the keep
window (rollback passes the id it is about to restore from).
""" """
if not is_enabled(): if not is_enabled():
logger.debug("Curator backup disabled by config; skipping snapshot") logger.debug("Curator backup disabled by config; skipping snapshot")
@ -276,15 +280,19 @@ def snapshot_skills(reason: str = "manual") -> Optional[Path]:
pass pass
return None return None
_prune_old(keep=get_keep()) _prune_old(keep=get_keep(), protect=protect_ids)
logger.info("Curator snapshot created: %s (%s)", snap_id, reason) logger.info("Curator snapshot created: %s (%s)", snap_id, reason)
return dest return dest
def _prune_old(keep: int) -> List[str]: def _prune_old(keep: int, protect: Optional[Set[str]] = None) -> List[str]:
"""Delete regular snapshots beyond the newest *keep*. Returns deleted """Delete regular snapshots beyond the newest *keep*. Returns deleted
ids. Staging dirs (``.rollback-staging-*``) are implementation detail ids. Snapshot ids in *protect* are never deleted even when they fall
and pruned independently on every call.""" outside the keep window rollback() uses this so the mandatory
pre-rollback safety snapshot can never evict the very snapshot being
restored. Staging dirs (``.rollback-staging-*``) are implementation
detail and pruned independently on every call."""
protect = protect or set()
backups = _backups_dir() backups = _backups_dir()
if not backups.exists(): if not backups.exists():
return [] return []
@ -305,6 +313,8 @@ def _prune_old(keep: int) -> List[str]:
entries.sort(key=lambda t: t[0], reverse=True) entries.sort(key=lambda t: t[0], reverse=True)
deleted: List[str] = [] deleted: List[str] = []
for _, path in entries[keep:]: for _, path in entries[keep:]:
if path.name in protect:
continue
try: try:
shutil.rmtree(path) shutil.rmtree(path)
deleted.append(path.name) deleted.append(path.name)
@ -564,7 +574,13 @@ def rollback(backup_id: Optional[str] = None) -> Tuple[bool, str, Optional[Path]
# out before touching anything — otherwise a failed extract could leave # out before touching anything — otherwise a failed extract could leave
# the user with no skills. # the user with no skills.
try: try:
snapshot_skills(reason=f"pre-rollback to {target.name}") # Protect the target from this snapshot's prune step: at the steady
# keep limit, pruning the oldest snapshot would otherwise delete the
# very snapshot we are about to extract from.
snapshot_skills(
reason=f"pre-rollback to {target.name}",
protect_ids={target.name},
)
except Exception as e: except Exception as e:
return (False, f"pre-rollback safety snapshot failed: {e}", None) return (False, f"pre-rollback safety snapshot failed: {e}", None)

View File

@ -33,6 +33,7 @@ from concurrent.futures import ThreadPoolExecutor
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from agent.memory_provider import MemoryProvider from agent.memory_provider import MemoryProvider
from agent.skill_commands import extract_user_instruction_from_skill_message
from tools.registry import tool_error from tools.registry import tool_error
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -430,16 +431,37 @@ class MemoryManager:
# -- Prefetch / recall --------------------------------------------------- # -- Prefetch / recall ---------------------------------------------------
@staticmethod
def _strip_skill_scaffolding(text: str) -> Optional[str]:
"""Return memory-worthy user text, or None to skip the turn.
When a user invokes a /skill or /bundle, Hermes expands the turn into
a model-facing message that embeds the entire skill body. Feeding that
verbatim to memory providers pollutes their stores/embeddings with
prompt scaffolding instead of what the user actually asked. We recover
just the user's instruction here, once, for every provider — so this
is fixed for the whole provider fan-out, not per backend.
- Non-skill messages pass through unchanged.
- Skill turns with a user instruction return that instruction.
- Bare skill invocations (no instruction) return None callers skip
the turn, since there is no user content worth remembering.
"""
return extract_user_instruction_from_skill_message(text)
def prefetch_all(self, query: str, *, session_id: str = "") -> str: def prefetch_all(self, query: str, *, session_id: str = "") -> str:
"""Collect prefetch context from all providers. """Collect prefetch context from all providers.
Returns merged context text labeled by provider. Empty providers Returns merged context text labeled by provider. Empty providers
are skipped. Failures in one provider don't block others. are skipped. Failures in one provider don't block others.
""" """
clean_query = self._strip_skill_scaffolding(query)
if not clean_query:
return ""
parts = [] parts = []
for provider in self._providers: for provider in self._providers:
try: try:
result = provider.prefetch(query, session_id=session_id) result = provider.prefetch(clean_query, session_id=session_id)
if result and result.strip(): if result and result.strip():
parts.append(result) parts.append(result)
except Exception as e: except Exception as e:
@ -460,10 +482,14 @@ class MemoryManager:
if not providers: if not providers:
return return
clean_query = self._strip_skill_scaffolding(query)
if not clean_query:
return
def _run() -> None: def _run() -> None:
for provider in providers: for provider in providers:
try: try:
provider.queue_prefetch(query, session_id=session_id) provider.queue_prefetch(clean_query, session_id=session_id)
except Exception as e: except Exception as e:
logger.debug( logger.debug(
"Memory provider '%s' queue_prefetch failed (non-fatal): %s", "Memory provider '%s' queue_prefetch failed (non-fatal): %s",
@ -515,6 +541,11 @@ class MemoryManager:
if not providers: if not providers:
return return
clean_user_content = self._strip_skill_scaffolding(user_content)
if not clean_user_content:
return
user_content = clean_user_content
def _run() -> None: def _run() -> None:
for provider in providers: for provider in providers:
try: try:

View File

@ -275,6 +275,11 @@ DEFAULT_CONTEXT_LENGTHS = {
# via a custom provider. Values sourced from models.dev (2026-04). # via a custom provider. Values sourced from models.dev (2026-04).
# Keys use substring matching (longest-first), so e.g. "grok-4.20" # Keys use substring matching (longest-first), so e.g. "grok-4.20"
# matches "grok-4.20-0309-reasoning" / "-non-reasoning" / "-multi-agent-0309". # matches "grok-4.20-0309-reasoning" / "-non-reasoning" / "-multi-agent-0309".
# OAuth-only slug; absent from GET /v1/models. xAI publishes a 200k
# usable context window for Composer 2.5 on Grok Build (SuperGrok /
# Premium+); /v1/responses additionally enforces a ~262144 input+output
# budget, but the usable context (what we track here) is 200k.
"grok-composer": 200000, # grok-composer-2.5-fast (Grok Build CLI)
"grok-build": 256000, # grok-build-0.1 "grok-build": 256000, # grok-build-0.1
"grok-code-fast": 256000, # grok-code-fast-1 "grok-code-fast": 256000, # grok-code-fast-1
"grok-2-vision": 8192, # grok-2-vision, -1212, -latest "grok-2-vision": 8192, # grok-2-vision, -1212, -latest

View File

@ -8,6 +8,7 @@ import json
import logging import logging
import os import os
import threading import threading
import contextvars
from collections import OrderedDict from collections import OrderedDict
from pathlib import Path from pathlib import Path
@ -304,6 +305,47 @@ TASK_COMPLETION_GUIDANCE = (
"is always better than inventing a result." "is always better than inventing a result."
) )
# Universal parallel-tool-call guidance — applied to ALL models.
#
# Why this matters for cost: every assistant turn resends the entire
# accumulated conversation (and, on cache-friendly providers, re-reads the
# cached prefix and pays for the newly-appended turn). A model that issues
# one tool call per turn multiplies the number of round-trips — and therefore
# the resent context — for any task that needs several independent reads,
# searches, or safe lookups. Batching independent calls into a single
# assistant response collapses N turns into one, cutting both latency and the
# resent-context cost that compounds over a long conversation.
#
# The hermes-agent runtime already executes a batch of tool calls
# concurrently when they are independent (read-only tools always; path-scoped
# file ops when their targets don't overlap — see
# run_agent._execute_tool_calls / tool_dispatch_helpers). The missing piece
# was telling the *model* to emit those calls together in the first place.
# Until now the only batching steer in the prompt lived in
# GOOGLE_MODEL_OPERATIONAL_GUIDANCE — Gemini/Gemma got it, every other model
# got nothing. This block makes the steer universal; the now-redundant
# Google-only bullet has been dropped so no model receives it twice.
#
# Short on purpose — shipped in the cached system prompt to every user, every
# session. Token cost is paid once at install and amortised across all
# sessions via prefix caching. Keep it tight.
#
# Ported from cline/cline#11514 ("encourage parallel tool calls"), adapted
# from Cline's TypeScript tool-surface guidance to hermes-agent's Python
# prompt-assembly architecture.
PARALLEL_TOOL_CALL_GUIDANCE = (
"# Parallel tool calls\n"
"When you need several pieces of information that don't depend on each "
"other, request them together in a single response instead of one tool "
"call per turn. Independent reads, searches, web fetches, and read-only "
"commands should be batched into the same assistant turn — the runtime "
"executes independent calls concurrently, and batching avoids resending "
"the whole conversation on every extra round-trip.\n"
"Only serialize calls when a later call genuinely depends on an earlier "
"call's result (e.g. you must read a file before you can patch it). When "
"in doubt and the calls are independent, batch them."
)
# OpenAI GPT/Codex-specific execution guidance. Addresses known failure modes # OpenAI GPT/Codex-specific execution guidance. Addresses known failure modes
# where GPT models abandon work on partial results, skip prerequisite lookups, # where GPT models abandon work on partial results, skip prerequisite lookups,
# hallucinate instead of using tools, and declare "done" without verification. # hallucinate instead of using tools, and declare "done" without verification.
@ -385,9 +427,10 @@ GOOGLE_MODEL_OPERATIONAL_GUIDANCE = (
"package.json, requirements.txt, Cargo.toml, etc. before importing.\n" "package.json, requirements.txt, Cargo.toml, etc. before importing.\n"
"- **Conciseness:** Keep explanatory text brief — a few sentences, not " "- **Conciseness:** Keep explanatory text brief — a few sentences, not "
"paragraphs. Focus on actions and results over narration.\n" "paragraphs. Focus on actions and results over narration.\n"
"- **Parallel tool calls:** When you need to perform multiple independent " # Parallel-tool-call steering now lives in the universal
"operations (e.g. reading several files), make all the tool calls in a " # PARALLEL_TOOL_CALL_GUIDANCE block (injected for all models), so it is no
"single response rather than sequentially.\n" # longer duplicated here — keeping it would send Gemini/Gemma the same
# instruction twice.
"- **Non-interactive commands:** Use flags like -y, --yes, --non-interactive " "- **Non-interactive commands:** Use flags like -y, --yes, --non-interactive "
"to prevent CLI tools from hanging on prompts.\n" "to prevent CLI tools from hanging on prompts.\n"
"- **Keep going:** Work autonomously until the task is fully resolved. " "- **Keep going:** Work autonomously until the task is fully resolved. "
@ -957,6 +1000,80 @@ CONTEXT_FILE_MAX_CHARS = 20_000
CONTEXT_TRUNCATE_HEAD_RATIO = 0.7 CONTEXT_TRUNCATE_HEAD_RATIO = 0.7
CONTEXT_TRUNCATE_TAIL_RATIO = 0.2 CONTEXT_TRUNCATE_TAIL_RATIO = 0.2
# Dynamic-cap parameters (used when no explicit context_file_max_chars is set).
# The cap scales with the model's context window so large-context models rarely
# truncate a project doc, while small-context models stay at the historical
# 20K floor. ~4 chars/token is the usual English heuristic; we spend a small
# slice of the window on context files since they share the cached prefix with
# the system prompt, tools, memory, and the whole conversation.
_CONTEXT_FILE_CHARS_PER_TOKEN = 4
_CONTEXT_FILE_WINDOW_FRACTION = 0.06
_CONTEXT_FILE_DYNAMIC_CEILING = 500_000
def _dynamic_context_file_max_chars(context_length: Optional[int]) -> int:
"""Derive a char cap from the model's context window.
Returns at least ``CONTEXT_FILE_MAX_CHARS`` (the historical 20K floor) and
at most ``_CONTEXT_FILE_DYNAMIC_CEILING``. When ``context_length`` is
unknown/invalid, returns the flat default so behavior is unchanged.
"""
if not isinstance(context_length, int) or context_length <= 0:
return CONTEXT_FILE_MAX_CHARS
budget = int(
context_length * _CONTEXT_FILE_CHARS_PER_TOKEN * _CONTEXT_FILE_WINDOW_FRACTION
)
return max(CONTEXT_FILE_MAX_CHARS, min(budget, _CONTEXT_FILE_DYNAMIC_CEILING))
def _get_context_file_max_chars(context_length: Optional[int] = None) -> int:
"""Return the context-file truncation limit.
Resolution order:
1. Explicit ``context_file_max_chars`` in config.yaml user knows best,
always wins (including over the dynamic cap).
2. Dynamic cap derived from the model's ``context_length`` when provided
(scales the budget to the window; floor 20K, ceiling 500K).
3. ``CONTEXT_FILE_MAX_CHARS`` (20K) as the upstream-compatible fallback.
"""
try:
from hermes_cli.config import load_config
val = load_config().get("context_file_max_chars")
if isinstance(val, (int, float)) and val > 0:
return int(val)
except Exception as e:
logger.debug("Could not read context_file_max_chars from config: %s", e)
return _dynamic_context_file_max_chars(context_length)
# Collect truncation warnings so the caller (run_agent) can surface them.
# A ContextVar (not a module-global list) isolates accumulation per thread /
# per async task, so concurrent gateway-session prompt builds can't drain or
# clear each other's pending warnings (cross-session leak). Each build runs in
# its own context, collects its own warnings, and drains them synchronously.
_truncation_warnings: "contextvars.ContextVar[Optional[list]]" = contextvars.ContextVar(
"context_file_truncation_warnings", default=None
)
def _record_truncation_warning(msg: str) -> None:
"""Append a truncation warning to the current context's accumulator."""
warnings = _truncation_warnings.get()
if warnings is None:
warnings = []
_truncation_warnings.set(warnings)
warnings.append(msg)
def drain_truncation_warnings() -> list:
"""Return and clear any truncation warnings accumulated in this context."""
warnings = _truncation_warnings.get()
if not warnings:
return []
drained = list(warnings)
warnings.clear()
return drained
# ========================================================================= # =========================================================================
# Skills prompt cache # Skills prompt cache
@ -1463,19 +1580,47 @@ def build_nous_subscription_prompt(valid_tool_names: "set[str] | None" = None) -
# Context files (SOUL.md, AGENTS.md, .cursorrules) # Context files (SOUL.md, AGENTS.md, .cursorrules)
# ========================================================================= # =========================================================================
def _truncate_content(content: str, filename: str, max_chars: int = CONTEXT_FILE_MAX_CHARS) -> str: def _truncate_content(
"""Head/tail truncation with a marker in the middle.""" content: str,
filename: str,
max_chars: Optional[int] = None,
context_length: Optional[int] = None,
read_path: Optional[str] = None,
) -> str:
"""Head/tail truncation with a marker in the middle.
``filename`` is the human label used in warnings. ``read_path`` is the
concrete path the agent should ``read_file`` to recover the full content
(defaults to ``filename`` when not supplied). ``context_length`` lets the
cap scale to the model's window when no explicit config override is set.
"""
if max_chars is None:
max_chars = _get_context_file_max_chars(context_length)
if len(content) <= max_chars: if len(content) <= max_chars:
return content return content
target = read_path or filename
msg = (
f"⚠️ Context file {filename} TRUNCATED: "
f"{len(content)} chars exceeds limit of {max_chars}"
f"trim the file, pin a larger context_file_max_chars, or use a "
f"larger-context model!"
)
logger.warning(msg)
_record_truncation_warning(msg)
head_chars = int(max_chars * CONTEXT_TRUNCATE_HEAD_RATIO) head_chars = int(max_chars * CONTEXT_TRUNCATE_HEAD_RATIO)
tail_chars = int(max_chars * CONTEXT_TRUNCATE_TAIL_RATIO) tail_chars = int(max_chars * CONTEXT_TRUNCATE_TAIL_RATIO)
head = content[:head_chars] head = content[:head_chars]
tail = content[-tail_chars:] tail = content[-tail_chars:]
marker = f"\n\n[...truncated {filename}: kept {head_chars}+{tail_chars} of {len(content)} chars. Use file tools to read the full file.]\n\n" marker = (
f"\n\n[...truncated {filename}: kept {head_chars}+{tail_chars} of "
f"{len(content)} chars. The middle is omitted — if you need the full "
f"instructions, read the complete file with the read_file tool: "
f"{target}]\n\n"
)
return head + marker + tail return head + marker + tail
def load_soul_md() -> Optional[str]: def load_soul_md(context_length: Optional[int] = None) -> Optional[str]:
"""Load SOUL.md from HERMES_HOME and return its content, or None. """Load SOUL.md from HERMES_HOME and return its content, or None.
Used as the agent identity (slot #1 in the system prompt). When this Used as the agent identity (slot #1 in the system prompt). When this
@ -1496,14 +1641,17 @@ def load_soul_md() -> Optional[str]:
if not content: if not content:
return None return None
content = _scan_context_content(content, "SOUL.md") content = _scan_context_content(content, "SOUL.md")
content = _truncate_content(content, "SOUL.md") content = _truncate_content(
content, "SOUL.md", context_length=context_length,
read_path=str(soul_path),
)
return content return content
except Exception as e: except Exception as e:
logger.debug("Could not read SOUL.md from %s: %s", soul_path, e) logger.debug("Could not read SOUL.md from %s: %s", soul_path, e)
return None return None
def _load_hermes_md(cwd_path: Path) -> str: def _load_hermes_md(cwd_path: Path, context_length: Optional[int] = None) -> str:
""".hermes.md / HERMES.md — walk to git root.""" """.hermes.md / HERMES.md — walk to git root."""
hermes_md_path = _find_hermes_md(cwd_path) hermes_md_path = _find_hermes_md(cwd_path)
if not hermes_md_path: if not hermes_md_path:
@ -1520,13 +1668,16 @@ def _load_hermes_md(cwd_path: Path) -> str:
pass pass
content = _scan_context_content(content, rel) content = _scan_context_content(content, rel)
result = f"## {rel}\n\n{content}" result = f"## {rel}\n\n{content}"
return _truncate_content(result, ".hermes.md") return _truncate_content(
result, ".hermes.md", context_length=context_length,
read_path=str(hermes_md_path),
)
except Exception as e: except Exception as e:
logger.debug("Could not read %s: %s", hermes_md_path, e) logger.debug("Could not read %s: %s", hermes_md_path, e)
return "" return ""
def _load_agents_md(cwd_path: Path) -> str: def _load_agents_md(cwd_path: Path, context_length: Optional[int] = None) -> str:
"""AGENTS.md — top-level only (no recursive walk).""" """AGENTS.md — top-level only (no recursive walk)."""
for name in ["AGENTS.md", "agents.md"]: for name in ["AGENTS.md", "agents.md"]:
candidate = cwd_path / name candidate = cwd_path / name
@ -1536,13 +1687,16 @@ def _load_agents_md(cwd_path: Path) -> str:
if content: if content:
content = _scan_context_content(content, name) content = _scan_context_content(content, name)
result = f"## {name}\n\n{content}" result = f"## {name}\n\n{content}"
return _truncate_content(result, "AGENTS.md") return _truncate_content(
result, "AGENTS.md", context_length=context_length,
read_path=str(candidate),
)
except Exception as e: except Exception as e:
logger.debug("Could not read %s: %s", candidate, e) logger.debug("Could not read %s: %s", candidate, e)
return "" return ""
def _load_claude_md(cwd_path: Path) -> str: def _load_claude_md(cwd_path: Path, context_length: Optional[int] = None) -> str:
"""CLAUDE.md / claude.md — cwd only.""" """CLAUDE.md / claude.md — cwd only."""
for name in ["CLAUDE.md", "claude.md"]: for name in ["CLAUDE.md", "claude.md"]:
candidate = cwd_path / name candidate = cwd_path / name
@ -1552,13 +1706,16 @@ def _load_claude_md(cwd_path: Path) -> str:
if content: if content:
content = _scan_context_content(content, name) content = _scan_context_content(content, name)
result = f"## {name}\n\n{content}" result = f"## {name}\n\n{content}"
return _truncate_content(result, "CLAUDE.md") return _truncate_content(
result, "CLAUDE.md", context_length=context_length,
read_path=str(candidate),
)
except Exception as e: except Exception as e:
logger.debug("Could not read %s: %s", candidate, e) logger.debug("Could not read %s: %s", candidate, e)
return "" return ""
def _load_cursorrules(cwd_path: Path) -> str: def _load_cursorrules(cwd_path: Path, context_length: Optional[int] = None) -> str:
""".cursorrules + .cursor/rules/*.mdc — cwd only.""" """.cursorrules + .cursor/rules/*.mdc — cwd only."""
cursorrules_content = "" cursorrules_content = ""
cursorrules_file = cwd_path / ".cursorrules" cursorrules_file = cwd_path / ".cursorrules"
@ -1585,10 +1742,17 @@ def _load_cursorrules(cwd_path: Path) -> str:
if not cursorrules_content: if not cursorrules_content:
return "" return ""
return _truncate_content(cursorrules_content, ".cursorrules") return _truncate_content(
cursorrules_content, ".cursorrules", context_length=context_length,
read_path=str(cwd_path / ".cursorrules"),
)
def build_context_files_prompt(cwd: Optional[str] = None, skip_soul: bool = False) -> str: def build_context_files_prompt(
cwd: Optional[str] = None,
skip_soul: bool = False,
context_length: Optional[int] = None,
) -> str:
"""Discover and load context files for the system prompt. """Discover and load context files for the system prompt.
Priority (first found wins only ONE project context type is loaded): Priority (first found wins only ONE project context type is loaded):
@ -1598,7 +1762,11 @@ def build_context_files_prompt(cwd: Optional[str] = None, skip_soul: bool = Fals
4. .cursorrules / .cursor/rules/*.mdc (cwd only) 4. .cursorrules / .cursor/rules/*.mdc (cwd only)
SOUL.md from HERMES_HOME is independent and always included when present. SOUL.md from HERMES_HOME is independent and always included when present.
Each context source is capped at 20,000 chars.
Each context source is capped before injection. The cap defaults to the
model's context window (scaled — see ``_dynamic_context_file_max_chars``)
when *context_length* is provided, falling back to 20,000 chars otherwise.
An explicit ``context_file_max_chars`` in config.yaml always wins.
When *skip_soul* is True, SOUL.md is not included here (it was already When *skip_soul* is True, SOUL.md is not included here (it was already
loaded via ``load_soul_md()`` for the identity slot). loaded via ``load_soul_md()`` for the identity slot).
@ -1611,17 +1779,17 @@ def build_context_files_prompt(cwd: Optional[str] = None, skip_soul: bool = Fals
# Priority-based project context: first match wins # Priority-based project context: first match wins
project_context = ( project_context = (
_load_hermes_md(cwd_path) _load_hermes_md(cwd_path, context_length)
or _load_agents_md(cwd_path) or _load_agents_md(cwd_path, context_length)
or _load_claude_md(cwd_path) or _load_claude_md(cwd_path, context_length)
or _load_cursorrules(cwd_path) or _load_cursorrules(cwd_path, context_length)
) )
if project_context: if project_context:
sections.append(project_context) sections.append(project_context)
# SOUL.md from HERMES_HOME only — skip when already loaded as identity # SOUL.md from HERMES_HOME only — skip when already loaded as identity
if not skip_soul: if not skip_soul:
soul_content = load_soul_md() soul_content = load_soul_md(context_length)
if soul_content: if soul_content:
sections.append(soul_content) sections.append(soul_content)

View File

@ -26,6 +26,91 @@ _skill_commands_platform: Optional[str] = None
_SKILL_INVALID_CHARS = re.compile(r"[^a-z0-9-]") _SKILL_INVALID_CHARS = re.compile(r"[^a-z0-9-]")
_SKILL_MULTI_HYPHEN = re.compile(r"-{2,}") _SKILL_MULTI_HYPHEN = re.compile(r"-{2,}")
# ---------------------------------------------------------------------------
# Skill-scaffolding markers and the canonical extractor.
#
# When a user invokes a /skill (or /bundle), Hermes expands the turn into a
# model-facing message that embeds the full skill body plus scaffolding. That
# expanded text is what flows into the agent loop — and into memory providers
# via MemoryManager. Providers that store or embed the raw user turn (mem0,
# openviking, hindsight, retaindb, byterover, honcho, supermemory) would
# otherwise capture the entire skill body instead of what the user actually
# asked. ``extract_user_instruction_from_skill_message`` recovers just the
# user's instruction so memory stays clean.
#
# These markers MUST stay byte-identical to the builders below
# (``_build_skill_message`` here, ``build_bundle_invocation_message`` in
# agent/skill_bundles.py). They are co-located with the single-skill builder
# on purpose, and the bundle markers are asserted against the bundle builder in
# tests/openviking_plugin/test_openviking.py::test_skill_markers_match_hermes_scaffolding.
# ---------------------------------------------------------------------------
_SKILL_INVOCATION_PREFIX = "[IMPORTANT: The user has invoked the "
_SINGLE_SKILL_MARKER = "The full skill content is loaded below.]"
_SINGLE_SKILL_INSTRUCTION = (
"The user has provided the following instruction alongside the skill invocation: "
)
_RUNTIME_NOTE = "\n\n[Runtime note:"
_BUNDLE_MARKER = " skill bundle,"
_BUNDLE_USER_INSTRUCTION = "\nUser instruction: "
_BUNDLE_FIRST_SKILL_BLOCK = "\n\n[Loaded as part of the "
def extract_user_instruction_from_skill_message(content: Any) -> Optional[str]:
"""Recover the user's instruction from a slash-skill-expanded turn.
Returns:
- The original string unchanged when it is NOT skill scaffolding
(a normal user message passes straight through).
- The extracted user instruction when the scaffolding carried one.
- ``None`` when the content is skill scaffolding with no user
instruction (i.e. a bare ``/skill`` invocation). Callers that feed
memory providers should skip the turn in that case there is no
user content worth storing.
"""
if not isinstance(content, str):
return None
if not content.startswith(_SKILL_INVOCATION_PREFIX):
return content
if _BUNDLE_MARKER in content:
return _extract_bundle_user_instruction(content)
if _SINGLE_SKILL_MARKER in content:
return _extract_single_skill_user_instruction(content)
return None
def _extract_single_skill_user_instruction(message: str) -> Optional[str]:
# Single-skill format appends the user instruction after the skill body, so
# the last occurrence is the user-provided one; the body may quote this text.
marker_idx = message.rfind(_SINGLE_SKILL_INSTRUCTION)
if marker_idx < 0:
return None
instruction = message[marker_idx + len(_SINGLE_SKILL_INSTRUCTION):]
runtime_idx = instruction.find(_RUNTIME_NOTE)
if runtime_idx >= 0:
instruction = instruction[:runtime_idx]
instruction = instruction.strip()
return instruction or None
def _extract_bundle_user_instruction(message: str) -> Optional[str]:
# Bundle format puts the user instruction before the loaded skills, so the
# first occurrence is the user-provided one.
marker_idx = message.find(_BUNDLE_USER_INSTRUCTION)
if marker_idx < 0:
return None
instruction = message[marker_idx + len(_BUNDLE_USER_INSTRUCTION):]
first_skill_idx = instruction.find(_BUNDLE_FIRST_SKILL_BLOCK)
if first_skill_idx >= 0:
instruction = instruction[:first_skill_idx]
instruction = instruction.strip()
return instruction or None
def _resolve_skill_commands_platform() -> Optional[str]: def _resolve_skill_commands_platform() -> Optional[str]:
"""Return the current platform scope used for disabled-skill filtering. """Return the current platform scope used for disabled-skill filtering.

View File

@ -43,14 +43,20 @@ EXCLUDED_SKILL_DIRS = frozenset(
) )
) )
# Supporting files live inside a skill package and are loaded explicitly via
# skill_view(skill, file_path=...). They are not standalone skills and must not
# be scanned for active SKILL.md/DESCRIPTION.md entries, even if a Curator or
# archive workflow preserves a complete old skill package under references/.
SKILL_SUPPORT_DIRS = frozenset(("references", "templates", "assets", "scripts"))
def is_excluded_skill_path(path) -> bool: def is_excluded_skill_path(path) -> bool:
"""True if any component of *path* is in EXCLUDED_SKILL_DIRS. """True if *path* should be skipped by active skill scanners.
Use this on every SKILL.md path produced by ``rglob`` to prune Use this on every ``SKILL.md`` path produced by direct ``rglob`` scans to
dependency, virtualenv, VCS, and cache directories. Centralising the prune dependency, virtualenv, VCS, cache, and progressive-disclosure
check here keeps every skill-scanning site in sync with the shared support-package paths. Centralising the check here keeps every
exclusion set. skill-scanning site in sync with the shared exclusion set.
Accepts a Path or string. Accepts a Path or string.
""" """
@ -59,7 +65,36 @@ def is_excluded_skill_path(path) -> bool:
except AttributeError: except AttributeError:
from pathlib import PurePath from pathlib import PurePath
parts = PurePath(str(path)).parts parts = PurePath(str(path)).parts
return any(part in EXCLUDED_SKILL_DIRS for part in parts) return any(part in EXCLUDED_SKILL_DIRS for part in parts) or is_skill_support_path(
path
)
def is_skill_support_path(path) -> bool:
"""True if *path* is under a support dir of an actual skill root.
``references/``, ``templates/``, ``assets/``, and ``scripts/`` are
progressive-disclosure support areas when they sit directly inside a skill
directory containing ``SKILL.md``. They are not active discovery roots for
standalone skills. A preserved package such as
``some-skill/references/old-skill-package/SKILL.md`` is documentation data
unless the caller explicitly loads it via ``file_path``.
Legitimate categories or skill names such as ``skills/scripts/foo`` remain
discoverable because their ``scripts`` component is not directly under a
directory that contains ``SKILL.md``.
"""
path_obj = path if isinstance(path, Path) else Path(str(path))
parts = path_obj.parts
# Last component may be a file or candidate skill directory name. Only
# components before the leaf can be containing support directories.
for idx, part in enumerate(parts[:-1]):
if part not in SKILL_SUPPORT_DIRS or idx == 0:
continue
skill_root = Path(*parts[:idx])
if (skill_root / "SKILL.md").exists():
return True
return False
# ── Lazy YAML loader ───────────────────────────────────────────────────── # ── Lazy YAML loader ─────────────────────────────────────────────────────
@ -661,12 +696,21 @@ def extract_skill_description(frontmatter: Dict[str, Any]) -> str:
def iter_skill_index_files(skills_dir: Path, filename: str): def iter_skill_index_files(skills_dir: Path, filename: str):
"""Walk skills_dir yielding sorted paths matching *filename*. """Walk skills_dir yielding sorted paths matching *filename*.
Excludes Hermes metadata, VCS, virtualenv/dependency, and cache Excludes Hermes metadata, VCS, virtualenv/dependency, cache, and skill
directories so dependencies cannot register nested skills. support directories. Support directories (references/templates/assets/
scripts) can contain arbitrary markdown and even archived package
``SKILL.md`` files, but they are progressive-disclosure data loaded through
``skill_view(..., file_path=...)`` rather than active skill roots.
""" """
matches = [] matches = []
for root, dirs, files in os.walk(skills_dir, followlinks=True): for root, dirs, files in os.walk(skills_dir, followlinks=True):
dirs[:] = [d for d in dirs if d not in EXCLUDED_SKILL_DIRS] has_skill_md = "SKILL.md" in files
dirs[:] = [
d
for d in dirs
if d not in EXCLUDED_SKILL_DIRS
and not (has_skill_md and d in SKILL_SUPPORT_DIRS)
]
if filename in files: if filename in files:
matches.append(Path(root) / filename) matches.append(Path(root) / filename)
for path in sorted(matches, key=lambda p: str(p.relative_to(skills_dir))): for path in sorted(matches, key=lambda p: str(p.relative_to(skills_dir))):

View File

@ -33,6 +33,7 @@ from agent.prompt_builder import (
KANBAN_GUIDANCE, KANBAN_GUIDANCE,
MEMORY_GUIDANCE, MEMORY_GUIDANCE,
OPENAI_MODEL_EXECUTION_GUIDANCE, OPENAI_MODEL_EXECUTION_GUIDANCE,
PARALLEL_TOOL_CALL_GUIDANCE,
PLATFORM_HINTS, PLATFORM_HINTS,
SESSION_SEARCH_GUIDANCE, SESSION_SEARCH_GUIDANCE,
SKILLS_GUIDANCE, SKILLS_GUIDANCE,
@ -40,6 +41,7 @@ from agent.prompt_builder import (
TASK_COMPLETION_GUIDANCE, TASK_COMPLETION_GUIDANCE,
TOOL_USE_ENFORCEMENT_GUIDANCE, TOOL_USE_ENFORCEMENT_GUIDANCE,
TOOL_USE_ENFORCEMENT_MODELS, TOOL_USE_ENFORCEMENT_MODELS,
drain_truncation_warnings,
) )
from agent.runtime_cwd import resolve_context_cwd from agent.runtime_cwd import resolve_context_cwd
@ -82,6 +84,17 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
# we resolve through ``_ra()`` to honor those patches. # we resolve through ``_ra()`` to honor those patches.
_r = _ra() _r = _ra()
# Resolve the model's context window once so context-file caps can scale
# to it (dynamic cap — see prompt_builder._dynamic_context_file_max_chars).
# None falls back to the historical flat default. This value is stable for
# the life of the conversation, so it does not threaten prompt caching.
_ctx_len: Optional[int] = None
_cc = getattr(agent, "context_compressor", None)
if _cc is not None:
_cc_len = getattr(_cc, "context_length", None)
if isinstance(_cc_len, int) and _cc_len > 0:
_ctx_len = _cc_len
# ── Stable tier ──────────────────────────────────────────────── # ── Stable tier ────────────────────────────────────────────────
stable_parts: List[str] = [] stable_parts: List[str] = []
@ -90,7 +103,7 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
# cwd project instructions disabled. # cwd project instructions disabled.
_soul_loaded = False _soul_loaded = False
if agent.load_soul_identity or not agent.skip_context_files: if agent.load_soul_identity or not agent.skip_context_files:
_soul_content = _r.load_soul_md() _soul_content = _r.load_soul_md(_ctx_len)
if _soul_content: if _soul_content:
stable_parts.append(_soul_content) stable_parts.append(_soul_content)
_soul_loaded = True _soul_loaded = True
@ -111,6 +124,17 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
if getattr(agent, "_task_completion_guidance", True) and agent.valid_tool_names: if getattr(agent, "_task_completion_guidance", True) and agent.valid_tool_names:
stable_parts.append(TASK_COMPLETION_GUIDANCE) stable_parts.append(TASK_COMPLETION_GUIDANCE)
# Universal parallel-tool-call guidance. Tells the model to batch
# independent tool calls into one assistant turn rather than emitting one
# call per turn — the runtime already runs independent calls concurrently
# (read-only tools always; non-overlapping path-scoped file ops), so the
# only thing missing was steering the model to produce the batch. Cuts
# round-trips and the resent-context cost that compounds over a long
# conversation. Gated by config.yaml ``agent.parallel_tool_call_guidance``
# (default True) and only injected when tools are actually loaded.
if getattr(agent, "_parallel_tool_call_guidance", True) and agent.valid_tool_names:
stable_parts.append(PARALLEL_TOOL_CALL_GUIDANCE)
# Tool-aware behavioral guidance: only inject when the tools are loaded # Tool-aware behavioral guidance: only inject when the tools are loaded
tool_guidance = [] tool_guidance = []
if "memory" in agent.valid_tool_names: if "memory" in agent.valid_tool_names:
@ -333,7 +357,8 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
# dir — the user's real cwd there, but the install dir for the gateway # dir — the user's real cwd there, but the install dir for the gateway
# daemon, which is why the gateway sets TERMINAL_CWD. # daemon, which is why the gateway sets TERMINAL_CWD.
context_files_prompt = _r.build_context_files_prompt( context_files_prompt = _r.build_context_files_prompt(
cwd=resolve_context_cwd(), skip_soul=_soul_loaded) cwd=resolve_context_cwd(), skip_soul=_soul_loaded,
context_length=_ctx_len)
if context_files_prompt: if context_files_prompt:
context_parts.append(context_files_prompt) context_parts.append(context_files_prompt)
@ -400,7 +425,14 @@ def build_system_prompt(agent: Any, system_message: Optional[str] = None) -> str
warm across turns. warm across turns.
""" """
parts = build_system_prompt_parts(agent, system_message=system_message) parts = build_system_prompt_parts(agent, system_message=system_message)
return "\n\n".join(p for p in (parts["stable"], parts["context"], parts["volatile"]) if p) joined = "\n\n".join(p for p in (parts["stable"], parts["context"], parts["volatile"]) if p)
# Surface context-file truncation warnings through the normal agent status
# channel so gateway/CLI users see them in chat instead of only in logs.
for warning in drain_truncation_warnings():
agent._emit_status(warning)
return joined
def invalidate_system_prompt(agent: Any) -> None: def invalidate_system_prompt(agent: Any) -> None:

View File

@ -1012,28 +1012,42 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
elif function_name == "memory": elif function_name == "memory":
def _execute(next_args: dict) -> Any: def _execute(next_args: dict) -> Any:
target = next_args.get("target", "memory") target = next_args.get("target", "memory")
operations = next_args.get("operations")
from tools.memory_tool import memory_tool as _memory_tool from tools.memory_tool import memory_tool as _memory_tool
result = _memory_tool( result = _memory_tool(
action=next_args.get("action"), action=next_args.get("action"),
target=target, target=target,
content=next_args.get("content"), content=next_args.get("content"),
old_text=next_args.get("old_text"), old_text=next_args.get("old_text"),
operations=operations,
store=agent._memory_store, store=agent._memory_store,
) )
# Bridge: notify external memory provider of built-in memory writes # Bridge: notify external memory provider of built-in memory writes.
if agent._memory_manager and next_args.get("action") in {"add", "replace"}: # Covers both the single-op shape and each add/replace inside a batch.
try: if agent._memory_manager:
agent._memory_manager.on_memory_write( if operations:
next_args.get("action", ""), _mem_ops = [
target, op for op in operations
next_args.get("content", ""), if isinstance(op, dict) and op.get("action") in {"add", "replace"}
metadata=agent._build_memory_write_metadata( ]
task_id=effective_task_id, else:
tool_call_id=getattr(tool_call, "id", None), _mem_ops = (
), [{"action": next_args.get("action"), "content": next_args.get("content")}]
if next_args.get("action") in {"add", "replace"} else []
) )
except Exception: for _op in _mem_ops:
pass try:
agent._memory_manager.on_memory_write(
_op.get("action", ""),
target,
_op.get("content", "") or "",
metadata=agent._build_memory_write_metadata(
task_id=effective_task_id,
tool_call_id=getattr(tool_call, "id", None),
),
)
except Exception:
pass
return result return result
function_result, function_args = _run_agent_tool_execution_middleware( function_result, function_args = _run_agent_tool_execution_middleware(
agent, agent,

View File

@ -88,7 +88,7 @@ class AnthropicTransport(ProviderTransport):
from agent.transports.types import ToolCall from agent.transports.types import ToolCall
strip_tool_prefix = kwargs.get("strip_tool_prefix", False) strip_tool_prefix = kwargs.get("strip_tool_prefix", False)
_MCP_PREFIX = "mcp_" _MCP_PREFIX = "mcp__"
text_parts = [] text_parts = []
reasoning_parts = [] reasoning_parts = []
@ -132,17 +132,25 @@ class AnthropicTransport(ProviderTransport):
elif block.type == "tool_use": elif block.type == "tool_use":
name = block.name name = block.name
if strip_tool_prefix and name.startswith(_MCP_PREFIX): if strip_tool_prefix and name.startswith(_MCP_PREFIX):
stripped = name[len(_MCP_PREFIX):] # On the OAuth wire every tool carries a double-underscore
# Only strip the mcp_ prefix for OAuth-injected tools # ``mcp__`` prefix (added in build_anthropic_kwargs to avoid
# (where Hermes adds the prefix when sending to Anthropic # Anthropic's single-underscore third-party classifier).
# and must remove it on the way back). Native MCP server # Reverse it back to the name the registry/dispatcher knows.
# tools (from mcp_servers: in config.yaml) are registered # Two original forms map onto the same ``mcp__`` wire name:
# in the tool registry under their FULL mcp_<server>_<tool> # ``mcp__read_file`` <- bare native tool ``read_file``
# name and must NOT be stripped. GH-25255. # ``mcp__linear_get_issue`` <- MCP server tool
# ``mcp_linear_get_issue``
# Resolve by registry lookup, preferring whichever original
# is actually registered; never rewrite a name the LLM used
# that already resolves natively. GH-25255.
from tools.registry import registry as _tool_registry from tools.registry import registry as _tool_registry
if (_tool_registry.get_entry(stripped) if not _tool_registry.get_entry(name):
and not _tool_registry.get_entry(name)): bare = name[len(_MCP_PREFIX):] # read_file
name = stripped single = "mcp_" + bare # mcp_read_file / mcp_linear_get_issue
if _tool_registry.get_entry(single):
name = single
elif _tool_registry.get_entry(bare):
name = bare
tool_calls.append( tool_calls.append(
ToolCall( ToolCall(
id=block.id, id=block.id,

View File

@ -128,6 +128,65 @@ class ResponsesApiTransport(ProviderTransport):
reasoning_effort = _effort_clamp.get(reasoning_effort, reasoning_effort) reasoning_effort = _effort_clamp.get(reasoning_effort, reasoning_effort)
response_tools = _responses_tools(tools) response_tools = _responses_tools(tools)
# xAI server-side web search.
#
# grok models on xAI's /v1/responses surface (notably
# grok-composer-2.5-fast on SuperGrok OAuth) have a *native*,
# server-executed web search. When the model is handed a
# client-side function literally named ``web_search``, it routes
# the intent to that native engine — but because the tool is
# declared as a plain ``function`` rather than xAI's first-class
# ``{"type": "web_search"}`` built-in, the server-side search is
# dispatched but never reconciled: the response streams reasoning
# + ``web_search_call`` progress items, the searches never reach
# ``status="completed"`` in the assembled output, no final
# message is emitted, and ``_normalize_codex_response`` correctly
# sees reasoning-with-no-answer and reports ``incomplete``. The
# turn then burns 3 continuation retries and fails with "Codex
# response remained incomplete after 3 continuation attempts".
# Verified live against grok-composer-2.5-fast (2026-06).
#
# Fix: when the agent HAS a client-side ``web_search`` function (i.e.
# the user enabled the web toolset), declare xAI's native
# ``web_search`` built-in instead so the search actually runs to
# completion server-side and the model streams a real answer. The
# Responses API rejects two tools sharing the name ``web_search``
# (HTTP 400 "Duplicate tool names"), so we drop the client-side
# ``web_search`` function for the xAI path and let the native tool
# satisfy it. All other client-side tools (read_file, terminal,
# web_extract, MCP tools, …) are untouched and continue to dispatch
# through Hermes's agent loop.
#
# Scope: we ONLY swap in the native built-in when the client
# ``web_search`` was actually present. We do NOT force-enable Grok
# server-side search on turns where the user never had web enabled —
# that would silently route around Hermes's web-provider config and
# tool-trace/citation plumbing for every xai-oauth turn. The swap is
# a 1:1 replacement of an already-requested capability, not an
# additive grant.
#
# NOTE: for the swapped case this routes ``web_search`` to Grok's
# native search engine for xAI sessions instead of Hermes's
# configured web provider (Tavily/etc.), and those results bypass
# Hermes's tool-trace / citation plumbing (they arrive baked into the
# model's answer rather than as a tool result the loop observes).
# Scoped to ``is_xai_responses`` deliberately; narrow to specific
# models if a future grok variant should keep the client-side
# function.
if is_xai_responses and response_tools:
has_client_web_search = any(
isinstance(t, dict) and t.get("name") == "web_search"
for t in response_tools
)
if has_client_web_search:
filtered = [
t for t in response_tools
if not (isinstance(t, dict) and t.get("name") == "web_search")
]
filtered.append({"type": "web_search"})
response_tools = filtered
# ``tools`` MUST be omitted entirely when there are no functions to # ``tools`` MUST be omitted entirely when there are no functions to
# expose: the openai SDK's ``responses.stream()`` / ``responses.parse()`` # expose: the openai SDK's ``responses.stream()`` / ``responses.parse()``
# eagerly call ``_make_tools(tools)`` which does ``for tool in tools`` # eagerly call ``_make_tools(tools)`` which does ``for tool in tools``
@ -218,10 +277,28 @@ class ResponsesApiTransport(ProviderTransport):
kwargs.pop("timeout", None) kwargs.pop("timeout", None)
if is_codex_backend: if is_codex_backend:
# chatgpt.com/backend-api/codex rejects body-level # The Codex backend rejects body-level ``extra_headers`` with
# ``extra_headers`` with HTTP 400. Correlation/cache routing for # HTTP 400, but the OpenAI SDK's ``extra_headers`` kwarg maps
# this backend must not be sent through the Responses payload. # to actual HTTP request headers (not body fields). We need
kwargs.pop("extra_headers", None) # these headers for cache-scope routing so prompt cache hits
# 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()
if cache_scope_id:
existing_extra_headers = kwargs.get("extra_headers")
merged_extra_headers: Dict[str, str] = {}
if isinstance(existing_extra_headers, dict):
merged_extra_headers.update(
{
str(key): str(value)
for key, value in existing_extra_headers.items()
if key and value is not None
}
)
merged_extra_headers["session_id"] = cache_scope_id
merged_extra_headers["x-client-request-id"] = cache_scope_id
kwargs["extra_headers"] = merged_extra_headers
max_tokens = params.get("max_tokens") max_tokens = params.get("max_tokens")
if max_tokens is not None and not is_codex_backend: if max_tokens is not None and not is_codex_backend:

View File

@ -69,6 +69,7 @@ def build_turn_context(
task_id: Optional[str], task_id: Optional[str],
stream_callback, stream_callback,
persist_user_message: Optional[str], persist_user_message: Optional[str],
persist_user_timestamp: Optional[float] = None,
*, *,
restore_or_build_system_prompt, restore_or_build_system_prompt,
install_safe_stdio, install_safe_stdio,
@ -121,6 +122,7 @@ def build_turn_context(
agent._stream_callback = stream_callback agent._stream_callback = stream_callback
agent._persist_user_message_idx = None agent._persist_user_message_idx = None
agent._persist_user_message_override = persist_user_message agent._persist_user_message_override = persist_user_message
agent._persist_user_message_timestamp = persist_user_timestamp
# Generate unique task_id if not provided to isolate VMs between tasks. # Generate unique task_id if not provided to isolate VMs between tasks.
effective_task_id = task_id or str(uuid.uuid4()) effective_task_id = task_id or str(uuid.uuid4())
agent._current_task_id = effective_task_id agent._current_task_id = effective_task_id

View File

@ -286,7 +286,7 @@ async fn run_update(app: AppHandle) -> Result<()> {
emit_stage(&app, "rebuild", StageState::Running, None, None); emit_stage(&app, "rebuild", StageState::Running, None, None);
let started = Instant::now(); let started = Instant::now();
let rebuild_args: Vec<String> = vec!["desktop".into(), "--build-only".into()]; let rebuild_args: Vec<String> = vec!["desktop".into(), "--build-only".into()];
let rebuild = run_streamed( let mut rebuild = run_streamed(
&app, &app,
&hermes, &hermes,
&rebuild_args, &rebuild_args,
@ -295,6 +295,33 @@ async fn run_update(app: AppHandle) -> Result<()> {
Some("rebuild"), Some("rebuild"),
) )
.await?; .await?;
// Retry-once: the first `--build-only` can return nonzero on a still-settling
// post-update tree or a network-blocked Electron fetch that our self-heal
// repaired mid-run. A second attempt then builds clean off the healed dist
// (the content-hash stamp makes it a near-no-op when the first actually
// succeeded). Without this the updater bails here and never reaches the
// relaunch below — the app updates but doesn't restart. Matches the
// retry-once `hermes update` already does above, and `hermes update`'s own
// desktop rebuild in cmd_update.
if rebuild_needs_retry(rebuild.exit_code) {
emit_log(
&app,
Some("rebuild"),
LogStream::Stdout,
"[rebuild] first desktop rebuild failed; retrying once (a self-healed \
Electron download builds clean on the second run)",
);
rebuild = run_streamed(
&app,
&hermes,
&rebuild_args,
&install_root,
&child_env,
Some("rebuild"),
)
.await?;
}
let rebuild_ms = started.elapsed().as_millis() as u64; let rebuild_ms = started.elapsed().as_millis() as u64;
if rebuild.exit_code != Some(0) { if rebuild.exit_code != Some(0) {
@ -533,6 +560,14 @@ fn is_locked(path: &Path) -> bool {
} }
} }
/// Whether the `desktop --build-only` rebuild should be retried once. Any
/// non-success exit qualifies: the common cause is a transient first-attempt
/// failure (still-settling tree / self-healed Electron download) that a clean
/// second run resolves.
fn rebuild_needs_retry(exit_code: Option<i32>) -> bool {
exit_code != Some(0)
}
/// Spawn `hermes <args>` from `cwd`, stream stdout/stderr as Log events on the /// Spawn `hermes <args>` from `cwd`, stream stdout/stderr as Log events on the
/// bootstrap channel, and return the exit code. Mirrors powershell::run_script /// bootstrap channel, and return the exit code. Mirrors powershell::run_script
/// but for an arbitrary command (no install.ps1 -File wrapping). /// but for an arbitrary command (no install.ps1 -File wrapping).
@ -970,6 +1005,16 @@ mod tests {
assert_eq!(update_branch_from_args(["--update"]), None); assert_eq!(update_branch_from_args(["--update"]), None);
} }
#[test]
fn rebuild_retries_only_on_failure() {
assert!(!rebuild_needs_retry(Some(0)), "a clean rebuild must not retry");
assert!(rebuild_needs_retry(Some(1)), "a failed rebuild retries once");
assert!(
rebuild_needs_retry(None),
"a killed/signalled rebuild (no exit code) retries once"
);
}
#[test] #[test]
fn parses_only_app_targets() { fn parses_only_app_targets() {
assert_eq!( assert_eq!(

View File

@ -28,6 +28,7 @@ const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = requ
const { runBootstrap } = require('./bootstrap-runner.cjs') const { runBootstrap } = require('./bootstrap-runner.cjs')
const { const {
buildSessionWindowUrl, buildSessionWindowUrl,
chatWindowWebPreferences,
createSessionWindowRegistry, createSessionWindowRegistry,
SESSION_WINDOW_MIN_HEIGHT, SESSION_WINDOW_MIN_HEIGHT,
SESSION_WINDOW_MIN_WIDTH SESSION_WINDOW_MIN_WIDTH
@ -44,6 +45,7 @@ const { readDirForIpc } = require('./fs-read-dir.cjs')
const { gitRootForIpc } = require('./git-root.cjs') const { gitRootForIpc } = require('./git-root.cjs')
const { worktreesForIpc } = require('./git-worktrees.cjs') const { worktreesForIpc } = require('./git-worktrees.cjs')
const { OFFICIAL_REPO_HTTPS_URL, isOfficialSshRemote } = require('./update-remote.cjs') const { OFFICIAL_REPO_HTTPS_URL, isOfficialSshRemote } = require('./update-remote.cjs')
const { runRebuildWithRetry } = require('./update-rebuild.cjs')
const { const {
buildPosixCleanupScript, buildPosixCleanupScript,
buildWindowsCleanupScript, buildWindowsCleanupScript,
@ -2008,10 +2010,14 @@ async function applyUpdatesPosixInApp() {
} }
emitUpdateProgress({ stage: 'rebuild', message: 'Rebuilding the desktop app…', percent: 60 }) emitUpdateProgress({ stage: 'rebuild', message: 'Rebuilding the desktop app…', percent: 60 })
const rebuilt = await runStreamedUpdate(hermes, ['desktop', '--build-only'], { // Retry-once: a first rebuild can fail on a still-settling tree or a
cwd: updateRoot, // self-healed (network-blocked) Electron download; a second run builds clean
env, // off the healed dist so we reach the swap+relaunch below instead of bailing.
stage: 'rebuild' const rebuilt = await runRebuildWithRetry(attempt => {
if (attempt > 0) {
emitUpdateProgress({ stage: 'rebuild', message: 'Retrying the desktop rebuild…', percent: 60 })
}
return runStreamedUpdate(hermes, ['desktop', '--build-only'], { cwd: updateRoot, env, stage: 'rebuild' })
}) })
if (rebuilt.code !== 0) { if (rebuilt.code !== 0) {
emitUpdateProgress({ emitUpdateProgress({
@ -5106,14 +5112,7 @@ function spawnSecondaryWindow({ sessionId, watch, newSession } = {}) {
// themes/context.tsx, so the window appears already themed. // themes/context.tsx, so the window appears already themed.
show: false, show: false,
backgroundColor: getWindowBackgroundColor(), backgroundColor: getWindowBackgroundColor(),
webPreferences: { webPreferences: chatWindowWebPreferences(path.join(__dirname, 'preload.cjs'))
preload: path.join(__dirname, 'preload.cjs'),
contextIsolation: true,
webviewTag: true,
sandbox: true,
nodeIntegration: false,
devTools: true
}
}) })
if (IS_MAC) { if (IS_MAC) {
@ -5180,23 +5179,11 @@ function createWindow() {
// material before the renderer paints the app theme. See createSessionWindow. // material before the renderer paints the app theme. See createSessionWindow.
show: false, show: false,
backgroundColor: getWindowBackgroundColor(), backgroundColor: getWindowBackgroundColor(),
webPreferences: { // Shared with the secondary session windows (chatWindowWebPreferences) so
preload: path.join(__dirname, 'preload.cjs'), // both keep `backgroundThrottling: false` — the chat transcript streams via
contextIsolation: true, // a requestAnimationFrame-gated flush that Chromium pauses for blurred
webviewTag: true, // windows, stalling the live answer until refocus. See session-windows.cjs.
sandbox: true, webPreferences: chatWindowWebPreferences(path.join(__dirname, 'preload.cjs'))
nodeIntegration: false,
devTools: true,
// Keep timers + requestAnimationFrame running at full speed when the
// window is blurred/occluded. The chat transcript streams to the screen
// through a requestAnimationFrame-gated flush (useSessionStateCache),
// so with Chromium's default background throttling the live answer
// stalls whenever this window isn't focused (e.g. you switch to your
// editor mid-turn, or open detached devtools) and only appears once you
// refocus or refresh. A streaming chat app must render in the
// background, so opt out — matching the secondary windows above.
backgroundThrottling: false
}
}) })
if (IS_MAC) { if (IS_MAC) {
@ -6564,6 +6551,12 @@ app.on('before-quit', () => {
flushDesktopLogBufferSync() flushDesktopLogBufferSync()
closePreviewWatchers() closePreviewWatchers()
// Kill open PTYs before environment teardown to avoid the node-pty#904
// ThreadSafeFunction SIGABRT race.
for (const id of [...terminalSessions.keys()]) {
disposeTerminalSession(id)
}
if (hermesProcess && !hermesProcess.killed) { if (hermesProcess && !hermesProcess.killed) {
hermesProcess.kill('SIGTERM') hermesProcess.kill('SIGTERM')
} }

View File

@ -10,6 +10,29 @@ const { pathToFileURL } = require('node:url')
const SESSION_WINDOW_MIN_WIDTH = 420 const SESSION_WINDOW_MIN_WIDTH = 420
const SESSION_WINDOW_MIN_HEIGHT = 620 const SESSION_WINDOW_MIN_HEIGHT = 620
// Shared webPreferences for every window that renders the chat transcript — the
// primary window AND the secondary session windows. Keeping it in one place is
// the whole point: the two BrowserWindow definitions in main.cjs used to be
// hand-copied, and the secondary windows silently lost `backgroundThrottling:
// false`, so a streamed answer stalled until the window regained focus.
//
// `backgroundThrottling: false` is load-bearing: the transcript streams to the
// screen through a requestAnimationFrame-gated flush, which Chromium pauses for
// blurred/occluded windows. A streaming chat app must keep painting in the
// background, so every chat window opts out. The preload path is injected
// because it depends on the Electron entry's __dirname.
function chatWindowWebPreferences(preloadPath) {
return {
preload: preloadPath,
contextIsolation: true,
webviewTag: true,
sandbox: true,
nodeIntegration: false,
devTools: true,
backgroundThrottling: false
}
}
// Build the renderer URL for a secondary window. The renderer uses a // Build the renderer URL for a secondary window. The renderer uses a
// HashRouter, so the session route lives after the '#'. The `?win=secondary` // HashRouter, so the session route lives after the '#'. The `?win=secondary`
// flag MUST sit in the query string BEFORE the '#': anything after the '#' is // flag MUST sit in the query string BEFORE the '#': anything after the '#' is
@ -94,6 +117,7 @@ function createSessionWindowRegistry() {
module.exports = { module.exports = {
buildSessionWindowUrl, buildSessionWindowUrl,
chatWindowWebPreferences,
createSessionWindowRegistry, createSessionWindowRegistry,
SESSION_WINDOW_MIN_HEIGHT, SESSION_WINDOW_MIN_HEIGHT,
SESSION_WINDOW_MIN_WIDTH SESSION_WINDOW_MIN_WIDTH

View File

@ -1,7 +1,11 @@
const assert = require('node:assert/strict') const assert = require('node:assert/strict')
const test = require('node:test') const test = require('node:test')
const { buildSessionWindowUrl, createSessionWindowRegistry } = require('./session-windows.cjs') const {
buildSessionWindowUrl,
chatWindowWebPreferences,
createSessionWindowRegistry
} = require('./session-windows.cjs')
// A minimal fake BrowserWindow: tracks listeners + destroyed state and lets a // A minimal fake BrowserWindow: tracks listeners + destroyed state and lets a
// test fire the 'closed' event, mirroring the slice of the Electron API the // test fire the 'closed' event, mirroring the slice of the Electron API the
@ -175,3 +179,21 @@ test('registry trims the session id before keying', () => {
assert.equal(registry.has('s1'), true) assert.equal(registry.has('s1'), true)
}) })
test('chatWindowWebPreferences disables background throttling so streaming paints while blurred', () => {
// Regression: secondary session windows used to omit this flag, so a streamed
// answer stalled until the window regained focus (Chromium pauses the
// requestAnimationFrame-gated transcript flush for backgrounded windows).
const prefs = chatWindowWebPreferences('/tmp/preload.cjs')
assert.equal(prefs.backgroundThrottling, false)
})
test('chatWindowWebPreferences passes the preload path through and keeps the hardened defaults', () => {
const prefs = chatWindowWebPreferences('/some/preload.cjs')
assert.equal(prefs.preload, '/some/preload.cjs')
assert.equal(prefs.contextIsolation, true)
assert.equal(prefs.sandbox, true)
assert.equal(prefs.nodeIntegration, false)
})

View File

@ -0,0 +1,29 @@
'use strict'
/**
* Retry-once policy for the desktop `--build-only` rebuild during self-update.
*
* The first rebuild can return nonzero on a still-settling post-update tree or a
* network-blocked Electron fetch that the installer's self-heal repaired mid-run.
* A second attempt then builds clean off the healed dist (the content-hash stamp
* makes it a near-no-op when the first actually succeeded). Without the retry the
* updater bails before the relaunch step the app updates but doesn't restart.
*/
function shouldRetryRebuild(code) {
return code !== 0
}
/**
* Run `rebuild()` (async, resolves `{ code, ... }`), retrying once on failure.
* Returns the final result.
*/
async function runRebuildWithRetry(rebuild) {
let result = await rebuild(0)
if (shouldRetryRebuild(result.code)) {
result = await rebuild(1)
}
return result
}
module.exports = { shouldRetryRebuild, runRebuildWithRetry }

View File

@ -0,0 +1,55 @@
/**
* Tests for electron/update-rebuild.cjs the retry-once policy for the desktop
* `--build-only` rebuild during self-update.
*
* Run with: node --test electron/update-rebuild.test.cjs
* (Wired into npm test:desktop:platforms in package.json.)
*
* Why this matters: a first rebuild can return nonzero on a still-settling tree
* or a self-healed (network-blocked) Electron download. Without a second attempt
* the updater bails before the relaunch step the app updates but never restarts
* (the field report behind this fix). The retry must fire on failure, not on
* success, and must run at most twice.
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const { shouldRetryRebuild, runRebuildWithRetry } = require('./update-rebuild.cjs')
test('shouldRetryRebuild retries only on a non-success exit', () => {
assert.equal(shouldRetryRebuild(0), false)
assert.equal(shouldRetryRebuild(1), true)
assert.equal(shouldRetryRebuild(null), true)
})
test('a clean first rebuild runs once and does not retry', async () => {
const codes = []
const result = await runRebuildWithRetry(attempt => {
codes.push(attempt)
return Promise.resolve({ code: 0 })
})
assert.deepEqual(codes, [0])
assert.equal(result.code, 0)
})
test('a failed first rebuild retries once and succeeds', async () => {
const codes = []
const result = await runRebuildWithRetry(attempt => {
codes.push(attempt)
return Promise.resolve({ code: attempt === 0 ? 1 : 0 })
})
assert.deepEqual(codes, [0, 1])
assert.equal(result.code, 0)
})
test('a rebuild that keeps failing runs at most twice and reports the failure', async () => {
const codes = []
const result = await runRebuildWithRetry(attempt => {
codes.push(attempt)
return Promise.resolve({ code: 1, error: 'rebuild-failed' })
})
assert.deepEqual(codes, [0, 1])
assert.equal(result.code, 1)
assert.equal(result.error, 'rebuild-failed')
})

View File

@ -21,7 +21,7 @@
"build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && npm run postbuild", "build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && npm run postbuild",
"postbuild": "node scripts/assert-dist-built.cjs", "postbuild": "node scripts/assert-dist-built.cjs",
"prebuilder": "node scripts/patch-electron-builder-mac-binary.cjs", "prebuilder": "node scripts/patch-electron-builder-mac-binary.cjs",
"builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 electron-builder", "builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 node scripts/run-electron-builder.cjs",
"pack": "npm run build && npm run builder -- --dir", "pack": "npm run build && npm run builder -- --dir",
"dist": "npm run build && npm run builder", "dist": "npm run build && npm run builder",
"dist:mac": "npm run build && npm run builder -- --mac", "dist:mac": "npm run build && npm run builder -- --mac",
@ -37,7 +37,7 @@
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis", "test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing", "test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh", "test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/windows-user-env.test.cjs", "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-rebuild.test.cjs electron/windows-user-env.test.cjs",
"typecheck": "tsc -p . --noEmit", "typecheck": "tsc -p . --noEmit",
"lint": "eslint src/ electron/", "lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix", "lint:fix": "eslint src/ electron/ --fix",
@ -117,7 +117,7 @@
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"concurrently": "^10.0.3", "concurrently": "^10.0.3",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"electron": "^40.9.3", "electron": "40.10.2",
"electron-builder": "^26.8.1", "electron-builder": "^26.8.1",
"eslint": "^9.39.4", "eslint": "^9.39.4",
"eslint-plugin-perfectionist": "^5.9.0", "eslint-plugin-perfectionist": "^5.9.0",
@ -134,8 +134,7 @@
"wait-on": "^9.0.5" "wait-on": "^9.0.5"
}, },
"build": { "build": {
"electronVersion": "40.9.3", "electronVersion": "40.10.2",
"electronDist": "../../node_modules/electron/dist",
"appId": "com.nousresearch.hermes", "appId": "com.nousresearch.hermes",
"productName": "Hermes", "productName": "Hermes",
"executableName": "Hermes", "executableName": "Hermes",

View File

@ -24,6 +24,11 @@ const replacement = ` // ${marker}: electron-builder 26.8.x can sometimes cop
if (!fs.existsSync(bundledElectronBinary)) { if (!fs.existsSync(bundledElectronBinary)) {
const candidates = [ const candidates = [
path.join(packager.info.framework.distMacOsAppName, "Contents", "MacOS", electronBranding.productName), path.join(packager.info.framework.distMacOsAppName, "Contents", "MacOS", electronBranding.productName),
// npm may nest the workspace-only electron devDep under
// apps/desktop/node_modules (process.cwd() during pack), or hoist
// it to the repo root. Try the workspace-local install first, then
// the root hoist, so the fallback works under either layout.
path.join(process.cwd(), "node_modules", "electron", "dist", "Electron.app", "Contents", "MacOS", electronBranding.productName),
path.join(process.cwd(), "..", "..", "node_modules", "electron", "dist", "Electron.app", "Contents", "MacOS", electronBranding.productName), path.join(process.cwd(), "..", "..", "node_modules", "electron", "dist", "Electron.app", "Contents", "MacOS", electronBranding.productName),
]; ];
const sourceBinary = candidates.find(candidate => fs.existsSync(candidate)); const sourceBinary = candidates.find(candidate => fs.existsSync(candidate));

View File

@ -0,0 +1,57 @@
"use strict"
// Resolve electronDist at runtime (#38673, #47917): electron-builder 26.8.x can
// re-unpack a broken Electron.app; reusing the installed dist dodges that.
// npm workspace hoisting is non-deterministic — require.resolve finds electron
// wherever it landed. Dist present → -c.electronDist=<abs>/dist; absent → let
// electron-builder fetch via @electron/get (electronVersion + ELECTRON_MIRROR).
const fs = require("node:fs")
const path = require("node:path")
const { spawnSync } = require("node:child_process")
function electronDistDir() {
try {
return path.join(path.dirname(require.resolve("electron/package.json")), "dist")
} catch {
return null
}
}
function distBinary(dist) {
if (process.platform === "darwin") {
return path.join(dist, "Electron.app", "Contents", "MacOS", "Electron")
}
if (process.platform === "win32") {
return path.join(dist, "electron.exe")
}
return path.join(dist, "electron")
}
function electronBuilderCli() {
const pkgJson = require.resolve("electron-builder/package.json")
const bin = require(pkgJson).bin
const rel = typeof bin === "string" ? bin : bin["electron-builder"]
return path.join(path.dirname(pkgJson), rel)
}
const dist = electronDistDir()
const args = []
if (dist && fs.existsSync(distBinary(dist))) {
args.push(`-c.electronDist=${dist}`)
} else {
console.warn(
"[run-electron-builder] no local electron dist; electron-builder will fetch " +
"via @electron/get (electronVersion + ELECTRON_MIRROR)."
)
}
args.push(...process.argv.slice(2))
const result = spawnSync(process.execPath, [electronBuilderCli(), ...args], {
stdio: "inherit",
})
if (result.error) {
console.error(`[run-electron-builder] spawn failed: ${result.error.message}`)
process.exit(1)
}
process.exit(result.status == null ? 1 : result.status)

View File

@ -9,6 +9,7 @@ import { formatCombo } from '@/lib/keybinds/combo'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { ConversationStatus } from './hooks/use-voice-conversation' import type { ConversationStatus } from './hooks/use-voice-conversation'
import { ModelPill } from './model-pill'
import type { ChatBarState, VoiceStatus } from './types' import type { ChatBarState, VoiceStatus } from './types'
export const ICON_BTN = 'size-(--composer-control-size) shrink-0 rounded-md' export const ICON_BTN = 'size-(--composer-control-size) shrink-0 rounded-md'
@ -66,6 +67,7 @@ export function ComposerControls({
const c = t.composer const c = t.composer
const steerCombo = formatCombo('mod+enter') const steerCombo = formatCombo('mod+enter')
const steerLabel = `${c.steer} (${steerCombo})` const steerLabel = `${c.steer} (${steerCombo})`
const steerTip = ( const steerTip = (
<span className="inline-flex items-center gap-1.5"> <span className="inline-flex items-center gap-1.5">
{c.steer} {c.steer}
@ -81,8 +83,10 @@ export function ComposerControls({
return ( return (
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)"> <div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} /> <ModelPill disabled={disabled} model={state.model} />
{canSteer && ( {/* While the agent runs and the user is typing, steer takes over the mic's
slot rather than crowding the row with an extra button. */}
{canSteer ? (
<Tip label={steerTip}> <Tip label={steerTip}>
<Button <Button
aria-label={steerLabel} aria-label={steerLabel}
@ -96,6 +100,8 @@ export function ComposerControls({
<SteeringWheel size={16} /> <SteeringWheel size={16} />
</Button> </Button>
</Tip> </Tip>
) : (
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
)} )}
{showVoicePrimary ? ( {showVoicePrimary ? (
<Tip label={c.startVoice}> <Tip label={c.startVoice}>

View File

@ -0,0 +1,86 @@
import { useStore } from '@nanostores/react'
import { useState } from 'react'
import { ModelMenuCloseContext } from '@/app/shell/model-menu-panel'
import { Button } from '@/components/ui/button'
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { useI18n } from '@/i18n'
import { ChevronDown } from '@/lib/icons'
import { formatModelStatusLabel } from '@/lib/model-status-label'
import { cn } from '@/lib/utils'
import {
$currentFastMode,
$currentModel,
$currentProvider,
$currentReasoningEffort,
setModelPickerOpen
} from '@/store/session'
import type { ChatBarState } from './types'
const PILL = cn(
'h-(--composer-control-size) max-w-40 shrink-0 gap-1 rounded-md px-2 text-xs font-normal',
'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
)
/**
* Composer model selector the relocated status-bar pill. Reuses the live
* `model.options` dropdown (`modelMenuContent`) verbatim; falls back to the
* full picker when the gateway is closed and no live menu exists.
*/
export function ModelPill({ disabled, model }: { disabled: boolean; model: ChatBarState['model'] }) {
const copy = useI18n().t.shell.statusbar
const currentModel = useStore($currentModel)
const currentProvider = useStore($currentProvider)
const fastMode = useStore($currentFastMode)
const reasoningEffort = useStore($currentReasoningEffort)
const [open, setOpen] = useState(false)
// 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.
const label = (
<>
{currentModel.trim() ? (
<span className="truncate">{formatModelStatusLabel(currentModel, { fastMode, reasoningEffort })}</span>
) : (
<GlyphSpinner className="opacity-50" spinner="braille" />
)}
<ChevronDown className="size-2.5 shrink-0 opacity-50" />
</>
)
const title = currentProvider ? copy.modelTitle(currentProvider, currentModel || copy.modelNone) : copy.switchModel
if (!model.modelMenuContent) {
return (
<Button
aria-label={copy.openModelPicker}
className={PILL}
disabled={disabled}
onClick={() => setModelPickerOpen(true)}
title={copy.openModelPicker}
type="button"
variant="ghost"
>
{label}
</Button>
)
}
return (
<DropdownMenu onOpenChange={setOpen} open={open}>
<DropdownMenuTrigger asChild>
<Button aria-label={title} className={PILL} disabled={disabled} title={title} type="button" variant="ghost">
{label}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64 p-0" side="top" sideOffset={8}>
<ModelMenuCloseContext.Provider value={() => setOpen(false)}>
{model.modelMenuContent}
</ModelMenuCloseContext.Provider>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -1,3 +1,5 @@
import type { ReactNode } from 'react'
import type { HermesGateway } from '@/hermes' import type { HermesGateway } from '@/hermes'
import type { ComposerAttachment } from '@/store/composer' import type { ComposerAttachment } from '@/store/composer'
@ -22,6 +24,8 @@ export interface ChatBarState {
canSwitch: boolean canSwitch: boolean
loading?: boolean loading?: boolean
quickModels?: QuickModelOption[] quickModels?: QuickModelOption[]
/** Reused status-bar dropdown (built with gateway + selectModel upstream). */
modelMenuContent?: ReactNode
} }
tools: { enabled: boolean; label: string; suggestions?: ContextSuggestion[] } tools: { enabled: boolean; label: string; suggestions?: ContextSuggestion[] }
voice: { enabled: boolean; active: boolean } voice: { enabled: boolean; active: boolean }

View File

@ -15,7 +15,9 @@ import { Backdrop } from '@/components/Backdrop'
import { PromptOverlays } from '@/components/prompt-overlays' import { PromptOverlays } from '@/components/prompt-overlays'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon' import { Codicon } from '@/components/ui/codicon'
import { ErrorState } from '@/components/ui/error-state'
import { getGlobalModelOptions, type HermesGateway } from '@/hermes' import { getGlobalModelOptions, type HermesGateway } from '@/hermes'
import { useI18n } from '@/i18n'
import type { ChatMessage } from '@/lib/chat-messages' import type { ChatMessage } from '@/lib/chat-messages'
import { quickModelOptions, sessionTitle, toRuntimeMessage } from '@/lib/chat-runtime' import { quickModelOptions, sessionTitle, toRuntimeMessage } from '@/lib/chat-runtime'
import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime' import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'
@ -38,11 +40,12 @@ import {
$lastVisibleMessageIsUser, $lastVisibleMessageIsUser,
$messages, $messages,
$messagesEmpty, $messagesEmpty,
$resumeExhaustedSessionId,
$selectedStoredSessionId, $selectedStoredSessionId,
$sessions, $sessions,
sessionPinId sessionPinId
} from '@/store/session' } from '@/store/session'
import { isNewSessionWindow, isSecondaryWindow } from '@/store/windows' import { isSecondaryWindow } from '@/store/windows'
import type { ModelOptionsResponse } from '@/types/hermes' import type { ModelOptionsResponse } from '@/types/hermes'
import { routeSessionId } from '../routes' import { routeSessionId } from '../routes'
@ -62,6 +65,7 @@ import { threadLoadingState } from './thread-loading'
interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> { interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
gateway: HermesGateway | null gateway: HermesGateway | null
modelMenuContent?: React.ReactNode
onToggleSelectedPin: () => void onToggleSelectedPin: () => void
onDeleteSelectedSession: () => void onDeleteSelectedSession: () => void
onCancel: () => Promise<void> | void onCancel: () => Promise<void> | void
@ -85,7 +89,9 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
onEdit: (message: AppendMessage) => Promise<void> onEdit: (message: AppendMessage) => Promise<void>
onReload: (parentId: string | null) => Promise<void> onReload: (parentId: string | null) => Promise<void>
onRestoreToMessage?: (messageId: string) => Promise<void> onRestoreToMessage?: (messageId: string) => Promise<void>
onRetryResume: (sessionId: string) => void
onTranscribeAudio?: (audio: Blob) => Promise<string> onTranscribeAudio?: (audio: Blob) => Promise<string>
onDismissError?: (messageId: string) => void
} }
interface ChatHeaderProps { interface ChatHeaderProps {
@ -120,10 +126,10 @@ function ChatHeader({
? pinnedSessionIds.includes(selectedSessionId) ? pinnedSessionIds.includes(selectedSessionId)
: false : false
// A brand-new session has no session to pin/delete/rename, so the header is // Secondary windows (new-session scratch, subagent watch, cmd-click pop-out)
// just a dead "New session" label + chevron. Drop it (and its border) // are compact side panels — they drop the session-actions header + border
// entirely until there's a real session to act on. // entirely. A brand-new draft has nothing to pin/delete/rename either.
if (isNewSessionWindow() || (!selectedSessionId && !activeSessionId && !isRoutedSessionView)) { if (isSecondaryWindow() || (!selectedSessionId && !activeSessionId && !isRoutedSessionView)) {
return null return null
} }
@ -250,6 +256,7 @@ function ChatRuntimeBoundary({
export function ChatView({ export function ChatView({
className, className,
gateway, gateway,
modelMenuContent,
onToggleSelectedPin, onToggleSelectedPin,
onDeleteSelectedSession, onDeleteSelectedSession,
onCancel, onCancel,
@ -270,9 +277,12 @@ export function ChatView({
onEdit, onEdit,
onReload, onReload,
onRestoreToMessage, onRestoreToMessage,
onTranscribeAudio onRetryResume,
onTranscribeAudio,
onDismissError
}: ChatViewProps) { }: ChatViewProps) {
const location = useLocation() const location = useLocation()
const { t } = useI18n()
const activeSessionId = useStore($activeSessionId) const activeSessionId = useStore($activeSessionId)
const awaitingResponse = useStore($awaitingResponse) const awaitingResponse = useStore($awaitingResponse)
const busy = useStore($busy) const busy = useStore($busy)
@ -294,6 +304,7 @@ export function ChatView({
const messagesEmpty = useStore($messagesEmpty) const messagesEmpty = useStore($messagesEmpty)
const lastVisibleIsUser = useStore($lastVisibleMessageIsUser) const lastVisibleIsUser = useStore($lastVisibleMessageIsUser)
const selectedSessionId = useStore($selectedStoredSessionId) const selectedSessionId = useStore($selectedStoredSessionId)
const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId)
const routedSessionId = routeSessionId(location.pathname) const routedSessionId = routeSessionId(location.pathname)
const isRoutedSessionView = Boolean(routedSessionId) const isRoutedSessionView = Boolean(routedSessionId)
@ -313,9 +324,21 @@ export function ChatView({
// session exists — even if it has zero messages (a brand-new routed // session exists — even if it has zero messages (a brand-new routed
// session). The flicker where `busy` flips true briefly during hydrate // session). The flicker where `busy` flips true briefly during hydrate
// is handled by `threadLoadingState`'s last-visible-user gate. // is handled by `threadLoadingState`'s last-visible-user gate.
const loadingSession = isRoutedSessionView && (routeSessionMismatch || (messagesEmpty && !activeSessionId)) //
// resumeExhausted: the bounded auto-retry in use-route-resume gave up on this
// routed session (gateway RPC + REST fallback failed through every attempt).
// Suppress the loader and show an explicit error + manual Retry instead of
// spinning forever. Gated on the route matching so a stale latch from another
// session can't blank the current one.
const resumeExhausted = isRoutedSessionView && resumeExhaustedSessionId === routedSessionId
const loadingSession =
!resumeExhausted && isRoutedSessionView && (routeSessionMismatch || (messagesEmpty && !activeSessionId))
const threadLoading = threadLoadingState(loadingSession, busy, awaitingResponse, lastVisibleIsUser) const threadLoading = threadLoadingState(loadingSession, busy, awaitingResponse, lastVisibleIsUser)
const showChatBar = !loadingSession // Hide the composer in the exhausted error state too: there's no live runtime
// to send to until a retry rebinds one.
const showChatBar = !loadingSession && !resumeExhausted
const threadKey = selectedSessionId || activeSessionId || (isRoutedSessionView ? location.pathname : 'new') const threadKey = selectedSessionId || activeSessionId || (isRoutedSessionView ? location.pathname : 'new')
const modelOptionsQuery = useQuery<ModelOptionsResponse>({ const modelOptionsQuery = useQuery<ModelOptionsResponse>({
@ -346,6 +369,7 @@ export function ChatView({
provider: currentProvider, provider: currentProvider,
canSwitch: gatewayOpen, canSwitch: gatewayOpen,
loading: !gatewayOpen || (!currentModel && !currentProvider), loading: !gatewayOpen || (!currentModel && !currentProvider),
modelMenuContent,
quickModels quickModels
}, },
tools: { tools: {
@ -358,7 +382,7 @@ export function ChatView({
active: false active: false
} }
}), }),
[contextSuggestions, currentModel, currentProvider, gatewayOpen, quickModels] [contextSuggestions, currentModel, currentProvider, gatewayOpen, modelMenuContent, quickModels]
) )
// Drop files anywhere in the conversation area, not just on the composer // Drop files anywhere in the conversation area, not just on the composer
@ -429,6 +453,7 @@ export function ChatView({
loading={threadLoading} loading={threadLoading}
onBranchInNewChat={onBranchInNewChat} onBranchInNewChat={onBranchInNewChat}
onCancel={onCancel} onCancel={onCancel}
onDismissError={onDismissError}
onRestoreToMessage={onRestoreToMessage} onRestoreToMessage={onRestoreToMessage}
sessionId={activeSessionId} sessionId={activeSessionId}
sessionKey={threadKey} sessionKey={threadKey}
@ -462,6 +487,21 @@ export function ChatView({
</Suspense> </Suspense>
)} )}
</ChatRuntimeBoundary> </ChatRuntimeBoundary>
{resumeExhausted && routedSessionId && (
<div className="absolute inset-0 z-10 grid place-items-center bg-(--ui-chat-surface-background) px-8 py-10">
<ErrorState
className="max-w-sm"
description={t.desktop.resumeStrandedBody}
title={t.desktop.resumeStrandedTitle}
>
<div className="grid justify-items-center">
<Button onClick={() => onRetryResume(routedSessionId)} size="sm" variant="outline">
{t.desktop.resumeRetry}
</Button>
</div>
</ErrorState>
</div>
)}
{showChatBar && <ScrollToBottomButton />} {showChatBar && <ScrollToBottomButton />}
<ChatDropOverlay kind={dragKind} /> <ChatDropOverlay kind={dragKind} />
<ChatSwapOverlay profile={gatewaySwapTarget} /> <ChatSwapOverlay profile={gatewaySwapTarget} />

View File

@ -13,7 +13,7 @@ import { useSkinCommand } from '@/themes/use-skin-command'
import { formatRefValue } from '../components/assistant-ui/directive-text' import { formatRefValue } from '../components/assistant-ui/directive-text'
import { getCronJobs, getSessionMessages, listAllProfileSessions, type SessionInfo, triggerCronJob } from '../hermes' import { getCronJobs, getSessionMessages, listAllProfileSessions, type SessionInfo, triggerCronJob } from '../hermes'
import { preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages' import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages'
import { import {
isMessagingSource, isMessagingSource,
LOCAL_SESSION_SOURCE_IDS, LOCAL_SESSION_SOURCE_IDS,
@ -52,7 +52,10 @@ import {
$currentCwd, $currentCwd,
$freshDraftReady, $freshDraftReady,
$gatewayState, $gatewayState,
$messages,
$messagingSessions, $messagingSessions,
$resumeFailedSessionId,
$resumeExhaustedSessionId,
$selectedStoredSessionId, $selectedStoredSessionId,
$sessions, $sessions,
$workingSessionIds, $workingSessionIds,
@ -199,6 +202,8 @@ export function DesktopController() {
const activeSessionId = useStore($activeSessionId) const activeSessionId = useStore($activeSessionId)
const currentCwd = useStore($currentCwd) const currentCwd = useStore($currentCwd)
const freshDraftReady = useStore($freshDraftReady) const freshDraftReady = useStore($freshDraftReady)
const resumeFailedSessionId = useStore($resumeFailedSessionId)
const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId)
const filePreviewTarget = useStore($filePreviewTarget) const filePreviewTarget = useStore($filePreviewTarget)
const previewTarget = useStore($previewTarget) const previewTarget = useStore($previewTarget)
const selectedStoredSessionId = useStore($selectedStoredSessionId) const selectedStoredSessionId = useStore($selectedStoredSessionId)
@ -711,7 +716,9 @@ export function DesktopController() {
} }
lastGatewayProfileRef.current = activeGatewayProfile lastGatewayProfileRef.current = activeGatewayProfile
void refreshCurrentModel() // Force: the new profile has its own default, so reseed even if the composer
// already shows the previous profile's model.
void refreshCurrentModel(true)
void refreshActiveProfile() void refreshActiveProfile()
}, [activeGatewayProfile, refreshCurrentModel]) }, [activeGatewayProfile, refreshCurrentModel])
@ -734,6 +741,49 @@ export function DesktopController() {
[branchCurrentSession, refreshSessions] [branchCurrentSession, refreshSessions]
) )
// Clear a failed turn's red error banner from the transcript. Errors are
// renderer-local state (never persisted), so dismissing is purely a view +
// session-cache edit. A message that errored before emitting any visible
// text is a bare error placeholder → drop it entirely; one that streamed
// partial output then failed keeps its content and just sheds the error.
// Both the per-runtime cache AND the live $messages view must be updated:
// `preserveLocalAssistantErrors` re-grafts any still-errored message it
// finds in the view onto the next session.info flush, so clearing only the
// cache would let the heartbeat resurrect the banner.
const dismissError = useCallback(
(messageId: string) => {
const runtimeSessionId = activeSessionIdRef.current
if (!runtimeSessionId) {
return
}
const clearErrorIn = (messages: ChatMessage[]): ChatMessage[] =>
messages.flatMap(message => {
if (message.id !== messageId || !message.error) {
return [message]
}
if (!chatMessageText(message).trim() && !message.parts.some(part => part.type !== 'text')) {
return []
}
return [{ ...message, error: undefined, pending: false }]
})
// View first: the flush below reads $messages as the "current" baseline
// for error preservation, so the banner must be gone from it before the
// cache update triggers a re-sync.
setMessages(clearErrorIn($messages.get()))
updateSessionState(runtimeSessionId, state => ({
...state,
messages: clearErrorIn(state.messages)
}))
},
[activeSessionIdRef, updateSessionState]
)
const startSessionInWorkspace = useCallback( const startSessionInWorkspace = useCallback(
(path: null | string) => { (path: null | string) => {
startFreshSessionDraft() startFreshSessionDraft()
@ -843,6 +893,8 @@ export function DesktopController() {
gatewayState, gatewayState,
locationPathname: location.pathname, locationPathname: location.pathname,
resumeSession, resumeSession,
resumeFailedSessionId,
resumeExhaustedSessionId,
routedSessionId, routedSessionId,
runtimeIdByStoredSessionIdRef, runtimeIdByStoredSessionIdRef,
selectedStoredSessionId, selectedStoredSessionId,
@ -859,7 +911,6 @@ export function DesktopController() {
gatewayLogLines, gatewayLogLines,
gatewayState, gatewayState,
inferenceStatus, inferenceStatus,
modelMenuContent,
openAgents, openAgents,
freshDraftReady, freshDraftReady,
openCommandCenterSection, openCommandCenterSection,
@ -981,6 +1032,7 @@ export function DesktopController() {
<ChatView <ChatView
gateway={gatewayRef.current} gateway={gatewayRef.current}
maxVoiceRecordingSeconds={voiceMaxRecordingSeconds} maxVoiceRecordingSeconds={voiceMaxRecordingSeconds}
modelMenuContent={modelMenuContent}
onAddContextRef={composer.addContextRefAttachment} onAddContextRef={composer.addContextRefAttachment}
onAddUrl={url => composer.addContextRefAttachment(`@url:${formatRefValue(url)}`, url)} onAddUrl={url => composer.addContextRefAttachment(`@url:${formatRefValue(url)}`, url)}
onAttachDroppedItems={composer.attachDroppedItems} onAttachDroppedItems={composer.attachDroppedItems}
@ -992,6 +1044,7 @@ export function DesktopController() {
void removeSession(selectedStoredSessionId) void removeSession(selectedStoredSessionId)
} }
}} }}
onDismissError={dismissError}
onEdit={editMessage} onEdit={editMessage}
onPasteClipboardImage={() => void composer.pasteClipboardImage()} onPasteClipboardImage={() => void composer.pasteClipboardImage()}
onPickFiles={() => void composer.pickContextPaths('file')} onPickFiles={() => void composer.pickContextPaths('file')}
@ -1000,6 +1053,7 @@ export function DesktopController() {
onReload={reloadFromMessage} onReload={reloadFromMessage}
onRemoveAttachment={id => void composer.removeAttachment(id)} onRemoveAttachment={id => void composer.removeAttachment(id)}
onRestoreToMessage={restoreToMessage} onRestoreToMessage={restoreToMessage}
onRetryResume={sessionId => void resumeSession(sessionId, true)}
onSteer={steerPrompt} onSteer={steerPrompt}
onSubmit={submitText} onSubmit={submitText}
onThreadMessagesChange={handleThreadMessagesChange} onThreadMessagesChange={handleThreadMessagesChange}

View File

@ -9,3 +9,22 @@ export const $terminalTakeover = atom(storedBoolean(TAKEOVER_KEY, false))
$terminalTakeover.subscribe(active => persistBoolean(TAKEOVER_KEY, active)) $terminalTakeover.subscribe(active => persistBoolean(TAKEOVER_KEY, active))
export const setTerminalTakeover = (active: boolean) => $terminalTakeover.set(active) export const setTerminalTakeover = (active: boolean) => $terminalTakeover.set(active)
/** A command queued to run in the embedded terminal. The terminal pane flushes
* (and clears) it once its session is live, so a value set before the pane
* mounts still runs. Cleared after flush so a later remount can't replay it. */
export const $terminalInjection = atom<null | string>(null)
/** Open the terminal pane and run a command in it. Used to disconnect external
* (CLI-managed) providers, which Hermes can't clear via the API the user
* sees exactly what runs instead of Hermes silently deleting their creds. */
export const runInTerminal = (command: string) => {
const trimmed = command.trim()
if (!trimmed) {
return
}
setTerminalTakeover(true)
$terminalInjection.set(trimmed)
}

View File

@ -10,6 +10,8 @@ import { triggerHaptic } from '@/lib/haptics'
import { $filePreviewTarget, $previewTarget } from '@/store/preview' import { $filePreviewTarget, $previewTarget } from '@/store/preview'
import { useTheme } from '@/themes/context' import { useTheme } from '@/themes/context'
import { $terminalInjection } from '../store'
import { makeTerminalReader, setActiveTerminalReader } from './buffer' import { makeTerminalReader, setActiveTerminalReader } from './buffer'
import { import {
isAddSelectionShortcut, isAddSelectionShortcut,
@ -675,6 +677,28 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
return () => cancelAnimationFrame(raf) return () => cancelAnimationFrame(raf)
}, [activeTheme, themeName]) }, [activeTheme, themeName])
// Flush a queued command (e.g. a provider-disconnect) into the live session.
// Only active while open; the subscribe fires immediately, so a command set
// before this pane mounted runs as soon as the session is ready. Clearing the
// atom after writing stops a later remount from replaying a stale command.
useEffect(() => {
if (status !== 'open') {
return
}
return $terminalInjection.subscribe(command => {
const id = sessionIdRef.current
if (!command || !id) {
return
}
void window.hermesDesktop?.terminal?.write(id, `${command}\r`)
$terminalInjection.set(null)
termRef.current?.focus()
})
}, [status])
return { return {
addSelectionToChat, addSelectionToChat,
hostRef, hostRef,

View File

@ -13,6 +13,7 @@ import {
type GatewayEventPayload, type GatewayEventPayload,
reasoningPart, reasoningPart,
renderMediaTags, renderMediaTags,
textPart,
upsertToolPart upsertToolPart
} from '@/lib/chat-messages' } from '@/lib/chat-messages'
import { coerceGatewayText, coerceThinkingText, normalizePersonalityValue } from '@/lib/chat-runtime' import { coerceGatewayText, coerceThinkingText, normalizePersonalityValue } from '@/lib/chat-runtime'
@ -1080,6 +1081,32 @@ export function useMessageStream({
// completions / watch matches here — re-sync the status stack. // completions / watch matches here — re-sync the status stack.
void refreshBackgroundProcesses(sessionId) void refreshBackgroundProcesses(sessionId)
} }
} else if (event.type === 'review.summary') {
// Self-improvement background review saved something to memory/skills
// and emitted a persistent summary (Python formats it as
// "💾 Self-improvement review: …"). The CLI prints this via
// prompt_toolkit and the Ink TUI renders it as a system line; the
// desktop has neither, so without this handler the skill/memory
// change happens silently. Surface it as a persistent system message
// in the transcript so the user is always informed — it must not be a
// transient toast that can be missed.
const text = coerceGatewayText(payload?.text).trim()
if (text && sessionId) {
flushQueuedDeltas(sessionId)
updateSessionState(sessionId, state => ({
...state,
messages: [
...state.messages,
{
id: `review-summary-${Date.now()}`,
role: 'system',
parts: [textPart(text)],
timestamp: Math.floor(Date.now() / 1000)
}
]
}))
}
} else if (event.type === 'error') { } else if (event.type === 'error') {
const errorMessage = payload?.message || 'Hermes reported an error' const errorMessage = payload?.message || 'Hermes reported an error'
const looksLikeProviderSetup = isProviderSetupErrorMessage(errorMessage) const looksLikeProviderSetup = isProviderSetupErrorMessage(errorMessage)
@ -1102,8 +1129,13 @@ export function useMessageStream({
if (looksLikeProviderSetup) { if (looksLikeProviderSetup) {
requestDesktopOnboarding(errorMessage) requestDesktopOnboarding(errorMessage)
} else if (isActiveEvent) { } else {
// Toast globally, not just when the failing thread is focused: a
// turn-ending error (e.g. out of funds) blocks every thread, so the
// inline error alone is too easy to miss. The stable id collapses the
// same error from multiple blocked threads into one toast.
notify({ notify({
id: `gateway-error:${errorMessage}`,
kind: 'error', kind: 'error',
title: 'Hermes error', title: 'Hermes error',
message: errorMessage message: errorMessage

View File

@ -130,7 +130,6 @@ describe('useModelControls', () => {
await expect( await expect(
controls.selectModel({ controls.selectModel({
model: 'claude-sonnet-4.6', model: 'claude-sonnet-4.6',
persistGlobal: false,
provider: 'anthropic' provider: 'anthropic'
}) })
).resolves.toBe(true) ).resolves.toBe(true)
@ -143,26 +142,57 @@ describe('useModelControls', () => {
expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything()) expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything())
}) })
it('keeps the global path on setGlobalModel when there is no active session', async () => { it('stores a no-session pick as UI state with no gateway or global write', async () => {
setGlobalModel.mockResolvedValue(undefined) const requestGateway = vi.fn()
let controls!: Controls let controls!: Controls
render( render(
<Harness <Harness
activeSessionId={null} activeSessionId={null}
onReady={value => (controls = value)} onReady={value => (controls = value)}
requestGateway={vi.fn()} requestGateway={requestGateway}
/> />
) )
await expect( await expect(
controls.selectModel({ controls.selectModel({
model: 'claude-sonnet-4.6', model: 'claude-sonnet-4.6',
persistGlobal: false,
provider: 'anthropic' provider: 'anthropic'
}) })
).resolves.toBe(true) ).resolves.toBe(true)
expect(setGlobalModel).toHaveBeenCalledWith('anthropic', 'claude-sonnet-4.6') // The pick is plain UI state; session.create ships it later. Nothing touches
// the gateway or the profile default here.
expect($currentModel.get()).toBe('claude-sonnet-4.6')
expect($currentProvider.get()).toBe('anthropic')
expect(requestGateway).not.toHaveBeenCalled()
expect(setGlobalModel).not.toHaveBeenCalled()
})
it('seeds an empty composer model from global but never clobbers a pick', async () => {
vi.mocked(getGlobalModelInfo).mockResolvedValue({ model: 'openai/gpt-5.5', provider: 'openai-codex' })
const { result } = renderHook(() =>
useModelControls({
activeSessionId: null,
queryClient: new QueryClient(),
requestGateway: vi.fn()
})
)
// Empty → seeds the default.
await result.current.refreshCurrentModel()
expect($currentModel.get()).toBe('openai/gpt-5.5')
// A user pick must survive the lifecycle refreshes that fire on boot / fresh
// draft / session events.
setCurrentModel('anthropic/claude-sonnet-4.6')
setCurrentProvider('anthropic')
await result.current.refreshCurrentModel()
expect($currentModel.get()).toBe('anthropic/claude-sonnet-4.6')
// A profile swap forces a reseed to the new profile's default.
await result.current.refreshCurrentModel(true)
expect($currentModel.get()).toBe('openai/gpt-5.5')
}) })
}) })

View File

@ -1,7 +1,7 @@
import { type QueryClient } from '@tanstack/react-query' import { type QueryClient } from '@tanstack/react-query'
import { useCallback } from 'react' import { useCallback } from 'react'
import { getGlobalModelInfo, setGlobalModel } from '@/hermes' import { getGlobalModelInfo } from '@/hermes'
import { useI18n } from '@/i18n' import { useI18n } from '@/i18n'
import { notifyError } from '@/store/notifications' import { notifyError } from '@/store/notifications'
import { import {
@ -15,7 +15,6 @@ import type { ModelOptionsResponse } from '@/types/hermes'
interface ModelSelection { interface ModelSelection {
model: string model: string
persistGlobal: boolean
provider: string provider: string
} }
@ -28,6 +27,7 @@ interface ModelControlsOptions {
export function useModelControls({ activeSessionId, queryClient, requestGateway }: ModelControlsOptions) { export function useModelControls({ activeSessionId, queryClient, requestGateway }: ModelControlsOptions) {
const { t } = useI18n() const { t } = useI18n()
const copy = t.desktop const copy = t.desktop
const updateModelOptionsCache = useCallback( const updateModelOptionsCache = useCallback(
(provider: string, model: string, includeGlobal: boolean) => { (provider: string, model: string, includeGlobal: boolean) => {
const patch = (prev: ModelOptionsResponse | undefined) => ({ ...(prev ?? {}), provider, model }) const patch = (prev: ModelOptionsResponse | undefined) => ({ ...(prev ?? {}), provider, model })
@ -41,14 +41,24 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
[activeSessionId, queryClient] [activeSessionId, queryClient]
) )
const refreshCurrentModel = useCallback(async () => { // Seed the composer's model state from the profile default. `force` reseeds
// for a profile swap (the new profile has its own default); otherwise this
// only fills an EMPTY selection so a user's pick (plain UI state in
// $currentModel) survives the lifecycle refreshes that fire on boot / fresh
// draft / session events. A live session owns the footer, so skip entirely.
const refreshCurrentModel = useCallback(async (force = false) => {
try { try {
if ($activeSessionId.get()) {
return
}
if (!force && $currentModel.get()) {
return
}
const result = await getGlobalModelInfo() const result = await getGlobalModelInfo()
// A resumed/live session owns the footer model state. Global config if ($activeSessionId.get() || (!force && $currentModel.get())) {
// refreshes (gateway boot, profile swap, settings save) must not clobber
// the active chat's runtime model/provider in the status bar.
if ($activeSessionId.get()) {
return return
} }
@ -64,12 +74,14 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
} }
}, []) }, [])
// Returns whether the switch succeeded so callers can await it before // Returns whether the switch succeeded so callers can await it before applying
// applying follow-up changes (e.g. editing a model's reasoning/fast must land // follow-up changes. The composer model is plain UI state: with no live
// on the right active model — bail rather than write to the previous one). // session it's just stored (and shipped on the next session.create); with one
// it's scoped to that session via config.set. It NEVER writes the profile
// default — that lives in Settings → Model — so picking a model here can't
// silently mutate global config.
const selectModel = useCallback( const selectModel = useCallback(
async (selection: ModelSelection): Promise<boolean> => { async (selection: ModelSelection): Promise<boolean> => {
const includeGlobal = selection.persistGlobal || !activeSessionId
// Snapshot for rollback: the switch is applied optimistically, so a // Snapshot for rollback: the switch is applied optimistically, so a
// failure must restore the prior model/provider (store + query cache) // failure must restore the prior model/provider (store + query cache)
// rather than leave the UI showing a model the backend never selected. // rather than leave the UI showing a model the backend never selected.
@ -78,42 +90,34 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
setCurrentModel(selection.model) setCurrentModel(selection.model)
setCurrentProvider(selection.provider) setCurrentProvider(selection.provider)
updateModelOptionsCache(selection.provider, selection.model, includeGlobal) updateModelOptionsCache(selection.provider, selection.model, !activeSessionId)
// No live session yet: the pick is pure UI state. session.create reads
// $currentModel/$currentProvider and applies it as that session's override.
if (!activeSessionId) {
return true
}
try { try {
if (activeSessionId) { await requestGateway('config.set', {
await requestGateway('config.set', { session_id: activeSessionId,
session_id: activeSessionId, key: 'model',
key: 'model', value: `${selection.model} --provider ${selection.provider}`
value: `${selection.model} --provider ${selection.provider}${selection.persistGlobal ? ' --global' : ''}` })
})
if (selection.persistGlobal) { void queryClient.invalidateQueries({ queryKey: ['model-options', activeSessionId] })
void refreshCurrentModel()
}
void queryClient.invalidateQueries({
queryKey: selection.persistGlobal ? ['model-options'] : ['model-options', activeSessionId]
})
return true
}
await setGlobalModel(selection.provider, selection.model)
void refreshCurrentModel()
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
return true return true
} catch (err) { } catch (err) {
setCurrentModel(prevModel) setCurrentModel(prevModel)
setCurrentProvider(prevProvider) setCurrentProvider(prevProvider)
updateModelOptionsCache(prevProvider, prevModel, includeGlobal) updateModelOptionsCache(prevProvider, prevModel, !activeSessionId)
notifyError(err, copy.modelSwitchFailed) notifyError(err, copy.modelSwitchFailed)
return false return false
} }
}, },
[activeSessionId, copy.modelSwitchFailed, queryClient, refreshCurrentModel, requestGateway, updateModelOptionsCache] [activeSessionId, copy.modelSwitchFailed, queryClient, requestGateway, updateModelOptionsCache]
) )
return { refreshCurrentModel, selectModel, updateModelOptionsCache } return { refreshCurrentModel, selectModel, updateModelOptionsCache }

View File

@ -2,6 +2,8 @@ import { cleanup, render } from '@testing-library/react'
import type { MutableRefObject } from 'react' import type { MutableRefObject } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import { $resumeExhaustedSessionId, setResumeExhaustedSessionId } from '@/store/session'
import { useRouteResume } from './use-route-resume' import { useRouteResume } from './use-route-resume'
interface HarnessProps { interface HarnessProps {
@ -13,6 +15,8 @@ interface HarnessProps {
gatewayState: string gatewayState: string
locationPathname: string locationPathname: string
resumeSession: (sessionId: string, focus: boolean) => Promise<unknown> resumeSession: (sessionId: string, focus: boolean) => Promise<unknown>
resumeFailedSessionId?: null | string
resumeExhaustedSessionId?: null | string
routedSessionId: null | string routedSessionId: null | string
runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>> runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>>
selectedStoredSessionId: null | string selectedStoredSessionId: null | string
@ -20,8 +24,12 @@ interface HarnessProps {
startFreshSessionDraft: (focus: boolean) => unknown startFreshSessionDraft: (focus: boolean) => unknown
} }
function RouteResumeHarness(props: HarnessProps) { function RouteResumeHarness({
useRouteResume(props) resumeFailedSessionId = null,
resumeExhaustedSessionId = null,
...props
}: HarnessProps) {
useRouteResume({ ...props, resumeExhaustedSessionId, resumeFailedSessionId })
return null return null
} }
@ -256,3 +264,212 @@ describe('useRouteResume', () => {
expect(resumeSession).toHaveBeenCalledWith('session-1', true) expect(resumeSession).toHaveBeenCalledWith('session-1', true)
}) })
}) })
describe('useRouteResume bounded auto-retry after a failed resume', () => {
afterEach(() => {
cleanup()
vi.useRealTimers()
vi.restoreAllMocks()
setResumeExhaustedSessionId(null)
})
// Common stranded-window props: gateway open, route on the session, no runtime
// yet, and the ref already synced to the route (resumeSession sets it at entry
// before failing) — the exact state that defeats the main effect's self-heal.
function strandedProps(resumeSession: (sid: string, focus: boolean) => Promise<unknown>) {
return {
activeSessionId: null,
activeSessionIdRef: { current: null } as MutableRefObject<null | string>,
creatingSessionRef: { current: false },
currentView: 'chat',
freshDraftReady: false,
gatewayState: 'open',
locationPathname: '/session-1',
resumeSession,
routedSessionId: 'session-1',
runtimeIdByStoredSessionIdRef: { current: new Map<string, string>() },
selectedStoredSessionId: 'session-1',
// Synced to the route by the failed resume's synchronous entry-write.
selectedStoredSessionIdRef: { current: 'session-1' } as MutableRefObject<null | string>,
startFreshSessionDraft: vi.fn()
}
}
it('retries the resume on backoff when the routed session is flagged as failed', () => {
vi.useFakeTimers()
const resumeSession = vi.fn(async () => undefined)
render(<RouteResumeHarness {...strandedProps(resumeSession)} resumeFailedSessionId="session-1" />)
// The main effect fires one resume on mount (pathname-changed). Clear it so
// we assert purely the bounded-retry effect's scheduled retry below.
resumeSession.mockClear()
// No immediate fire — the retry is scheduled behind the backoff timer.
expect(resumeSession).not.toHaveBeenCalled()
// First backoff window (1s) elapses → one retry.
vi.advanceTimersByTime(1_000)
expect(resumeSession).toHaveBeenCalledTimes(1)
expect(resumeSession).toHaveBeenCalledWith('session-1', true)
})
it('does NOT retry a failed session that is not the routed one', () => {
vi.useFakeTimers()
const resumeSession = vi.fn(async () => undefined)
// The failure flag points at a different session than the route.
render(<RouteResumeHarness {...strandedProps(resumeSession)} resumeFailedSessionId="other-session" />)
resumeSession.mockClear() // drop the mount resume
vi.advanceTimersByTime(10_000)
expect(resumeSession).not.toHaveBeenCalled()
})
it('skips the scheduled retry if the session already recovered when the timer fires', () => {
vi.useFakeTimers()
const resumeSession = vi.fn(async () => undefined)
const props = strandedProps(resumeSession)
render(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />)
resumeSession.mockClear() // drop the mount resume
// A resume landed while we waited: runtime is now bound.
props.activeSessionIdRef.current = 'runtime-1'
vi.advanceTimersByTime(8_000)
expect(resumeSession).not.toHaveBeenCalled()
})
it('stops retrying after MAX_RESUME_RETRIES consecutive failures', () => {
vi.useFakeTimers()
const resumeSession = vi.fn(async () => undefined)
const props = strandedProps(resumeSession)
// Model the real re-arm loop: resumeSession clears $resumeFailedSessionId at
// entry (null) and a repeat failure re-sets it ('session-1'). That null->id
// toggle is what re-runs the effect and advances the bounded counter. The
// routed session never changes, so the counter is NOT reset between cycles.
const { rerender } = render(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />)
resumeSession.mockClear() // drop the mount resume; count only the retries
for (let i = 0; i < 8; i += 1) {
vi.advanceTimersByTime(8_000) // fire the scheduled retry (if any)
rerender(<RouteResumeHarness {...props} resumeFailedSessionId={null} />) // cleared at entry
rerender(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />) // re-armed on failure
}
// Capped at MAX_RESUME_RETRIES (4): a persistently dead backend can't
// hot-loop the resume forever.
expect(resumeSession.mock.calls.length).toBe(4)
// Once auto-retry gives up, the exhausted latch is armed for the routed
// session so the chat view can swap the perpetual loader for an explicit
// error + manual Retry instead of spinning forever.
expect($resumeExhaustedSessionId.get()).toBe('session-1')
})
it('does not arm the exhausted latch while retries remain', () => {
vi.useFakeTimers()
const resumeSession = vi.fn(async () => undefined)
const props = strandedProps(resumeSession)
const { rerender } = render(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />)
resumeSession.mockClear()
// Two failure cycles — still under the 4-retry cap, so the latch must stay
// clear and the loader keeps spinning (auto-recovery hasn't given up yet).
for (let i = 0; i < 2; i += 1) {
vi.advanceTimersByTime(8_000)
rerender(<RouteResumeHarness {...props} resumeFailedSessionId={null} />)
rerender(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />)
}
expect($resumeExhaustedSessionId.get()).toBeNull()
})
it('clears a stale exhausted latch when the route moves off the stranded session', () => {
vi.useFakeTimers()
const resumeSession = vi.fn(async () => undefined)
const props = strandedProps(resumeSession)
// Pre-arm the latch as if this session had exhausted its retries.
setResumeExhaustedSessionId('session-1')
// Route is now on a different, healthy session that is not flagged as
// failed — the retry effect's "route moved off" branch clears the latch.
render(
<RouteResumeHarness
{...props}
activeSessionId="runtime-2"
activeSessionIdRef={{ current: 'runtime-2' }}
locationPathname="/session-2"
resumeFailedSessionId={null}
routedSessionId="session-2"
selectedStoredSessionId="session-2"
selectedStoredSessionIdRef={{ current: 'session-2' }}
/>
)
expect($resumeExhaustedSessionId.get()).toBeNull()
})
it('resets the retry counter for a fresh backoff cycle when the exhausted latch clears (manual retry, same session)', () => {
vi.useFakeTimers()
const resumeSession = vi.fn(async () => undefined)
const props = strandedProps(resumeSession)
// Phase A — exhaust the bounded auto-retry (counter → MAX) like a dead
// backend. The resumeExhaustedSessionId prop stays null here: the hook sets
// the store, which doesn't feed back into the prop in this harness.
const { rerender } = render(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />)
resumeSession.mockClear()
for (let i = 0; i < 8; i += 1) {
vi.advanceTimersByTime(8_000)
rerender(<RouteResumeHarness {...props} resumeFailedSessionId={null} />)
rerender(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />)
}
expect(resumeSession.mock.calls.length).toBe(4) // capped
expect($resumeExhaustedSessionId.get()).toBe('session-1')
// Phase B — user clicks Retry on the SAME stranded session. resumeSession
// clears both latches at entry; the exhausted latch's armed->cleared edge
// must reset the attempt counter so a fresh bounded cycle runs, not a single
// one-shot attempt that immediately re-arms the error. Model the prop
// transitions: reflect the armed latch, then clear it (retry), then re-arm
// the failure latch on the fresh failure.
resumeSession.mockClear()
rerender(<RouteResumeHarness {...props} resumeExhaustedSessionId="session-1" resumeFailedSessionId="session-1" />)
rerender(<RouteResumeHarness {...props} resumeExhaustedSessionId={null} resumeFailedSessionId={null} />)
rerender(<RouteResumeHarness {...props} resumeExhaustedSessionId={null} resumeFailedSessionId="session-1" />)
// A real retry fires again instead of staying pinned at MAX (which would
// dispatch nothing). Without the reset the counter stays >= MAX and this
// advance dispatches zero resumes.
vi.advanceTimersByTime(8_000)
expect(resumeSession.mock.calls.length).toBeGreaterThan(0)
})
it('does not burn retry attempts on unrelated re-renders during the backoff window', () => {
vi.useFakeTimers()
const props = strandedProps(vi.fn())
// Mount schedules the first backoff timer. Then re-render repeatedly with a
// fresh resumeSession identity (referential instability — a real dep change
// for the retry effect) WITHOUT ever letting the timer fire. The old code
// incremented the attempt counter at schedule time, so >= MAX re-renders
// armed the exhausted error with zero resumes actually dispatched. The fix
// only advances the counter when a timer truly fires, so the latch stays
// clear no matter how many spurious re-renders happen mid-backoff.
const { rerender } = render(
<RouteResumeHarness {...props} resumeFailedSessionId="session-1" resumeSession={vi.fn(async () => undefined)} />
)
for (let j = 0; j < 8; j += 1) {
rerender(
<RouteResumeHarness {...props} resumeFailedSessionId="session-1" resumeSession={vi.fn(async () => undefined)} />
)
}
expect($resumeExhaustedSessionId.get()).toBeNull()
})
})

View File

@ -1,6 +1,7 @@
import { type MutableRefObject, useEffect, useRef } from 'react' import { type MutableRefObject, useEffect, useRef } from 'react'
import { isNewChatRoute } from '@/app/routes' import { isNewChatRoute } from '@/app/routes'
import { setResumeExhaustedSessionId } from '@/store/session'
interface RouteResumeOptions { interface RouteResumeOptions {
activeSessionId: string | null activeSessionId: string | null
@ -11,6 +12,17 @@ interface RouteResumeOptions {
gatewayState: string | undefined gatewayState: string | undefined
locationPathname: string locationPathname: string
resumeSession: (sessionId: string, focus: boolean) => Promise<unknown> resumeSession: (sessionId: string, focus: boolean) => Promise<unknown>
// Stored-session id whose most recent resume failed terminally (set by
// useSessionActions, mirrored from $resumeFailedSessionId). While this equals
// routedSessionId the window would otherwise latch on the loader forever, so
// the bounded-retry effect below re-attempts the resume.
resumeFailedSessionId: string | null
// Stored-session id whose bounded auto-retry has EXHAUSTED (mirrored from
// $resumeExhaustedSessionId). Only resumeSession clears this latch (manual
// Retry / reconnect / reselect) — the auto-retry loop never does — so its
// armed->cleared edge is an unambiguous "give me a fresh backoff cycle"
// signal the effect below uses to reset the attempt counter.
resumeExhaustedSessionId: string | null
routedSessionId: string | null routedSessionId: string | null
runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>> runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>>
selectedStoredSessionId: string | null selectedStoredSessionId: string | null
@ -18,6 +30,19 @@ interface RouteResumeOptions {
startFreshSessionDraft: (focus: boolean) => unknown startFreshSessionDraft: (focus: boolean) => unknown
} }
// Bounded auto-retry for a stranded session window. A resume can fail terminally
// (gateway RPC reject + REST fallback failure) on a transiently wedged backend —
// dead provider key, a runaway turn hogging the dispatcher, flaky DNS. Without a
// retry the loader latches forever. We retry with backoff, capped, so a
// genuinely dead backend doesn't hot-loop the resume.
const MAX_RESUME_RETRIES = 4
const RESUME_RETRY_BASE_MS = 1_000
const RESUME_RETRY_MAX_MS = 8_000
function resumeRetryDelayMs(attempt: number): number {
return Math.min(RESUME_RETRY_MAX_MS, RESUME_RETRY_BASE_MS * 2 ** attempt)
}
// HashRouter boot edge case: pathname briefly reads `/` before the hash is // HashRouter boot edge case: pathname briefly reads `/` before the hash is
// parsed. If the hash references a real session, defer; resume picks it up // parsed. If the hash references a real session, defer; resume picks it up
// next tick. Without this, ctrl+R on `#/:sessionId` flashes 5 loading states. // next tick. Without this, ctrl+R on `#/:sessionId` flashes 5 loading states.
@ -49,6 +74,8 @@ export function useRouteResume({
gatewayState, gatewayState,
locationPathname, locationPathname,
resumeSession, resumeSession,
resumeFailedSessionId,
resumeExhaustedSessionId,
routedSessionId, routedSessionId,
runtimeIdByStoredSessionIdRef, runtimeIdByStoredSessionIdRef,
selectedStoredSessionId, selectedStoredSessionId,
@ -58,6 +85,16 @@ export function useRouteResume({
const lastPathnameRef = useRef<string | null>(null) const lastPathnameRef = useRef<string | null>(null)
const seenGatewayStateRef = useRef(false) const seenGatewayStateRef = useRef(false)
const wasGatewayOpenRef = useRef(false) const wasGatewayOpenRef = useRef(false)
// Per-session retry bookkeeping for the bounded auto-retry effect below. Keyed
// by the session id we're retrying so switching chats resets the counter.
const retrySessionIdRef = useRef<string | null>(null)
const retryAttemptRef = useRef(0)
// Tracks the previous exhausted-latch value so we can detect its armed->cleared
// edge. resumeSession clears $resumeExhaustedSessionId on a manual Retry /
// reconnect / reselect; that transition is our cue to reset the attempt counter
// for a fresh backoff cycle on the SAME session (the auto-retry loop itself
// never touches this latch, so it can't spuriously trigger the reset).
const prevResumeExhaustedRef = useRef<string | null>(null)
useEffect(() => { useEffect(() => {
const gatewayOpen = gatewayState === 'open' const gatewayOpen = gatewayState === 'open'
@ -139,4 +176,111 @@ export function useRouteResume({
selectedStoredSessionIdRef, selectedStoredSessionIdRef,
startFreshSessionDraft startFreshSessionDraft
]) ])
// Bounded auto-retry: when the routed session's resume failed terminally
// (resumeFailedSessionId matches the route), schedule a backoff retry so the
// window recovers on its own instead of latching the loader forever. This is
// the safety net the main effect above can't provide: after a failed resume,
// selectedStoredSessionIdRef.current already equals the route (resumeSession
// sets it synchronously at entry) and the pathname/gateway are unchanged, so
// none of stuckOnRoutedSession / pathnameChanged / gatewayBecameOpen fire
// again. resumeSession clears resumeFailedSessionId on its next attempt; a
// success keeps it clear (the effect's guard then no-ops), a repeat failure
// re-arms it and we back off further, capped at MAX_RESUME_RETRIES.
useEffect(() => {
// Detect the exhausted-latch armed->cleared edge for the current route. Only
// resumeSession clears $resumeExhaustedSessionId (manual Retry / reconnect /
// reselect) — the auto-retry loop never touches it — so this transition
// uniquely means "the user asked for another go." Reset the attempt counter
// for a fresh bounded backoff cycle on the SAME session. Without this,
// retryAttemptRef stays pinned at MAX after exhaustion (the !stranded reset
// below only fires on a route CHANGE to a different session), so a manual
// retry on the same stranded session would get exactly ONE attempt and then
// immediately re-arm the exhausted error — never the renewed backoff cycle
// the store/session.ts + use-session-actions.ts comments promise. (Point 2)
const wasExhausted = prevResumeExhaustedRef.current
prevResumeExhaustedRef.current = resumeExhaustedSessionId
if (wasExhausted && wasExhausted === routedSessionId && resumeExhaustedSessionId !== wasExhausted) {
retrySessionIdRef.current = routedSessionId
retryAttemptRef.current = 0
}
if (currentView !== 'chat' || gatewayState !== 'open') {
return
}
const stranded =
Boolean(routedSessionId) &&
resumeFailedSessionId === routedSessionId &&
!creatingSessionRef.current
if (!stranded) {
// Route moved off the stranded session (or it recovered) — reset the
// counter so a future failure on another session starts fresh, and clear
// any exhausted-latch armed for a session we're no longer viewing (never
// the current route: that's the error state we want to keep showing).
// resumeSession also clears it on a fresh attempt; this covers a plain
// route-change away from the stranded window.
if (retrySessionIdRef.current !== routedSessionId) {
retrySessionIdRef.current = null
retryAttemptRef.current = 0
setResumeExhaustedSessionId(current => (current && current !== routedSessionId ? null : current))
}
return
}
// New stranded session id → reset the attempt counter.
if (retrySessionIdRef.current !== routedSessionId) {
retrySessionIdRef.current = routedSessionId
retryAttemptRef.current = 0
}
if (retryAttemptRef.current >= MAX_RESUME_RETRIES) {
// Give up auto-retrying a persistently dead backend; the user can still
// reconnect / reselect (which resets the counter via the branch above).
// Surface an explicit error + manual Retry in the chat view instead of
// spinning the loader forever — resumeSession (manual Retry / reconnect /
// reselect) clears this latch and resets the counter for a fresh cycle.
setResumeExhaustedSessionId(routedSessionId)
return
}
const attempt = retryAttemptRef.current
const sessionId = routedSessionId as string
const timer = setTimeout(() => {
// Re-check liveness at fire time: a resume may have landed while we waited.
if (
creatingSessionRef.current ||
selectedStoredSessionIdRef.current !== sessionId ||
activeSessionIdRef.current !== null
) {
return
}
// Consume an attempt ONLY now that a resume is actually dispatching.
// Incrementing at schedule time (the old behavior) let unrelated dep
// changes during the 1s8s backoff window — a transient gatewayState
// flip, a non-referentially-stable resumeSession — clear the pending
// timer and re-run the effect, burning an attempt without any resume
// having fired. A flapping backend could then hit MAX in a couple of
// re-renders with far fewer than MAX real attempts. (Point 3)
retryAttemptRef.current += 1
void resumeSession(sessionId, true)
}, resumeRetryDelayMs(attempt))
return () => clearTimeout(timer)
}, [
activeSessionIdRef,
creatingSessionRef,
currentView,
gatewayState,
resumeSession,
resumeFailedSessionId,
resumeExhaustedSessionId,
routedSessionId,
selectedStoredSessionIdRef
])
} }

View File

@ -3,8 +3,9 @@ import type { MutableRefObject } from 'react'
import { useEffect } from 'react' import { useEffect } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import { getSessionMessages } from '@/hermes'
import { $activeGatewayProfile, $newChatProfile } from '@/store/profile' import { $activeGatewayProfile, $newChatProfile } from '@/store/profile'
import { $currentCwd } from '@/store/session' import { $currentCwd, $messages, $resumeFailedSessionId, setMessages, setResumeFailedSessionId } from '@/store/session'
import type { ClientSessionState } from '../../types' import type { ClientSessionState } from '../../types'
@ -117,3 +118,142 @@ describe('createBackendSessionForSend profile routing', () => {
expect(params).toMatchObject({ profile: 'default' }) expect(params).toMatchObject({ profile: 'default' })
}) })
}) })
// ── Resume failure recovery (the "stuck loading session window" bug) ──────────
// When session.resume rejects AND the REST transcript fallback ALSO fails, the
// hook must (a) not throw out of the fallback (which stranded the loader), and
// (b) arm $resumeFailedSessionId so use-route-resume can retry. A resume that
// succeeds must NOT leave the flag armed.
function ResumeHarness({
onReady,
requestGateway
}: {
onReady: (resume: (storedSessionId: string, replaceRoute?: boolean) => Promise<unknown>) => void
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
}) {
const ref = <T,>(value: T): MutableRefObject<T> => ({ current: value })
const actions = useSessionActions({
activeSessionId: null,
activeSessionIdRef: ref<string | null>(null),
busyRef: ref(false),
creatingSessionRef: ref(false),
ensureSessionState: () => ({}) as ClientSessionState,
getRouteToken: () => 'token',
navigate: vi.fn() as never,
requestGateway,
runtimeIdByStoredSessionIdRef: ref(new Map<string, string>()),
selectedStoredSessionId: null,
selectedStoredSessionIdRef: ref<string | null>(null),
sessionStateByRuntimeIdRef: ref(new Map<string, ClientSessionState>()),
syncSessionStateToView: vi.fn(),
updateSessionState: (_sessionId, updater) => updater({} as ClientSessionState)
})
useEffect(() => {
onReady(actions.resumeSession)
}, [actions.resumeSession, onReady])
return null
}
describe('resumeSession failure recovery', () => {
afterEach(() => {
cleanup()
setResumeFailedSessionId(null)
setMessages([])
vi.restoreAllMocks()
})
async function runResume(
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
): Promise<void> {
let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise<unknown>) | null = null
render(<ResumeHarness onReady={r => (resume = r)} requestGateway={requestGateway} />)
await waitFor(() => expect(resume).not.toBeNull())
await resume!('stored-1', true)
}
it('arms $resumeFailedSessionId when resume RPC and REST fallback both fail', async () => {
// session.resume rejects (e.g. timeout against a wedged backend)...
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.resume') {
throw new Error('request timed out: session.resume')
}
return {} as never
})
// ...and the REST transcript fallback also rejects (backend unreachable).
vi.mocked(getSessionMessages).mockRejectedValue(new Error('network down'))
await runResume(requestGateway)
// The window is no longer silently stranded: the failure latch is armed for
// the stored session, which use-route-resume consumes to retry.
expect($resumeFailedSessionId.get()).toBe('stored-1')
})
it('does NOT arm the failure latch when the resume RPC fails but the REST fallback paints history', async () => {
// session.resume rejects, but the REST transcript fallback succeeds and
// hydrates a readable transcript — the window is NOT stranded.
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.resume') {
throw new Error('request timed out: session.resume')
}
return {} as never
})
vi.mocked(getSessionMessages).mockResolvedValue({
messages: [
{ content: 'hello', role: 'user', timestamp: 1 },
{ content: 'hi there', role: 'assistant', timestamp: 2 }
],
session_id: 'stored-1'
} as never)
await runResume(requestGateway)
// Arming here would auto-retry a window that already shows history and,
// on exhaustion, blank that transcript behind the error overlay — a
// regression vs. plain fallback-success. The latch must stay clear.
expect($resumeFailedSessionId.get()).toBeNull()
// The fallback transcript is visible.
expect($messages.get().length).toBeGreaterThan(0)
})
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') {
throw new Error('request timed out: session.resume')
}
return {} as never
})
vi.mocked(getSessionMessages).mockRejectedValue(new Error('network down'))
// resumeSession must resolve (swallow the fallback failure), not reject.
await expect(runResume(requestGateway)).resolves.toBeUndefined()
})
it('leaves the failure latch clear when resume succeeds', async () => {
// Pre-arm to prove a successful resume clears it (entry-clear path).
setResumeFailedSessionId('stored-1')
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
if (method === 'session.resume') {
return { session_id: 'runtime-1', resumed: params?.session_id, messages: [], info: {} } as never
}
return {} as never
})
vi.mocked(getSessionMessages).mockResolvedValue({ messages: [] } as never)
await runResume(requestGateway)
expect($resumeFailedSessionId.get()).toBeNull()
})
})

View File

@ -15,6 +15,10 @@ import { requestDesktopOnboarding } from '@/store/onboarding'
import { $activeGatewayProfile, $newChatProfile, $profiles, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile' import { $activeGatewayProfile, $newChatProfile, $profiles, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
import { import {
$currentCwd, $currentCwd,
$currentFastMode,
$currentModel,
$currentProvider,
$currentReasoningEffort,
$messages, $messages,
$sessions, $sessions,
$yoloActive, $yoloActive,
@ -34,6 +38,8 @@ import {
setFreshDraftReady, setFreshDraftReady,
setIntroSeed, setIntroSeed,
setMessages, setMessages,
setResumeExhaustedSessionId,
setResumeFailedSessionId,
setSelectedStoredSessionId, setSelectedStoredSessionId,
setSessions, setSessions,
setSessionStartedAt, setSessionStartedAt,
@ -407,13 +413,13 @@ export function useSessionActions({
}) })
setSessionStartedAt(null) setSessionStartedAt(null)
setTurnStartedAt(null) setTurnStartedAt(null)
// New chats start in the configured default project dir when set, // The composer's model/effort/fast is sticky UI state (persisted in
// otherwise the sticky last-used workspace (PR #37586). // localStorage) — a new chat FOLLOWS your last pick instead of snapping
setCurrentModel('') // back to the profile default, so we deliberately don't reset it here. The
setCurrentProvider('') // profile default still owns first-run seeding and profile switches (see
setCurrentReasoningEffort('') // refreshCurrentModel). Only $currentServiceTier (a live-session mirror)
// is cleared.
setCurrentServiceTier('') setCurrentServiceTier('')
setCurrentFastMode(false)
setYoloActive(false) setYoloActive(false)
setCurrentCwd(workspaceCwdForNewSession()) setCurrentCwd(workspaceCwdForNewSession())
setCurrentBranch('') setCurrentBranch('')
@ -443,11 +449,23 @@ export function useSessionActions({
const newChatProfile = $newChatProfile.get() ?? normalizeProfileKey($activeGatewayProfile.get()) const newChatProfile = $newChatProfile.get() ?? normalizeProfileKey($activeGatewayProfile.get())
await ensureGatewayProfile(newChatProfile) await ensureGatewayProfile(newChatProfile)
const cwd = $currentCwd.get().trim() || workspaceCwdForNewSession() const cwd = $currentCwd.get().trim() || workspaceCwdForNewSession()
// The composer's model/effort/fast is sticky UI state ($currentModel,
// $currentProvider, $currentReasoningEffort, $currentFastMode). Ship it
// with every session.create so the new chat opens on whatever the picker
// shows — applied as per-session overrides, never written to the profile
// default (that lives in Settings → Model).
const uiModel = $currentModel.get().trim()
const uiProvider = $currentProvider.get().trim()
const uiEffort = $currentReasoningEffort.get().trim()
const uiFast = $currentFastMode.get()
const created = await requestGateway<SessionCreateResponse>('session.create', { const created = await requestGateway<SessionCreateResponse>('session.create', {
cols: 96, cols: 96,
...(cwd && { cwd }), ...(cwd && { cwd }),
...(newChatProfile ? { profile: newChatProfile } : {}) ...(newChatProfile ? { profile: newChatProfile } : {}),
...(uiModel ? { model: uiModel, ...(uiProvider ? { provider: uiProvider } : {}) } : {}),
...(uiEffort ? { reasoning_effort: uiEffort } : {}),
...(uiFast ? { fast: true } : {})
}) })
const stored = created.stored_session_id ?? null const stored = created.stored_session_id ?? null
@ -563,6 +581,15 @@ export function useSessionActions({
clearNotifications() clearNotifications()
setSelectedStoredSessionId(storedSessionId) setSelectedStoredSessionId(storedSessionId)
selectedStoredSessionIdRef.current = storedSessionId selectedStoredSessionIdRef.current = storedSessionId
// Optimistically clear any prior resume-failure latch for this session:
// we're attempting a fresh resume, so the self-heal in use-route-resume
// must not keep treating it as stranded. It's re-armed below only if THIS
// attempt fails terminally (RPC reject + REST fallback failure).
setResumeFailedSessionId(current => (current === storedSessionId ? null : current))
// Also clear the exhausted-latch: a fresh attempt (manual Retry, reconnect,
// reselect) gives the bounded auto-retry counter a clean cycle, so the
// chat view drops the error state and shows the loader again.
setResumeExhaustedSessionId(current => (current === storedSessionId ? null : current))
const warmRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId) const warmRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
@ -753,13 +780,41 @@ export function useSessionActions({
return return
} }
const fallback = await getSessionMessages(storedSessionId, sessionProfile) // The gateway resume RPC failed. Try the REST transcript as a fallback
// so the window at least shows history. CRITICAL: this fallback must be
// wrapped in its own try — if it ALSO throws (wedged/unreachable backend,
// the common case when resume failed in the first place), an unguarded
// throw here skips setMessages AND leaves activeSessionId null with an
// empty transcript. That is the exact state the thread loader latches on
// forever (messagesEmpty && !activeSessionId) with no recovery path —
// the "open in new window stays stuck loading, even after a nap" bug.
try {
const fallback = await getSessionMessages(storedSessionId, sessionProfile)
if (!isCurrentResume()) { if (!isCurrentResume()) {
return return
}
setMessages(preserveLocalAssistantErrors(toChatMessages(fallback.messages), $messages.get()))
} catch {
// Fallback also failed: nothing to paint. Leave whatever messages are
// already shown and fall through to arm the resume-failure latch so
// use-route-resume re-attempts the resume on the next render / window
// focus / gateway reconnect instead of stranding the loader.
}
if (isCurrentResume() && $messages.get().length === 0) {
// Arm the self-heal ONLY when the window is still empty: the gateway
// resume rejected AND the REST fallback failed to paint a transcript.
// That is the exact stranded state the loader latches on
// (messagesEmpty && !activeSessionId), and matches $resumeFailedSessionId's
// documented contract. If the REST fallback DID paint history, the
// window is readable — arming here would needlessly auto-retry and,
// once retries exhaust, blank that visible transcript behind the
// exhausted-state error overlay (a regression vs. plain fallback success).
setResumeFailedSessionId(storedSessionId)
} }
setMessages(preserveLocalAssistantErrors(toChatMessages(fallback.messages), $messages.get()))
notifyError(err, copy.resumeFailed) notifyError(err, copy.resumeFailed)
} finally { } finally {
if (isCurrentResume()) { if (isCurrentResume()) {

View File

@ -2,12 +2,14 @@ import { act, cleanup, render } from '@testing-library/react'
import type { MutableRefObject } from 'react' import type { MutableRefObject } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ChatMessage } from '@/lib/chat-messages'
import { import {
$currentFastMode, $currentFastMode,
$currentModel, $currentModel,
$currentProvider, $currentProvider,
$currentReasoningEffort, $currentReasoningEffort,
$currentServiceTier, $currentServiceTier,
$messages,
$turnStartedAt, $turnStartedAt,
setCurrentFastMode, setCurrentFastMode,
setCurrentModel, setCurrentModel,
@ -213,3 +215,113 @@ describe('useSessionStateCache — per-session turn timer', () => {
expect($currentFastMode.get()).toBe(false) expect($currentFastMode.get()).toBe(false)
}) })
}) })
function userMessage(id: string, text: string): ChatMessage {
return { id, role: 'user', parts: [{ type: 'text', text }] }
}
function assistantText(id: string, text: string): ChatMessage {
return { id, role: 'assistant', parts: [{ type: 'text', text }] }
}
function assistantError(id: string, error: string): ChatMessage {
return { id, role: 'assistant', parts: [], error, pending: false }
}
interface ViewHarnessProps {
activeSessionId: string | null
onReady: (cache: Cache) => void
}
function ViewHarness({ activeSessionId, onReady }: ViewHarnessProps) {
const busyRef: MutableRefObject<boolean> = { current: false }
const cache = useSessionStateCache({
activeSessionId,
busyRef,
selectedStoredSessionId: null,
setAwaitingResponse: () => undefined,
setBusy: () => undefined,
// Wire the published view back into the real $messages atom the flush
// reads from, so the round-trip matches production.
setMessages: messages => $messages.set(messages)
})
onReady(cache)
return null
}
describe('useSessionStateCache — cross-thread error isolation', () => {
afterEach(() => {
cleanup()
$messages.set([])
})
it('does not leak a failed turn into another thread on switch', () => {
$messages.set([])
let cache!: Cache
const { rerender } = render(<ViewHarness activeSessionId="thread-A" onReady={c => (cache = c)} />)
// Thread A ends its turn with an out-of-funds error and is on screen.
act(() => {
cache.updateSessionState(
'thread-A',
state => ({
...state,
busy: false,
messages: [userMessage('user-a', 'do the thing'), assistantError('assistant-a-error', 'Out of funds')]
}),
'stored-A'
)
})
expect($messages.get().some(message => message.error === 'Out of funds')).toBe(true)
// Switch to thread B (which completed cleanly). Its cached state syncs to
// the view while $messages still holds thread A's transcript.
rerender(<ViewHarness activeSessionId="thread-B" onReady={c => (cache = c)} />)
act(() => {
cache.updateSessionState(
'thread-B',
state => ({
...state,
busy: false,
messages: [userMessage('user-b', 'hello'), assistantText('assistant-b', 'hi there')]
}),
'stored-B'
)
})
expect($messages.get().map(message => message.id)).toEqual(['user-b', 'assistant-b'])
expect($messages.get().some(message => message.error === 'Out of funds')).toBe(false)
})
it('still preserves a same-session local error a heartbeat dropped', () => {
$messages.set([])
let cache!: Cache
render(<ViewHarness activeSessionId="thread-A" onReady={c => (cache = c)} />)
// First paint establishes thread A as the on-screen session.
act(() => {
cache.updateSessionState(
'thread-A',
state => ({ ...state, busy: false, messages: [userMessage('user-a', 'do the thing')] }),
'stored-A'
)
})
// A local error lands in the view (e.g. failAssistantMessage wrote it).
$messages.set([userMessage('user-a', 'do the thing'), assistantError('assistant-a-error', 'OpenRouter 403')])
// A later same-session heartbeat carries cached state that lost the error.
act(() => {
cache.updateSessionState('thread-A', state => ({
...state,
busy: false,
messages: [userMessage('user-a', 'do the thing')]
}))
})
expect($messages.get().some(message => message.error === 'OpenRouter 403')).toBe(true)
})
})

View File

@ -79,6 +79,9 @@ export function useSessionStateCache({
const runtimeIdByStoredSessionIdRef = useRef(new Map<string, string>()) const runtimeIdByStoredSessionIdRef = useRef(new Map<string, string>())
const pendingViewStateRef = useRef<{ sessionId: string; state: ClientSessionState } | null>(null) const pendingViewStateRef = useRef<{ sessionId: string; state: ClientSessionState } | null>(null)
const viewSyncRafRef = useRef<number | null>(null) const viewSyncRafRef = useRef<number | null>(null)
// Runtime id whose transcript currently occupies `$messages` — lets the
// flush below tell a same-session refresh from a thread switch.
const viewSessionIdRef = useRef<string | null>(null)
useEffect(() => { useEffect(() => {
activeSessionIdRef.current = activeSessionId activeSessionIdRef.current = activeSessionId
@ -142,12 +145,22 @@ export function useSessionStateCache({
// jerks the scroll position while the user is reading. Skip the publish when // jerks the scroll position while the user is reading. Skip the publish when
// the merged result is content-identical to what's already on screen. // the merged result is content-identical to what's already on screen.
const currentMessages = $messages.get() const currentMessages = $messages.get()
const nextMessages = preserveLocalAssistantErrors(pending.state.messages, currentMessages) // On a thread switch `$messages` still holds the *previous* thread, so
// preserving its local errors would graft that thread's failed turn (e.g.
// an out-of-funds error) onto this one — then cascade it everywhere as the
// polluted view becomes the next switch's baseline. Only carry errors
// across a same-session refresh; our cached state already keeps its own.
const nextMessages =
viewSessionIdRef.current === pending.sessionId
? preserveLocalAssistantErrors(pending.state.messages, currentMessages)
: pending.state.messages
if (!sameMessageList(nextMessages, currentMessages)) { if (!sameMessageList(nextMessages, currentMessages)) {
setMessages(nextMessages) setMessages(nextMessages)
} }
viewSessionIdRef.current = pending.sessionId
syncRuntimeMetadataToView(pending.state) syncRuntimeMetadataToView(pending.state)
setBusy(pending.state.busy) setBusy(pending.state.busy)
setMutableRef(busyRef, pending.state.busy) setMutableRef(busyRef, pending.state.busy)

View File

@ -228,7 +228,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
onMainModelChanged={onMainModelChanged} onMainModelChanged={onMainModelChanged}
/> />
) : activeView === 'providers' ? ( ) : activeView === 'providers' ? (
<ProvidersSettings onViewChange={setProviderView} view={providerView} /> <ProvidersSettings onClose={onClose} onViewChange={setProviderView} view={providerView} />
) : activeView === 'keys' ? ( ) : activeView === 'keys' ? (
<KeysSettings view={keysView} /> <KeysSettings view={keysView} />
) : activeView === 'mcp' ? ( ) : activeView === 'mcp' ? (

View File

@ -16,6 +16,8 @@ const getAuxiliaryModels = vi.fn()
const setModelAssignment = vi.fn() const setModelAssignment = vi.fn()
const getRecommendedDefaultModel = vi.fn() const getRecommendedDefaultModel = vi.fn()
const setEnvVar = vi.fn() const setEnvVar = vi.fn()
const getHermesConfigRecord = vi.fn()
const saveHermesConfig = vi.fn()
const startManualProviderOAuth = vi.fn() const startManualProviderOAuth = vi.fn()
vi.mock('@/hermes', () => ({ vi.mock('@/hermes', () => ({
@ -24,7 +26,9 @@ vi.mock('@/hermes', () => ({
getAuxiliaryModels: () => getAuxiliaryModels(), getAuxiliaryModels: () => getAuxiliaryModels(),
setModelAssignment: (body: unknown) => setModelAssignment(body), setModelAssignment: (body: unknown) => setModelAssignment(body),
getRecommendedDefaultModel: (slug: string) => getRecommendedDefaultModel(slug), getRecommendedDefaultModel: (slug: string) => getRecommendedDefaultModel(slug),
setEnvVar: (key: string, value: string) => setEnvVar(key, value) setEnvVar: (key: string, value: string) => setEnvVar(key, value),
getHermesConfigRecord: () => getHermesConfigRecord(),
saveHermesConfig: (config: unknown) => saveHermesConfig(config)
})) }))
vi.mock('@/store/onboarding', () => ({ vi.mock('@/store/onboarding', () => ({
@ -35,7 +39,13 @@ beforeEach(() => {
getGlobalModelInfo.mockResolvedValue({ provider: 'nous', model: 'hermes-4' }) getGlobalModelInfo.mockResolvedValue({ provider: 'nous', model: 'hermes-4' })
getGlobalModelOptions.mockResolvedValue({ getGlobalModelOptions.mockResolvedValue({
providers: [ providers: [
{ name: 'Nous', slug: 'nous', models: ['hermes-4', 'hermes-4-mini'], authenticated: true }, {
name: 'Nous',
slug: 'nous',
models: ['hermes-4', 'hermes-4-mini'],
authenticated: true,
capabilities: { 'hermes-4': { reasoning: true, fast: true } }
},
// An unconfigured api_key provider — surfaced by the full-universe payload. // An unconfigured api_key provider — surfaced by the full-universe payload.
{ name: 'DeepSeek', slug: 'deepseek', models: [], authenticated: false, auth_type: 'api_key', key_env: 'DEEPSEEK_API_KEY' } { name: 'DeepSeek', slug: 'deepseek', models: [], authenticated: false, auth_type: 'api_key', key_env: 'DEEPSEEK_API_KEY' }
] ]
@ -47,6 +57,8 @@ beforeEach(() => {
setModelAssignment.mockResolvedValue({ provider: 'nous', model: 'hermes-4', gateway_tools: [] }) setModelAssignment.mockResolvedValue({ provider: 'nous', model: 'hermes-4', gateway_tools: [] })
getRecommendedDefaultModel.mockResolvedValue({ provider: 'deepseek', model: 'deepseek-chat', free_tier: null }) getRecommendedDefaultModel.mockResolvedValue({ provider: 'deepseek', model: 'deepseek-chat', free_tier: null })
setEnvVar.mockResolvedValue({ ok: true }) setEnvVar.mockResolvedValue({ ok: true })
getHermesConfigRecord.mockResolvedValue({ agent: { reasoning_effort: 'medium', service_tier: 'normal' } })
saveHermesConfig.mockResolvedValue({ ok: true })
}) })
afterEach(() => { afterEach(() => {
@ -100,6 +112,31 @@ describe('ModelSettings', () => {
await waitFor(() => expect(setEnvVar).toHaveBeenCalledWith('DEEPSEEK_API_KEY', 'sk-test-123')) await waitFor(() => expect(setEnvVar).toHaveBeenCalledWith('DEEPSEEK_API_KEY', 'sk-test-123'))
}) })
it('writes the profile default speed (service_tier) when the fast switch is toggled', async () => {
await renderModelSettings()
await waitFor(() => expect(getHermesConfigRecord).toHaveBeenCalled())
const fastSwitch = await screen.findByRole('switch')
fireEvent.click(fastSwitch)
await waitFor(() =>
expect(saveHermesConfig).toHaveBeenCalledWith(
expect.objectContaining({ agent: expect.objectContaining({ service_tier: 'fast' }) })
)
)
})
it('hides the reasoning/speed defaults when the main model reports no capabilities', async () => {
getGlobalModelOptions.mockResolvedValueOnce({
providers: [{ name: 'Nous', slug: 'nous', models: ['hermes-4'], authenticated: true, capabilities: { 'hermes-4': { reasoning: false, fast: false } } }]
})
await renderModelSettings()
await waitFor(() => expect(getHermesConfigRecord).toHaveBeenCalled())
expect(screen.queryByRole('switch')).toBeNull()
})
it('renders the auxiliary task rows', async () => { it('renders the auxiliary task rows', async () => {
await renderModelSettings() await renderModelSettings()

View File

@ -3,11 +3,14 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { import {
getAuxiliaryModels, getAuxiliaryModels,
getGlobalModelInfo, getGlobalModelInfo,
getGlobalModelOptions, getGlobalModelOptions,
getHermesConfigRecord,
getRecommendedDefaultModel, getRecommendedDefaultModel,
saveHermesConfig,
setEnvVar, setEnvVar,
setModelAssignment setModelAssignment
} from '@/hermes' } from '@/hermes'
@ -15,11 +18,26 @@ import type { AuxiliaryModelsResponse, ModelOptionProvider, StaleAuxAssignment }
import { useI18n } from '@/i18n' import { useI18n } from '@/i18n'
import { AlertTriangle, Cpu, Loader2 } from '@/lib/icons' import { AlertTriangle, Cpu, Loader2 } from '@/lib/icons'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
import { startManualLocalEndpoint, startManualProviderOAuth } from '@/store/onboarding' import { startManualLocalEndpoint, startManualProviderOAuth } from '@/store/onboarding'
import type { HermesConfigRecord } from '@/types/hermes'
import { CONTROL_TEXT } from './constants' import { CONTROL_TEXT } from './constants'
import { getNested, setNested } from './helpers'
import { ListRow, LoadingState, Pill, SectionHeading } from './primitives' import { ListRow, LoadingState, Pill, SectionHeading } from './primitives'
// Hermes' reasoning levels (VALID_REASONING_EFFORTS); `none` = thinking off.
// Empty config = Hermes default (medium), shown as Medium.
const EFFORT_VALUES = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh'] as const
// agent.service_tier stores "fast"/"priority"/"on" for fast; anything else is
// normal (mirrors tui_gateway _load_service_tier).
const isFastTier = (tier: unknown): boolean =>
['fast', 'priority', 'on'].includes(String(tier ?? '').trim().toLowerCase())
// Reuse the composer's effort labels (`xhigh` shows as "Max", else 1:1).
const effortLabelKey = (v: string) => (v === 'xhigh' ? 'max' : v) as 'high' | 'low' | 'max' | 'medium' | 'minimal'
// A provider row is "ready" to pick a model from when it reports models. The // A provider row is "ready" to pick a model from when it reports models. The
// backend now surfaces the full `hermes model` universe (every canonical // backend now surfaces the full `hermes model` universe (every canonical
// provider), so unconfigured providers come back with `authenticated:false` // provider), so unconfigured providers come back with `authenticated:false`
@ -97,6 +115,9 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
const [selectedProvider, setSelectedProvider] = useState('') const [selectedProvider, setSelectedProvider] = useState('')
const [selectedModel, setSelectedModel] = useState('') const [selectedModel, setSelectedModel] = useState('')
const [auxiliary, setAuxiliary] = useState<AuxiliaryModelsResponse | null>(null) const [auxiliary, setAuxiliary] = useState<AuxiliaryModelsResponse | null>(null)
// Full profile config, kept so the reasoning/speed defaults round-trip
// (read agent.* → write back the whole record) like the generic config page.
const [config, setConfig] = useState<HermesConfigRecord | null>(null)
const [applying, setApplying] = useState(false) const [applying, setApplying] = useState(false)
const [editingAuxTask, setEditingAuxTask] = useState<null | string>(null) const [editingAuxTask, setEditingAuxTask] = useState<null | string>(null)
const [auxDraft, setAuxDraft] = useState<{ model: string; provider: string }>({ model: '', provider: '' }) const [auxDraft, setAuxDraft] = useState<{ model: string; provider: string }>({ model: '', provider: '' })
@ -113,10 +134,11 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
setError('') setError('')
try { try {
const [modelInfo, modelOptions, auxiliaryModels] = await Promise.all([ const [modelInfo, modelOptions, auxiliaryModels, cfg] = await Promise.all([
getGlobalModelInfo(), getGlobalModelInfo(),
getGlobalModelOptions(), getGlobalModelOptions(),
getAuxiliaryModels() getAuxiliaryModels(),
getHermesConfigRecord()
]) ])
setMainModel({ model: modelInfo.model, provider: modelInfo.provider }) setMainModel({ model: modelInfo.model, provider: modelInfo.provider })
@ -124,6 +146,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
setSelectedProvider(prev => prev || modelInfo.provider) setSelectedProvider(prev => prev || modelInfo.provider)
setSelectedModel(prev => prev || modelInfo.model) setSelectedModel(prev => prev || modelInfo.model)
setAuxiliary(auxiliaryModels) setAuxiliary(auxiliaryModels)
setConfig(cfg)
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : String(err)) setError(err instanceof Error ? err.message : String(err))
} finally { } finally {
@ -181,6 +204,42 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
.map(entry => ({ task: entry.task, provider: entry.provider, model: entry.model })) .map(entry => ({ task: entry.task, provider: entry.provider, model: entry.model }))
}, [auxiliary, mainModel]) }, [auxiliary, mainModel])
// Capabilities of the APPLIED main model — gates the profile-default
// reasoning/speed controls the same way the composer picker gates per-model
// edits (reasoning defaults on, fast defaults off when unreported).
const mainCaps = useMemo(() => {
const row = providers.find(provider => provider.slug === mainModel?.provider)
return mainModel ? row?.capabilities?.[mainModel.model] : undefined
}, [providers, mainModel])
const reasoningSupported = mainCaps?.reasoning ?? true
const fastSupported = mainCaps?.fast ?? false
const effortValue = String(getNested(config ?? {}, 'agent.reasoning_effort') ?? '').trim().toLowerCase() || 'medium'
const fastOn = isFastTier(getNested(config ?? {}, 'agent.service_tier'))
// Persist a single agent.* default by round-tripping the whole config record
// (PUT /api/config replaces it) — optimistic, with rollback on failure.
const writeAgentDefault = useCallback(
async (key: string, value: string) => {
if (!config) {
return
}
const prev = config
const next = setNested(config, key, value)
setConfig(next)
try {
await saveHermesConfig(next)
} catch (err) {
setConfig(prev)
notifyError(err, m.defaultsFailed)
}
},
[config, m.defaultsFailed]
)
// Paste an API key for the selected `api_key` provider, persist it, then // Paste an API key for the selected `api_key` provider, persist it, then
// refresh so the now-authenticated provider's models populate. Auto-selects // refresh so the now-authenticated provider's models populate. Auto-selects
// the recommended default model so the user can Apply in one more click. // the recommended default model so the user can Apply in one more click.
@ -433,6 +492,38 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
: `${selectedProviderRow?.name} signs in through your browser — Hermes runs the flow for you.`} : `${selectedProviderRow?.name} signs in through your browser — Hermes runs the flow for you.`}
</p> </p>
)} )}
{config && mainModel && (reasoningSupported || fastSupported) && (
<div className="mt-3 flex flex-wrap items-center gap-x-6 gap-y-3">
<span className="text-xs text-muted-foreground">{m.defaultsLabel}</span>
{reasoningSupported && (
<div className="flex items-center gap-2 text-xs">
{m.reasoning}
<Select onValueChange={value => void writeAgentDefault('agent.reasoning_effort', value)} value={effortValue}>
<SelectTrigger className={cn('min-w-28', CONTROL_TEXT)}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{EFFORT_VALUES.map(value => (
<SelectItem key={value} value={value}>
{value === 'none' ? m.reasoningOff : t.shell.modelOptions[effortLabelKey(value)]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{fastSupported && (
<label className="flex items-center gap-2 text-xs">
{t.shell.modelOptions.fast}
<Switch
checked={fastOn}
onCheckedChange={checked => void writeAgentDefault('agent.service_tier', checked ? 'fast' : 'normal')}
size="xs"
/>
</label>
)}
</div>
)}
{error && <div className="mt-2 text-xs text-destructive">{error}</div>} {error && <div className="mt-2 text-xs text-destructive">{error}</div>}
{switchStaleAux.length > 0 && ( {switchStaleAux.length > 0 && (
<div className="mt-2"> <div className="mt-2">

View File

@ -55,7 +55,7 @@ afterEach(() => {
async function renderProvidersSettings() { async function renderProvidersSettings() {
const { ProvidersSettings } = await import('./providers-settings') const { ProvidersSettings } = await import('./providers-settings')
return render(<ProvidersSettings onViewChange={vi.fn()} view="accounts" />) return render(<ProvidersSettings onClose={vi.fn()} onViewChange={vi.fn()} view="accounts" />)
} }
describe('ProvidersSettings', () => { describe('ProvidersSettings', () => {
@ -95,6 +95,6 @@ describe('ProvidersSettings', () => {
expect(await screen.findByText('Qwen Code')).toBeTruthy() expect(await screen.findByText('Qwen Code')).toBeTruthy()
expect(screen.queryByRole('button', { name: 'Remove Qwen Code' })).toBeNull() expect(screen.queryByRole('button', { name: 'Remove Qwen Code' })).toBeNull()
expect(screen.getByText(/managed outside Hermes/)).toBeTruthy() expect(screen.getByText(/managed by its own CLI/)).toBeTruthy()
}) })
}) })

View File

@ -1,6 +1,8 @@
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
import type { ReactNode } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { runInTerminal } from '@/app/right-sidebar/store'
import { import {
FEATURED_ID, FEATURED_ID,
FeaturedProviderRow, FeaturedProviderRow,
@ -23,6 +25,20 @@ import { SettingsCategoryHeading, useEnvCredentials } from './env-credentials'
import { providerGroup, providerMeta, providerPriority } from './helpers' import { providerGroup, providerMeta, providerPriority } from './helpers'
import { LoadingState, SettingsContent } from './primitives' import { LoadingState, SettingsContent } from './primitives'
// The embedded terminal (and thus the "run disconnect command" path) only
// exists in the Electron desktop shell, not the web dashboard.
const canRunInTerminal = () => typeof window !== 'undefined' && Boolean(window.hermesDesktop?.terminal)
// Parallel group headers ("Connected", "Other providers") so the expanded list
// reads as its own section instead of bleeding into the connected group.
function GroupLabel({ children }: { children: ReactNode }) {
return (
<p className="mt-3 px-0.5 text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-tertiary)">
{children}
</p>
)
}
// Sub-views surfaced as a sidebar subnav: account sign-in vs raw API keys. // Sub-views surfaced as a sidebar subnav: account sign-in vs raw API keys.
export const PROVIDER_VIEWS = ['accounts', 'keys'] as const export const PROVIDER_VIEWS = ['accounts', 'keys'] as const
@ -90,11 +106,13 @@ function buildProviderKeyGroups(vars: Record<string, EnvVarInfo>): ProviderKeyGr
function OAuthPicker({ function OAuthPicker({
disconnecting, disconnecting,
onDisconnect, onDisconnect,
onTerminalDisconnect,
onWantApiKey, onWantApiKey,
providers providers
}: { }: {
disconnecting: null | string disconnecting: null | string
onDisconnect: (provider: OAuthProvider) => void onDisconnect: (provider: OAuthProvider) => void
onTerminalDisconnect: (provider: OAuthProvider) => void
onWantApiKey: () => void onWantApiKey: () => void
providers: OAuthProvider[] providers: OAuthProvider[]
}) { }) {
@ -138,15 +156,14 @@ function OAuthPicker({
{featured && <FeaturedProviderRow onSelect={select} provider={featured} />} {featured && <FeaturedProviderRow onSelect={select} provider={featured} />}
{connected.length > 0 && ( {connected.length > 0 && (
<> <>
<p className="mt-1 px-0.5 text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-tertiary)"> <GroupLabel>{p.connected}</GroupLabel>
{p.connected}
</p>
{connected.map(p => ( {connected.map(p => (
<ConnectedProviderRow <ConnectedProviderRow
disconnecting={disconnecting === p.id} disconnecting={disconnecting === p.id}
key={p.id} key={p.id}
onDisconnect={onDisconnect} onDisconnect={onDisconnect}
onSelect={select} onSelect={select}
onTerminalDisconnect={onTerminalDisconnect}
provider={p} provider={p}
/> />
))} ))}
@ -154,6 +171,7 @@ function OAuthPicker({
)} )}
{showOthers && ( {showOthers && (
<> <>
{connected.length > 0 && <GroupLabel>{p.otherProviders}</GroupLabel>}
{others.map(p => ( {others.map(p => (
<ProviderRow key={p.id} onSelect={select} provider={p} /> <ProviderRow key={p.id} onSelect={select} provider={p} />
))} ))}
@ -180,21 +198,26 @@ function ConnectedProviderRow({
disconnecting, disconnecting,
onDisconnect, onDisconnect,
onSelect, onSelect,
onTerminalDisconnect,
provider provider
}: { }: {
disconnecting: boolean disconnecting: boolean
onDisconnect: (provider: OAuthProvider) => void onDisconnect: (provider: OAuthProvider) => void
onSelect: (provider: OAuthProvider) => void onSelect: (provider: OAuthProvider) => void
onTerminalDisconnect: (provider: OAuthProvider) => void
provider: OAuthProvider provider: OAuthProvider
}) { }) {
const { t } = useI18n() const { t } = useI18n()
const copy = t.settings.providers
const title = providerTitle(provider) const title = providerTitle(provider)
const Trail = provider.flow === 'external' ? Terminal : ChevronRight const Trail = provider.flow === 'external' ? Terminal : ChevronRight
// Hermes can clear this provider's creds via the API.
const canDisconnect = provider.disconnectable ?? provider.flow !== 'external' const canDisconnect = provider.disconnectable ?? provider.flow !== 'external'
// External (CLI-managed) provider Hermes can't clear via the API, but ships a
const disconnectHint = provider.flow === 'external' // command we can run in the embedded terminal (Electron shell only).
? t.settings.providers.removeExternal(title, provider.cli_command) const terminalDisconnect = !canDisconnect && Boolean(provider.disconnect_command) && canRunInTerminal()
: t.settings.providers.removeKeyManaged(title) // Only fall back to a static "remove it elsewhere" hint when we offer no button.
const showHint = !canDisconnect && !terminalDisconnect
return ( return (
<div className="group grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1 rounded-[6px] transition-colors hover:bg-(--ui-control-hover-background)"> <div className="group grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1 rounded-[6px] transition-colors hover:bg-(--ui-control-hover-background)">
@ -203,13 +226,13 @@ function ConnectedProviderRow({
<span className="truncate text-[length:var(--conversation-text-font-size)] font-semibold">{title}</span> <span className="truncate text-[length:var(--conversation-text-font-size)] font-semibold">{title}</span>
<span className="inline-flex shrink-0 items-center gap-1 bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"> <span className="inline-flex shrink-0 items-center gap-1 bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
<Check className="size-3" /> <Check className="size-3" />
{t.settings.providers.connected} {copy.connected}
</span> </span>
</div> </div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">{t.onboarding.flowSubtitles[provider.flow]}</p> <p className="mt-1 text-xs leading-5 text-muted-foreground">{t.onboarding.flowSubtitles[provider.flow]}</p>
{!canDisconnect && ( {showHint && (
<p className="mt-0.5 truncate text-[0.68rem] leading-5 text-muted-foreground/70"> <p className="mt-0.5 truncate text-[0.68rem] leading-5 text-muted-foreground/70">
{disconnectHint} {provider.flow === 'external' ? copy.removeExternalGeneric(title) : copy.removeKeyManaged(title)}
</p> </p>
)} )}
</button> </button>
@ -228,6 +251,18 @@ function ConnectedProviderRow({
{disconnecting ? <Loader2 className="size-3 animate-spin" /> : <Trash2 className="size-3" />} {disconnecting ? <Loader2 className="size-3 animate-spin" /> : <Trash2 className="size-3" />}
</Button> </Button>
)} )}
{terminalDisconnect && (
<Button
aria-label={`${copy.disconnect} ${title}`}
onClick={() => onTerminalDisconnect(provider)}
size="icon-xs"
title={copy.disconnectInTerminal}
type="button"
variant="ghost"
>
<Trash2 className="size-3" />
</Button>
)}
</div> </div>
</div> </div>
) )
@ -243,7 +278,7 @@ function NoProviderKeys() {
) )
} }
export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps) { export function ProvidersSettings({ onClose, onViewChange, view }: ProvidersSettingsProps) {
const { t } = useI18n() const { t } = useI18n()
const { rowProps, vars } = useEnvCredentials() const { rowProps, vars } = useEnvCredentials()
const [oauthProviders, setOauthProviders] = useState<OAuthProvider[]>([]) const [oauthProviders, setOauthProviders] = useState<OAuthProvider[]>([])
@ -282,6 +317,29 @@ export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps
return () => void (cancelled = true) return () => void (cancelled = true)
}, [onboardingActive]) }, [onboardingActive])
// External (CLI-managed) providers can't be cleared via the API by design —
// Hermes never deletes creds another tool owns behind a silent API call.
// Instead we run the documented removal command in the embedded terminal so
// the user sees exactly what executes, then return them to chat to watch it.
function handleTerminalDisconnect(provider: OAuthProvider) {
const command = provider.disconnect_command
if (!command) {
return
}
const name = providerTitle(provider)
if (!window.confirm(t.settings.providers.removeTerminalConfirm(name, command))) {
return
}
// Leave the settings overlay so the terminal pane (chat-only) is visible.
onClose()
runInTerminal(command)
notify({ kind: 'info', title: t.settings.providers.removedTitle, message: t.settings.providers.removeTerminalRunning(name) })
}
async function handleDisconnect(provider: OAuthProvider) { async function handleDisconnect(provider: OAuthProvider) {
const name = providerTitle(provider) const name = providerTitle(provider)
@ -341,6 +399,7 @@ export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps
<OAuthPicker <OAuthPicker
disconnecting={disconnecting} disconnecting={disconnecting}
onDisconnect={provider => void handleDisconnect(provider)} onDisconnect={provider => void handleDisconnect(provider)}
onTerminalDisconnect={handleTerminalDisconnect}
onWantApiKey={() => onViewChange('keys')} onWantApiKey={() => onViewChange('keys')}
providers={oauthProviders} providers={oauthProviders}
/> />
@ -359,6 +418,7 @@ interface ProviderKeyGroup {
} }
interface ProvidersSettingsProps { interface ProvidersSettingsProps {
onClose: () => void
onViewChange: (view: ProviderView) => void onViewChange: (view: ProviderView) => void
view: ProviderView view: ProviderView
} }

View File

@ -16,7 +16,7 @@ import {
} from '@/store/layout' } from '@/store/layout'
import { $paneWidthOverride } from '@/store/panes' import { $paneWidthOverride } from '@/store/panes'
import { $connection } from '@/store/session' import { $connection } from '@/store/session'
import { isNewSessionWindow, isSecondaryWindow } from '@/store/windows' import { isSecondaryWindow } from '@/store/windows'
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '../layout-constants' import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '../layout-constants'
@ -80,7 +80,10 @@ export function AppShell({
const connection = useStore($connection) const connection = useStore($connection)
const viewportFullscreen = useSyncExternalStore(subscribeWindowSize, viewportIsFullscreen, () => false) const viewportFullscreen = useSyncExternalStore(subscribeWindowSize, viewportIsFullscreen, () => false)
const isFullscreen = Boolean(connection?.isFullscreen) || viewportFullscreen const isFullscreen = Boolean(connection?.isFullscreen) || viewportFullscreen
const hideTitlebarControls = isNewSessionWindow() // Every secondary window (new-session scratch, subagent watch, cmd-click
// pop-out) is a compact side panel — none of them carry the full titlebar
// tool cluster. Gate on isSecondaryWindow, never the narrower new-session flag.
const hideTitlebarControls = isSecondaryWindow()
const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition, isFullscreen) const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition, isFullscreen)
// Width Windows/Linux reserve for the OS-painted min/max/close overlay (zero // Width Windows/Linux reserve for the OS-painted min/max/close overlay (zero
// on macOS, where window controls sit on the left and are reported via // on macOS, where window controls sit on the left and are reported via

View File

@ -1,5 +1,4 @@
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
import type { ReactNode } from 'react'
import { useCallback, useMemo } from 'react' import { useCallback, useMemo } from 'react'
import type { CommandCenterSection } from '@/app/command-center' import type { CommandCenterSection } from '@/app/command-center'
@ -9,7 +8,6 @@ import { useI18n } from '@/i18n'
import { import {
Activity, Activity,
AlertCircle, AlertCircle,
ChevronDown,
Clock, Clock,
Command, Command,
Hash, Hash,
@ -19,7 +17,6 @@ import {
Zap, Zap,
ZapFilled ZapFilled
} from '@/lib/icons' } from '@/lib/icons'
import { formatModelStatusLabel } from '@/lib/model-status-label'
import type { RuntimeReadinessResult } from '@/lib/runtime-readiness' import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar' import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@ -30,16 +27,11 @@ import {
$activeSessionId, $activeSessionId,
$busy, $busy,
$connection, $connection,
$currentFastMode,
$currentModel,
$currentProvider,
$currentReasoningEffort,
$currentUsage, $currentUsage,
$sessionStartedAt, $sessionStartedAt,
$turnStartedAt, $turnStartedAt,
$workingSessionIds, $workingSessionIds,
$yoloActive, $yoloActive,
setModelPickerOpen,
setYoloActive setYoloActive
} from '@/store/session' } from '@/store/session'
import { $subagentsBySession, activeSubagentCount } from '@/store/subagents' import { $subagentsBySession, activeSubagentCount } from '@/store/subagents'
@ -65,7 +57,6 @@ interface StatusbarItemsOptions {
gatewayLogLines: readonly string[] gatewayLogLines: readonly string[]
gatewayState: string gatewayState: string
inferenceStatus: RuntimeReadinessResult | null inferenceStatus: RuntimeReadinessResult | null
modelMenuContent?: ReactNode
openAgents: () => void openAgents: () => void
openCommandCenterSection: (section: CommandCenterSection) => void openCommandCenterSection: (section: CommandCenterSection) => void
freshDraftReady: boolean freshDraftReady: boolean
@ -83,7 +74,6 @@ export function useStatusbarItems({
gatewayLogLines, gatewayLogLines,
gatewayState, gatewayState,
inferenceStatus, inferenceStatus,
modelMenuContent,
openAgents, openAgents,
openCommandCenterSection, openCommandCenterSection,
freshDraftReady, freshDraftReady,
@ -97,10 +87,6 @@ export function useStatusbarItems({
const terminalTakeover = useStore($terminalTakeover) const terminalTakeover = useStore($terminalTakeover)
const yoloActive = useStore($yoloActive) const yoloActive = useStore($yoloActive)
const busy = useStore($busy) const busy = useStore($busy)
const currentFastMode = useStore($currentFastMode)
const currentModel = useStore($currentModel)
const currentProvider = useStore($currentProvider)
const currentReasoningEffort = useStore($currentReasoningEffort)
const currentUsage = useStore($currentUsage) const currentUsage = useStore($currentUsage)
const desktopActionTasks = useStore($desktopActionTasks) const desktopActionTasks = useStore($desktopActionTasks)
const previewServerRestartStatus = useStore($previewServerRestartStatus) const previewServerRestartStatus = useStore($previewServerRestartStatus)
@ -416,37 +402,6 @@ export function useStatusbarItems({
title: yoloActive ? copy.yoloOn : copy.yoloOff, title: yoloActive ? copy.yoloOn : copy.yoloOff,
variant: 'action' variant: 'action'
}, },
{
id: 'model-summary',
label: (
<span className="inline-flex min-w-0 items-center gap-0.5">
<span className="truncate">
{formatModelStatusLabel(currentModel, {
fastMode: currentFastMode,
reasoningEffort: currentReasoningEffort
})}
</span>
<ChevronDown className="size-2.5 shrink-0 opacity-50" />
</span>
),
...(modelMenuContent
? {
menuAlign: 'end' as const,
menuClassName: 'w-64',
menuContent: modelMenuContent,
title: currentProvider
? copy.modelTitle(currentProvider, currentModel || copy.modelNone)
: copy.switchModel,
variant: 'menu' as const
}
: {
onSelect: () => setModelPickerOpen(true),
title: currentProvider
? copy.providerModelTitle(currentProvider, currentModel || copy.noModel)
: copy.openModelPicker,
variant: 'action' as const
})
},
{ {
className: `w-7 justify-center px-0${terminalTakeover ? ' bg-accent/55 text-foreground' : ''}`, className: `w-7 justify-center px-0${terminalTakeover ? ' bg-accent/55 text-foreground' : ''}`,
hidden: !chatOpen, hidden: !chatOpen,
@ -465,11 +420,6 @@ export function useStatusbarItems({
contextBar, contextBar,
contextUsage, contextUsage,
copy, copy,
currentFastMode,
currentModel,
currentProvider,
currentReasoningEffort,
modelMenuContent,
sessionStartedAt, sessionStartedAt,
showYoloToggle, showYoloToggle,
terminalTakeover, terminalTakeover,

View File

@ -0,0 +1,84 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { DropdownMenu, DropdownMenuContent, DropdownMenuSub, DropdownMenuSubTrigger } from '@/components/ui/dropdown-menu'
import { $modelPresets, getModelPreset } from '@/store/model-presets'
import { $activeSessionId } from '@/store/session'
import { type FastControl, ModelEditSubmenu } from './model-edit-submenu'
// Radix calls these on open; jsdom doesn't implement them.
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn()
Element.prototype.hasPointerCapture = vi.fn(() => false)
Element.prototype.releasePointerCapture = vi.fn()
})
beforeEach(() => {
$modelPresets.set({})
$activeSessionId.set(null)
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
// Render the submenu inside an open menu/sub so its content (switches) mounts.
function renderSubmenu(opts: { fastControl: FastControl; reasoning: boolean; requestGateway: () => Promise<unknown> }) {
return render(
<DropdownMenu open>
<DropdownMenuContent>
<DropdownMenuSub open>
<DropdownMenuSubTrigger>edit</DropdownMenuSubTrigger>
<ModelEditSubmenu
effort="medium"
fastControl={opts.fastControl}
isActive
model="m1"
onSelectModel={vi.fn()}
provider="p1"
reasoning={opts.reasoning}
requestGateway={opts.requestGateway as never}
/>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
)
}
// Regression: editing the active row before a live session exists must stay
// preset-only — the gateway's config.set falls back to global config when no
// session matches, so it must not be called. (Caught in the second review.)
describe('ModelEditSubmenu no-session guard', () => {
it('param fast: records the preset but skips the gateway without a session', () => {
const requestGateway = vi.fn().mockResolvedValue({})
renderSubmenu({ fastControl: { kind: 'param', on: false }, reasoning: false, requestGateway })
fireEvent.click(screen.getByRole('switch'))
expect(getModelPreset('p1', 'm1').fast).toBe(true)
expect(requestGateway).not.toHaveBeenCalled()
})
it('reasoning: records the preset but skips the gateway without a session', () => {
const requestGateway = vi.fn().mockResolvedValue({})
renderSubmenu({ fastControl: { kind: 'none' }, reasoning: true, requestGateway })
// Thinking starts on (medium); toggling it off routes through patchReasoning.
fireEvent.click(screen.getByRole('switch'))
expect(getModelPreset('p1', 'm1').effort).toBe('none')
expect(requestGateway).not.toHaveBeenCalled()
})
it('param fast: pushes to the gateway once a session is active', async () => {
const requestGateway = vi.fn().mockResolvedValue({})
$activeSessionId.set('sess1')
renderSubmenu({ fastControl: { kind: 'param', on: false }, reasoning: false, requestGateway })
fireEvent.click(screen.getByRole('switch'))
expect(requestGateway).toHaveBeenCalledWith('config.set', { key: 'fast', session_id: 'sess1', value: 'fast' })
})
})

View File

@ -12,13 +12,9 @@ import {
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { useI18n } from '@/i18n' import { useI18n } from '@/i18n'
import { setModelPreset } from '@/store/model-presets'
import { notifyError } from '@/store/notifications' import { notifyError } from '@/store/notifications'
import { import { $activeSessionId, setCurrentFastMode, setCurrentReasoningEffort } from '@/store/session'
$activeSessionId,
$currentReasoningEffort,
setCurrentFastMode,
setCurrentReasoningEffort
} from '@/store/session'
// Hermes' real reasoning levels (see VALID_REASONING_EFFORTS); `none` is owned // Hermes' real reasoning levels (see VALID_REASONING_EFFORTS); `none` is owned
// by the Thinking toggle, not the radio. // by the Thinking toggle, not the radio.
@ -76,96 +72,104 @@ export function resolveFastControl(
} }
interface ModelEditSubmenuProps { interface ModelEditSubmenuProps {
/** This row's effective reasoning effort (live for the active model, else its
* preset) the submenu shows and edits from this, never the raw session. */
effort: string
/** How fast mode is offered for this model (param toggle vs. variant swap). */ /** How fast mode is offered for this model (param toggle vs. variant swap). */
fastControl: FastControl fastControl: FastControl
/** Whether this row's model is the active one. */ /** Whether this row's model is the active one. */
isActive: boolean isActive: boolean
/** Switch to this model (resolves false on failure). Awaited before applying /** This row's model id — edits persist as its global preset. */
* edits when not active so a failed switch doesn't write to the old model. */ model: string
onActivate: () => Promise<boolean> | void
/** Switch to a specific model id (used to swap base ⇄ -fast variant). */ /** Switch to a specific model id (used to swap base ⇄ -fast variant). */
onSelectModel: (model: string) => Promise<boolean> | void onSelectModel: (model: string) => Promise<boolean> | void
/** This row's provider slug — edits persist as its global preset. */
provider: string
/** Whether this model supports reasoning effort. */ /** Whether this model supports reasoning effort. */
reasoning: boolean reasoning: boolean
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T> requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
} }
export function ModelEditSubmenu({ export function ModelEditSubmenu({
effort,
fastControl, fastControl,
isActive, isActive,
onActivate, model,
onSelectModel, onSelectModel,
provider,
reasoning, reasoning,
requestGateway requestGateway
}: ModelEditSubmenuProps) { }: ModelEditSubmenuProps) {
const { t } = useI18n() const { t } = useI18n()
const copy = t.shell.modelOptions const copy = t.shell.modelOptions
// Reactive session state comes straight from the stores rather than being
// drilled through the panel, so editing it re-renders only this submenu.
const activeSessionId = useStore($activeSessionId) const activeSessionId = useStore($activeSessionId)
const currentReasoningEffort = useStore($currentReasoningEffort)
const effort = normalizeEffort(currentReasoningEffort) const effortValue = normalizeEffort(effort)
const thinkingOn = isThinkingEnabled(currentReasoningEffort) const thinkingOn = isThinkingEnabled(effort)
// Reasoning/fast are session-scoped (they apply to the active model), so // Editing always records the model's global preset; the active model also gets
// editing a non-active model first switches to it. Returns false if the // it pushed onto the live session. Non-active edits stay preset-only — they do
// switch failed, so callers skip applying to the wrong (previous) model. // not switch you to that model.
const ensureActive = async (): Promise<boolean> => { const patchReasoning = async (next: string) => {
if (isActive) { setModelPreset(provider, model, { effort: next })
return true
if (!isActive) {
return
} }
return (await onActivate()) !== false
}
const patchReasoning = async (next: string, rollback: string) => {
setCurrentReasoningEffort(next) setCurrentReasoningEffort(next)
// Preset-only without a session: `isActive` holds for the global/default
// row pre-session, and the gateway's `config.set` falls back to global
// config when none matches — so don't reach it (preset + optimistic store
// are the whole effect). Same guard in applyModelPreset / toggleFast.
if (!activeSessionId) {
return
}
try { try {
if (!(await ensureActive())) { await requestGateway('config.set', { key: 'reasoning', session_id: activeSessionId, value: next })
setCurrentReasoningEffort(rollback)
return
}
await requestGateway('config.set', {
key: 'reasoning',
session_id: activeSessionId ?? '',
value: next
})
} catch (err) { } catch (err) {
setCurrentReasoningEffort(rollback) setCurrentReasoningEffort(effort)
setModelPreset(provider, model, { effort })
notifyError(err, copy.updateFailed) notifyError(err, copy.updateFailed)
} }
} }
const toggleFast = (enabled: boolean) => { const toggleFast = (enabled: boolean) => {
if (fastControl.kind === 'variant') { if (fastControl.kind === 'variant') {
// Fast is a separate model id — swap to it (or back to the base). // Fast is a separate model id. Record the choice on the base model's
void onSelectModel(enabled ? fastControl.fastId : fastControl.baseId) // preset (selectFamily picks the `-fast` sibling later when set), and
// only swap models now if this is the active row — inactive edits must
// stay preset-only, same as the param path below.
setModelPreset(provider, fastControl.baseId, { fast: enabled })
if (isActive) {
void onSelectModel(enabled ? fastControl.fastId : fastControl.baseId)
}
return return
} }
if (fastControl.kind === 'param') { if (fastControl.kind === 'param') {
setModelPreset(provider, model, { fast: enabled })
if (!isActive) {
return
}
setCurrentFastMode(enabled) setCurrentFastMode(enabled)
// Preset-only without a session (see patchReasoning).
if (!activeSessionId) {
return
}
void (async () => { void (async () => {
try { try {
if (!(await ensureActive())) { await requestGateway('config.set', { key: 'fast', session_id: activeSessionId, value: enabled ? 'fast' : 'normal' })
setCurrentFastMode(!enabled)
return
}
await requestGateway('config.set', {
key: 'fast',
session_id: activeSessionId ?? '',
value: enabled ? 'fast' : 'normal'
})
} catch (err) { } catch (err) {
setCurrentFastMode(!enabled) setCurrentFastMode(!enabled)
setModelPreset(provider, model, { fast: !enabled })
notifyError(err, copy.fastFailed) notifyError(err, copy.fastFailed)
} }
})() })()
@ -188,9 +192,7 @@ export function ModelEditSubmenu({
<Switch <Switch
checked={thinkingOn} checked={thinkingOn}
className="ml-auto" className="ml-auto"
onCheckedChange={checked => onCheckedChange={checked => void patchReasoning(checked ? effortValue || 'medium' : 'none')}
void patchReasoning(checked ? effort || 'medium' : 'none', currentReasoningEffort)
}
size="xs" size="xs"
/> />
</DropdownMenuItem> </DropdownMenuItem>
@ -205,10 +207,7 @@ export function ModelEditSubmenu({
<> <>
<DropdownMenuSeparator className="mx-0" /> <DropdownMenuSeparator className="mx-0" />
<DropdownMenuLabel className={dropdownMenuSectionLabel}>{copy.effort}</DropdownMenuLabel> <DropdownMenuLabel className={dropdownMenuSectionLabel}>{copy.effort}</DropdownMenuLabel>
<DropdownMenuRadioGroup <DropdownMenuRadioGroup onValueChange={value => void patchReasoning(value)} value={effortValue}>
onValueChange={value => void patchReasoning(value, currentReasoningEffort)}
value={effort}
>
{EFFORT_OPTIONS.map(option => ( {EFFORT_OPTIONS.map(option => (
<DropdownMenuRadioItem <DropdownMenuRadioItem
className={dropdownMenuRow} className={dropdownMenuRow}

View File

@ -1,6 +1,6 @@
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useMemo, useState } from 'react' import { createContext, useContext, useMemo, useState } from 'react'
import { Codicon } from '@/components/ui/codicon' import { Codicon } from '@/components/ui/codicon'
import { import {
@ -18,8 +18,9 @@ import { Skeleton } from '@/components/ui/skeleton'
import type { HermesGateway } from '@/hermes' import type { HermesGateway } from '@/hermes'
import { getGlobalModelOptions } from '@/hermes' import { getGlobalModelOptions } from '@/hermes'
import { useI18n } from '@/i18n' import { useI18n } from '@/i18n'
import { displayModelName, modelDisplayParts, reasoningEffortLabel } from '@/lib/model-status-label' import { currentPickerSelection, displayModelName, modelDisplayParts, reasoningEffortLabel } from '@/lib/model-status-label'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { $modelPresets, applyModelPreset, modelPresetKey } from '@/store/model-presets'
import { import {
$visibleModels, $visibleModels,
collapseModelFamilies, collapseModelFamilies,
@ -40,9 +41,14 @@ import type { ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes'
import { ModelEditSubmenu, resolveFastControl } from './model-edit-submenu' import { ModelEditSubmenu, resolveFastControl } from './model-edit-submenu'
// Lets the host dropdown (model-pill) hand the panel a way to dismiss itself so
// clicking a model row commits + closes, while the hover-revealed edit submenu
// (reasoning/fast) stays open to play with (its items preventDefault on select).
export const ModelMenuCloseContext = createContext<() => void>(() => {})
interface ModelMenuPanelProps { interface ModelMenuPanelProps {
gateway?: HermesGateway gateway?: HermesGateway
onSelectModel: (selection: { model: string; persistGlobal: boolean; provider: string }) => Promise<boolean> | void onSelectModel: (selection: { model: string; provider: string }) => Promise<boolean> | void
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T> requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
} }
@ -54,6 +60,7 @@ interface ProviderGroup {
export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: ModelMenuPanelProps) { export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: ModelMenuPanelProps) {
const { t } = useI18n() const { t } = useI18n()
const copy = t.shell.modelMenu const copy = t.shell.modelMenu
const closeMenu = useContext(ModelMenuCloseContext)
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
// Reactive session state is read from the stores here (not drilled in), so // Reactive session state is read from the stores here (not drilled in), so
// toggling effort/fast/model re-renders this panel in place without forcing // toggling effort/fast/model re-renders this panel in place without forcing
@ -63,6 +70,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
const currentModel = useStore($currentModel) const currentModel = useStore($currentModel)
const currentProvider = useStore($currentProvider) const currentProvider = useStore($currentProvider)
const currentReasoningEffort = useStore($currentReasoningEffort) const currentReasoningEffort = useStore($currentReasoningEffort)
const modelPresets = useStore($modelPresets)
const visibleModels = useStore($visibleModels) const visibleModels = useStore($visibleModels)
const modelOptions = useQuery({ const modelOptions = useQuery({
@ -76,8 +84,12 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
} }
}) })
const optionsModel = String(modelOptions.data?.model ?? currentModel ?? '') const { model: optionsModel, provider: optionsProvider } = currentPickerSelection(
const optionsProvider = String(modelOptions.data?.provider ?? currentProvider ?? '') !!activeSessionId,
{ model: currentModel, provider: currentProvider },
modelOptions.data
)
const loading = modelOptions.isPending && !modelOptions.data const loading = modelOptions.isPending && !modelOptions.data
const error = modelOptions.error const error = modelOptions.error
@ -87,13 +99,41 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
: null : null
const providers = modelOptions.data?.providers const providers = modelOptions.data?.providers
const effectiveVisibleModels = useMemo( const effectiveVisibleModels = useMemo(
() => effectiveVisibleKeys(visibleModels, providers ?? []), () => effectiveVisibleKeys(visibleModels, providers ?? []),
[visibleModels, providers] [visibleModels, providers]
) )
const switchTo = (model: string, provider: string) => // The composer picker never persists the profile default. With a session it
onSelectModel({ model, persistGlobal: !activeSessionId, provider }) // scopes the switch to that session; with none it's UI state shipped on the
// next session.create (see selectModel). The default lives in Settings → Model.
const switchTo = (model: string, provider: string) => onSelectModel({ model, provider })
// Selecting a model row restores that model's remembered preset onto the
// session (effort/fast), gated by capability. Unset → Hermes defaults.
const selectFamily = async (family: ModelFamily, provider: ModelOptionProvider) => {
const caps = provider.capabilities?.[family.id]
const preset = modelPresets[modelPresetKey(provider.slug, family.id)] ?? {}
// Variant-fast models (no speed param) express "fast" as a separate `-fast`
// id, so honor the saved preset by selecting that sibling. Param-fast is
// applied via applyModelPreset below instead.
const variantFast = !(caps?.fast ?? false) && !!family.fastId
const targetId = variantFast && preset.fast === true ? family.fastId! : family.id
if ((await switchTo(targetId, provider.slug)) === false) {
return
}
await applyModelPreset(
{
effort: (caps?.reasoning ?? true) ? (preset.effort ?? 'medium') : undefined,
fast: (caps?.fast ?? false) ? (preset.fast ?? false) : undefined
},
{ failMessage: t.shell.modelOptions.updateFailed, request: requestGateway, sessionId: activeSessionId }
)
}
const groups = useMemo( const groups = useMemo(
() => groupModels(providers ?? [], search, { model: optionsModel, provider: optionsProvider }, effectiveVisibleModels), () => groupModels(providers ?? [], search, { model: optionsModel, provider: optionsProvider }, effectiveVisibleModels),
@ -152,37 +192,42 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
// -fast variant carries the same param support as its base. // -fast variant carries the same param support as its base.
const caps = group.provider.capabilities?.[family.id] const caps = group.provider.capabilities?.[family.id]
// Single source of truth for the active row's fast state — keeps // Effective settings for this row: live session state when it's
// the row label in lock-step with the submenu's Fast toggle and // the active model, otherwise its remembered preset (Hermes
// handles the standalone `-fast` id case. // defaults when unset). Row label AND submenu read from these so
// they never disagree.
const preset = modelPresets[modelPresetKey(group.provider.slug, family.id)] ?? {}
const effEffort = isCurrent ? currentReasoningEffort : preset.effort ?? ''
const effFast = isCurrent ? currentFastMode : preset.fast ?? false
const fastControl = resolveFastControl( const fastControl = resolveFastControl(
activeId ?? family.id, activeId ?? family.id,
group.provider.models ?? [], group.provider.models ?? [],
caps?.fast ?? false, caps?.fast ?? false,
currentFastMode effFast
) )
// Grayed text is live session state only. Do not label inactive const meta = [
// rows as "Fast" just because they have a fast-capable sibling: fastControl.kind !== 'none' && fastControl.on ? copy.fast : null,
// that makes an off Fast toggle look like it is already on. (caps?.reasoning ?? true) ? reasoningEffortLabel(effEffort) || copy.medium : null
const meta = isCurrent ]
? [ .filter(Boolean)
fastControl.kind !== 'none' && fastControl.on ? copy.fast : null, .join(' ')
reasoningEffortLabel(currentReasoningEffort) || copy.medium
]
.filter(Boolean)
.join(' ')
: ''
// Every row is a hover-Edit submenu trigger. Activating it // Every row is a hover-Edit submenu trigger. Activating it
// (pointer or keyboard) switches to the family's base model; // (pointer or keyboard) switches to the family's base model and
// the Fast toggle inside swaps to the -fast sibling (or flips // restores its preset; the Fast toggle inside swaps to the -fast
// the speed param). The sub-trigger has no `onSelect`, so wire // sibling (or flips the speed param). The sub-trigger has no
// both click and Enter/Space for keyboard parity. // `onSelect`, so wire both click and Enter/Space for keyboard parity.
// Clicking the row commits the model and closes the picker; the
// edit submenu (reasoning/fast) is reached by HOVER, so you can
// still tweak those without the click dismissing everything.
const activate = () => { const activate = () => {
if (!isCurrent) { if (!isCurrent) {
void switchTo(family.id, group.provider.slug) void selectFamily(family, group.provider)
} }
closeMenu()
} }
return ( return (
@ -204,10 +249,12 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
{isCurrent ? <Codicon className="ml-auto text-foreground" name="check" size="0.75rem" /> : null} {isCurrent ? <Codicon className="ml-auto text-foreground" name="check" size="0.75rem" /> : null}
</DropdownMenuSubTrigger> </DropdownMenuSubTrigger>
<ModelEditSubmenu <ModelEditSubmenu
effort={effEffort}
fastControl={fastControl} fastControl={fastControl}
isActive={isCurrent} isActive={isCurrent}
onActivate={() => switchTo(family.id, group.provider.slug)} model={family.id}
onSelectModel={nextModel => switchTo(nextModel, group.provider.slug)} onSelectModel={nextModel => switchTo(nextModel, group.provider.slug)}
provider={group.provider.slug}
reasoning={caps?.reasoning ?? true} reasoning={caps?.reasoning ?? true}
requestGateway={requestGateway} requestGateway={requestGateway}
/> />

View File

@ -0,0 +1,129 @@
// Lists and blockquotes have chrome beside the text (markers, the quote
// border) whose side is driven by the box's CSS direction, which the
// unicode-bidi:plaintext rules never touch. These tests pin the split of
// responsibilities: ul/ol/blockquote carry dir="auto" so the browser
// resolves their box direction from content, inline code carries dir="ltr"
// so it neither votes in that resolution nor reorders, and plain prose
// blocks stay attribute-free (the plaintext CSS owns them). jsdom does not
// resolve dir="auto", so the contract is asserted at the attribute level.
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { Thread } from './thread'
const createdAt = new Date('2026-06-01T00:00:00.000Z')
class TestResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', TestResizeObserver)
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
window.setTimeout(() => callback(performance.now()), 0)
)
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
Element.prototype.scrollTo = function scrollTo() {}
function stubOffsetDimension(
prop: 'offsetHeight' | 'offsetWidth',
clientProp: 'clientHeight' | 'clientWidth',
fallback: number
) {
const previous = Object.getOwnPropertyDescriptor(HTMLElement.prototype, prop)
Object.defineProperty(HTMLElement.prototype, prop, {
configurable: true,
get() {
return previous?.get?.call(this) || (this as HTMLElement)[clientProp] || fallback
}
})
}
stubOffsetDimension('offsetWidth', 'clientWidth', 800)
stubOffsetDimension('offsetHeight', 'clientHeight', 600)
function userMessage(): ThreadMessage {
return {
id: 'user-1',
role: 'user',
content: [{ type: 'text', text: 'hi' }],
attachments: [],
createdAt,
metadata: { custom: {} }
} as ThreadMessage
}
function assistantMessage(text: string): ThreadMessage {
return {
id: 'assistant-1',
role: 'assistant',
content: [{ type: 'text', text }],
status: { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
function Harness({ text }: { text: string }) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [userMessage(), assistantMessage(text)],
isRunning: false,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
describe('block-level direction chrome', () => {
it('lists carry dir="auto" so markers follow the resolved direction', async () => {
render(<Harness text={'מקומות:\n\n1. חוף גורדון\n2. שוק הכרמל\n\n- פריט\n- item'} />)
const item = await screen.findByText(/חוף גורדון/)
expect(item.closest('ol')?.getAttribute('dir')).toBe('auto')
const bullet = await screen.findByText(/פריט/)
expect(bullet.closest('ul')?.getAttribute('dir')).toBe('auto')
})
it('blockquotes carry dir="auto" so the border follows the resolved direction', async () => {
render(<Harness text={'> ציטוט קצר בעברית'} />)
const quote = await screen.findByText(/ציטוט קצר/)
expect(quote.closest('blockquote')?.getAttribute('dir')).toBe('auto')
})
it('inline code carries dir="ltr" so it does not vote in dir="auto" resolution', async () => {
render(<Harness text={'1. `npm install` מתקין תלויות'} />)
const code = await screen.findByText('npm install')
expect(code.tagName).toBe('CODE')
expect(code.getAttribute('dir')).toBe('ltr')
expect(code.closest('ol')?.getAttribute('dir')).toBe('auto')
})
it('plain prose blocks stay attribute-free (plaintext CSS owns them)', async () => {
render(<Harness text={'שלום לכולם'} />)
const paragraph = await screen.findByText(/שלום לכולם/)
expect(paragraph.closest('p')?.hasAttribute('dir')).toBe(false)
})
})

View File

@ -322,13 +322,29 @@ function shortLabel(type: HermesRefType, id: string): string {
return tail || id return tail || id
} }
function safeEmbeddedImages(text: string) {
try {
return extractEmbeddedImages(text)
} catch {
return { cleanedText: text, images: [] as string[] }
}
}
function safeDirectiveSegments(text: string): Unstable_DirectiveSegment[] {
try {
return [...hermesDirectiveFormatter.parse(text)]
} catch {
return [{ kind: 'text', text }]
}
}
/** /**
* Renders text containing Hermes directives (`@file:...`, `@image:...`) as * Renders text containing Hermes directives (`@file:...`, `@image:...`) as
* inline chips. Embedded MEDIA images render below as a thumbnail row. * inline chips. Embedded MEDIA images render below as a thumbnail row.
*/ */
export function DirectiveContent({ text }: { text: string }) { export function DirectiveContent({ text }: { text: string }) {
const { cleanedText, images } = useMemo(() => extractEmbeddedImages(text ?? ''), [text]) const { cleanedText, images } = useMemo(() => safeEmbeddedImages(text ?? ''), [text])
const segments = useMemo(() => hermesDirectiveFormatter.parse(cleanedText), [cleanedText]) const segments = useMemo(() => safeDirectiveSegments(cleanedText), [cleanedText])
return ( return (
<span className="whitespace-pre-line" data-slot="aui_directive-text"> <span className="whitespace-pre-line" data-slot="aui_directive-text">

View File

@ -201,4 +201,13 @@ describe('preprocessMarkdown', () => {
expect(output).toContain('<https://example.com/a_b/c~d/page>') expect(output).toContain('<https://example.com/a_b/c~d/page>')
}) })
it('handles a fenced block larger than V8 spread-argument limit', () => {
// A single huge code block (e.g. a logged minified bundle) used to throw
// `RangeError: Maximum call stack size exceeded` via `out.push(...lines)`.
const body = Array.from({ length: 200_000 }, (_, i) => `line ${i}`).join('\n')
const input = `\`\`\`js\n${body}\n\`\`\``
expect(() => preprocessMarkdown(input)).not.toThrow()
})
}) })

View File

@ -19,8 +19,9 @@ import {
useState useState
} from 'react' } from 'react'
import { ExpandableBlock } from '@/components/chat/expandable-block'
import { PreviewAttachment } from '@/components/chat/preview-attachment' import { PreviewAttachment } from '@/components/chat/preview-attachment'
import { SyntaxHighlighter } from '@/components/chat/shiki-highlighter' import { chunkByLines, SyntaxHighlighter } from '@/components/chat/shiki-highlighter'
import { ZoomableImage } from '@/components/chat/zoomable-image' import { ZoomableImage } from '@/components/chat/zoomable-image'
import { normalizeExternalUrl, openExternalLink, PrettyLink } from '@/lib/external-link' import { normalizeExternalUrl, openExternalLink, PrettyLink } from '@/lib/external-link'
import { createMemoizedMathPlugin } from '@/lib/katex-memo' import { createMemoizedMathPlugin } from '@/lib/katex-memo'
@ -57,7 +58,11 @@ const mathPlugin = createMemoizedMathPlugin({ singleDollarTextMath: true })
// flush) with a tail-bounded repair — see lib/remend-tail.ts. Must stay // flush) with a tail-bounded repair — see lib/remend-tail.ts. Must stay
// module-scope so the prop identity is stable across renders. // module-scope so the prop identity is stable across renders.
function preprocessWithTailRepair(text: string): string { function preprocessWithTailRepair(text: string): string {
return tailBoundedRemend(preprocessMarkdown(text)) try {
return tailBoundedRemend(preprocessMarkdown(text))
} catch {
return text
}
} }
// Memoized block splitter. Streamdown calls `parseMarkdownIntoBlocks` (a full // Memoized block splitter. Streamdown calls `parseMarkdownIntoBlocks` (a full
@ -453,8 +458,35 @@ const MARKDOWN_CONTAINER_CLASS_NAME = cn(
'[&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*+*]:mt-(--paragraph-gap)' '[&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*+*]:mt-(--paragraph-gap)'
) )
const MAX_MARKDOWN_CHARS = 200_000
function HugeTextFallback({ containerClassName, text }: { containerClassName?: string; text: string }) {
const chunks = useMemo(() => chunkByLines(text, 200), [text])
return (
<div
className={cn(
'aui-md w-full max-w-none overflow-hidden rounded-[0.625rem] border border-border font-mono text-[0.7rem] leading-relaxed text-foreground/90',
containerClassName
)}
>
<ExpandableBlock className="p-2">
{chunks.map((chunk, index) => (
<div
className="[content-visibility:auto]"
key={index}
style={{ containIntrinsicSize: `auto ${chunk.lines * 16}px` }}
>
{chunk.text}
</div>
))}
</ExpandableBlock>
</div>
)
}
function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTextSurfaceProps) { function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTextSurfaceProps) {
const { status } = useMessagePartText() const { status, text } = useMessagePartText()
const isStreaming = status.type === 'running' const isStreaming = status.type === 'running'
// Keep code parsing enabled while streaming so incomplete fenced blocks still // Keep code parsing enabled while streaming so incomplete fenced blocks still
@ -484,19 +516,37 @@ function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTex
<p className={cn('wrap-anywhere leading-(--dt-line-height)', className)} {...props} /> <p className={cn('wrap-anywhere leading-(--dt-line-height)', className)} {...props} />
), ),
a: MarkdownLink, a: MarkdownLink,
// Inline code must not vote when an ancestor resolves `dir="auto"`
// (HTML's algorithm skips descendants that carry their own dir),
// mirroring the CSS isolate that already keeps it out of the
// plaintext scan. Fenced code never reaches this override; it goes
// through the code plugin's CodeCard path.
inlineCode: ({ className, ...props }: ComponentProps<'code'>) => (
<code className={className} dir="ltr" {...props} />
),
// `---` as quiet spacing, not a heavy full-width rule. // `---` as quiet spacing, not a heavy full-width rule.
hr: (_props: ComponentProps<'hr'>) => <div aria-hidden className="my-3" />, hr: (_props: ComponentProps<'hr'>) => <div aria-hidden className="my-3" />,
// Lists and blockquotes have chrome that sits *beside* the text
// (markers, the quote border), and that side is driven by the CSS
// `direction` of the box, which `unicode-bidi: plaintext` never
// touches — an RTL list otherwise renders its numbers stranded at
// the far left. `dir="auto"` lets the browser resolve the box
// direction from content; the plaintext rules in styles.css keep
// owning per-line text direction. Inline code carries `dir="ltr"`
// (see the `code` override) so it doesn't vote here either, same
// contract as the CSS isolate.
blockquote: ({ className, ...props }: ComponentProps<'blockquote'>) => ( blockquote: ({ className, ...props }: ComponentProps<'blockquote'>) => (
<blockquote <blockquote
className={cn('border-l-2 border-border pl-3 text-muted-foreground italic', className)} className={cn('border-s-2 border-border ps-3 text-muted-foreground italic', className)}
dir="auto"
{...props} {...props}
/> />
), ),
ul: ({ className, ...props }: ComponentProps<'ul'>) => ( ul: ({ className, ...props }: ComponentProps<'ul'>) => (
<ul className={cn('my-1 gap-0', className)} {...props} /> <ul className={cn('my-1 gap-0', className)} dir="auto" {...props} />
), ),
ol: ({ className, ...props }: ComponentProps<'ol'>) => ( ol: ({ className, ...props }: ComponentProps<'ol'>) => (
<ol className={cn('my-1 gap-0', className)} {...props} /> <ol className={cn('my-1 gap-0', className)} dir="auto" {...props} />
), ),
li: ({ className, ...props }: ComponentProps<'li'>) => ( li: ({ className, ...props }: ComponentProps<'li'>) => (
<li className={cn('leading-(--dt-line-height)', className)} {...props} /> <li className={cn('leading-(--dt-line-height)', className)} {...props} />
@ -533,6 +583,10 @@ function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTex
[isStreaming] [isStreaming]
) )
if (text.length > MAX_MARKDOWN_CHARS) {
return <HugeTextFallback containerClassName={containerClassName} text={text} />
}
return ( return (
<StreamdownTextPrimitive <StreamdownTextPrimitive
components={components} components={components}

View File

@ -378,6 +378,20 @@ function IntroHarness() {
) )
} }
function DismissibleErrorHarness({ onDismissError }: { onDismissError: (messageId: string) => void }) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [assistantErrorMessage('OpenRouter rejected the request (403).')],
isRunning: false,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread onDismissError={onDismissError} />
</AssistantRuntimeProvider>
)
}
describe('assistant-ui streaming renderer', () => { describe('assistant-ui streaming renderer', () => {
beforeEach(() => { beforeEach(() => {
resizeObservers.clear() resizeObservers.clear()
@ -421,6 +435,23 @@ describe('assistant-ui streaming renderer', () => {
expect(screen.getByRole('alert').textContent).toContain('OpenRouter rejected the request (403).') expect(screen.getByRole('alert').textContent).toContain('OpenRouter rejected the request (403).')
}) })
it('omits the dismiss control when no onDismissError handler is supplied', () => {
render(<MessageHarness message={assistantErrorMessage('OpenRouter rejected the request (403).')} />)
expect(screen.queryByRole('button', { name: 'Dismiss error' })).toBeNull()
})
it('invokes onDismissError with the errored message id when the dismiss control is clicked', () => {
const onDismissError = vi.fn()
render(<DismissibleErrorHarness onDismissError={onDismissError} />)
const dismiss = screen.getByRole('button', { name: 'Dismiss error' })
fireEvent.click(dismiss)
expect(onDismissError).toHaveBeenCalledTimes(1)
expect(onDismissError).toHaveBeenCalledWith('assistant-error-1')
})
// Scroll behavior (follow-at-bottom, escape-on-scroll-up, re-engage) is owned // Scroll behavior (follow-at-bottom, escape-on-scroll-up, re-engage) is owned
// by the use-stick-to-bottom library and covered by its own test suite. We // by the use-stick-to-bottom library and covered by its own test suite. We
// don't re-assert its scrollTop mechanics here — doing so in jsdom (no real // don't re-assert its scrollTop mechanics here — doing so in jsdom (no real

View File

@ -22,7 +22,7 @@ import {
resetThreadScroll, resetThreadScroll,
setThreadAtBottom setThreadAtBottom
} from '@/store/thread-scroll' } from '@/store/thread-scroll'
import { isNewSessionWindow, isSecondaryWindow } from '@/store/windows' import { isSecondaryWindow } from '@/store/windows'
import { MessageRenderBoundary } from './message-render-boundary' import { MessageRenderBoundary } from './message-render-boundary'
@ -134,13 +134,20 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
const hiddenCount = firstVisible const hiddenCount = firstVisible
const visibleGroups = hiddenCount > 0 ? groups.slice(hiddenCount) : groups const visibleGroups = hiddenCount > 0 ? groups.slice(hiddenCount) : groups
const restoreFromBottomRef = useRef<number | null>(null) const restoreFromBottomRef = useRef<number | null>(null)
const newSessionWindow = isNewSessionWindow() // Secondary windows (new-session scratch, subagent watch, cmd-click pop-out)
const newSessionTitlebarGap = 'calc(var(--titlebar-height)+0.75rem)' // hide the titlebar tool cluster + session header, but the OS traffic lights
const threadContentTopPad = newSessionWindow // still sit in the top-left, so reserve the titlebar gap above the transcript.
const secondaryWindow = isSecondaryWindow()
// NB: CSS calc() requires whitespace around the +/- operator. This string is
// assigned verbatim to the --sticky-human-top inline style below (it does not
// go through Tailwind, which would auto-space it), so the spaces are load-
// bearing — without them the declaration is invalid, gets dropped, and the
// sticky user bubble falls back to its ~4px default and slides under the OS
// traffic lights.
const secondaryTitlebarGap = 'calc(var(--titlebar-height) + 0.75rem)'
const threadContentTopPad = secondaryWindow
? 'pt-[calc(var(--titlebar-height)+0.75rem)]' ? 'pt-[calc(var(--titlebar-height)+0.75rem)]'
: isSecondaryWindow() : 'pt-[calc(var(--titlebar-height)-0.5rem)]'
? 'pt-6'
: 'pt-[calc(var(--titlebar-height)+1.5rem)]'
useEffect(() => setThreadAtBottom(isAtBottom), [isAtBottom]) useEffect(() => setThreadAtBottom(isAtBottom), [isAtBottom])
useEffect(() => () => resetThreadScroll(), []) useEffect(() => () => resetThreadScroll(), [])
@ -247,10 +254,21 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
style={ style={
{ {
height: clampToComposer ? 'var(--thread-viewport-height)' : '100%', height: clampToComposer ? 'var(--thread-viewport-height)' : '100%',
...(newSessionWindow ? { '--sticky-human-top': newSessionTitlebarGap } : {}) ...(secondaryWindow ? { '--sticky-human-top': secondaryTitlebarGap } : {})
} as CSSProperties } as CSSProperties
} }
> >
{secondaryWindow && (
// Secondary windows hide the titlebar chrome, so the scroller runs to
// the window's top edge and streamed text slides up under the OS
// traffic lights. Content padding alone scrolls away with the text — a
// fixed opaque strip (the titlebar's drag region) masks anything behind
// it and keeps the window draggable, matching the main window's header.
<div
aria-hidden="true"
className="absolute inset-x-0 top-0 z-10 h-(--titlebar-height) bg-background [-webkit-app-region:drag]"
/>
)}
<div <div
className="size-full overflow-x-hidden overflow-y-auto overscroll-contain" className="size-full overflow-x-hidden overflow-y-auto overscroll-contain"
data-following={isAtBottom ? 'true' : 'false'} data-following={isAtBottom ? 'true' : 'false'}

View File

@ -91,7 +91,7 @@ import { attachmentDisplayText, attachmentId, pathLabel } from '@/lib/chat-runti
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images' import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
import { LinkifiedText } from '@/lib/external-link' import { LinkifiedText } from '@/lib/external-link'
import { triggerHaptic } from '@/lib/haptics' import { triggerHaptic } from '@/lib/haptics'
import { GitBranchIcon, Loader2Icon, Volume2Icon, VolumeXIcon } from '@/lib/icons' import { GitBranchIcon, Loader2Icon, Volume2Icon, VolumeXIcon, XIcon } from '@/lib/icons'
import { extractPreviewTargets } from '@/lib/preview-targets' import { extractPreviewTargets } from '@/lib/preview-targets'
import { useEnterAnimation } from '@/lib/use-enter-animation' import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@ -169,6 +169,7 @@ export const Thread: FC<{
loading?: ThreadLoadingState loading?: ThreadLoadingState
onBranchInNewChat?: (messageId: string) => void onBranchInNewChat?: (messageId: string) => void
onCancel?: () => Promise<void> | void onCancel?: () => Promise<void> | void
onDismissError?: (messageId: string) => void
onRestoreToMessage?: (messageId: string) => Promise<void> | void onRestoreToMessage?: (messageId: string) => Promise<void> | void
sessionId?: string | null sessionId?: string | null
sessionKey?: string | null sessionKey?: string | null
@ -180,18 +181,19 @@ export const Thread: FC<{
loading, loading,
onBranchInNewChat, onBranchInNewChat,
onCancel, onCancel,
onDismissError,
onRestoreToMessage, onRestoreToMessage,
sessionId = null, sessionId = null,
sessionKey sessionKey
}) => { }) => {
const messageComponents = useMemo( const messageComponents = useMemo(
() => ({ () => ({
AssistantMessage: () => <AssistantMessage onBranchInNewChat={onBranchInNewChat} />, AssistantMessage: () => <AssistantMessage onBranchInNewChat={onBranchInNewChat} onDismissError={onDismissError} />,
SystemMessage, SystemMessage,
UserEditComposer: () => <UserEditComposer cwd={cwd} gateway={gateway} sessionId={sessionId} />, UserEditComposer: () => <UserEditComposer cwd={cwd} gateway={gateway} sessionId={sessionId} />,
UserMessage: () => <UserMessage onCancel={onCancel} onRestoreToMessage={onRestoreToMessage} /> UserMessage: () => <UserMessage onCancel={onCancel} onRestoreToMessage={onRestoreToMessage} />
}), }),
[cwd, gateway, onBranchInNewChat, onCancel, onRestoreToMessage, sessionId] [cwd, gateway, onBranchInNewChat, onCancel, onDismissError, onRestoreToMessage, sessionId]
) )
const emptyPlaceholder = intro ? ( const emptyPlaceholder = intro ? (
@ -245,9 +247,13 @@ const CenteredThreadSpinner: FC = () => {
) )
} }
const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }> = ({ onBranchInNewChat }) => { const AssistantMessage: FC<{
onBranchInNewChat?: (messageId: string) => void
onDismissError?: (messageId: string) => void
}> = ({ onBranchInNewChat, onDismissError }) => {
const messageId = useAuiState(s => s.message.id) const messageId = useAuiState(s => s.message.id)
const messageRuntime = useMessageRuntime() const messageRuntime = useMessageRuntime()
const { t } = useI18n()
// PERF: this component must NOT subscribe to the streaming text. Every // PERF: this component must NOT subscribe to the streaming text. Every
// selector here returns a value that stays referentially stable across // selector here returns a value that stays referentially stable across
@ -306,10 +312,20 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
)} )}
<MessagePrimitive.Error> <MessagePrimitive.Error>
<ErrorPrimitive.Root <ErrorPrimitive.Root
className="mt-1.5 text-[0.78rem] leading-5 text-[color-mix(in_srgb,var(--dt-destructive)_78%,var(--ui-text-secondary))]" className="mt-1.5 flex items-start gap-1.5 text-[0.78rem] leading-5 text-[color-mix(in_srgb,var(--dt-destructive)_78%,var(--ui-text-secondary))]"
role="alert" role="alert"
> >
<ErrorPrimitive.Message /> <ErrorPrimitive.Message className="min-w-0 flex-1" />
{onDismissError && (
<TooltipIconButton
className="-my-0.5 shrink-0 text-current opacity-70 hover:opacity-100"
onClick={() => onDismissError(messageId)}
side="top"
tooltip={t.assistant.thread.dismissError}
>
<XIcon className="size-3.5" />
</TooltipIconButton>
)}
</ErrorPrimitive.Root> </ErrorPrimitive.Root>
</MessagePrimitive.Error> </MessagePrimitive.Error>
</div> </div>
@ -811,7 +827,7 @@ function StickyHumanMessageContainer({ attachments, children }: { attachments?:
// so without the carve-out, clicking a stuck bubble drags the window instead of // so without the carve-out, clicking a stuck bubble drags the window instead of
// opening the edit composer. // opening the edit composer.
const USER_BUBBLE_BASE_CLASS = const USER_BUBBLE_BASE_CLASS =
'composer-human-message standalone-glass relative flex w-full min-w-0 max-w-full flex-col gap-1.5 overflow-hidden rounded-xl border bg-(--dt-user-bubble) px-3 py-2 text-left [-webkit-app-region:no-drag]' 'composer-human-message standalone-glass relative flex w-full min-w-0 max-w-full flex-col gap-1.5 overflow-y-auto rounded-xl border bg-(--dt-user-bubble) px-3 py-2 text-left [-webkit-app-region:no-drag]'
const USER_ACTION_ICON_BUTTON_CLASS = const USER_ACTION_ICON_BUTTON_CLASS =
'grid place-items-center rounded-md bg-transparent text-(--ui-text-secondary) transition-colors hover:bg-(--ui-control-active-background) hover:text-foreground disabled:cursor-default disabled:text-(--ui-text-quaternary) disabled:opacity-70' 'grid place-items-center rounded-md bg-transparent text-(--ui-text-secondary) transition-colors hover:bg-(--ui-control-active-background) hover:text-foreground disabled:cursor-default disabled:text-(--ui-text-quaternary) disabled:opacity-70'

View File

@ -66,7 +66,7 @@ function CodeCardBody({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
className={cn( className={cn(
'p-1.5 font-mono text-[0.7rem] leading-relaxed text-foreground/90 [&_pre]:m-0 [&_pre]:overflow-x-auto [&_pre]:bg-transparent! [&_pre]:px-2 [&_pre]:py-1.5 [&_pre]:font-mono [&_pre]:leading-relaxed', 'font-mono text-[0.7rem] leading-relaxed text-foreground/90 [&_pre]:m-0 [&_pre]:overflow-x-auto [&_pre]:bg-transparent! [&_pre]:px-2 [&_pre]:py-1.5 [&_pre]:font-mono [&_pre]:leading-relaxed',
className className
)} )}
data-slot="code-card-body" data-slot="code-card-body"

View File

@ -0,0 +1,52 @@
'use client'
import { type ReactNode, useLayoutEffect, useRef, useState } from 'react'
import { ChevronDown } from '@/lib/icons'
import { cn } from '@/lib/utils'
interface ExpandableBlockProps {
children: ReactNode
className?: string
}
export function ExpandableBlock({ children, className }: ExpandableBlockProps) {
const innerRef = useRef<HTMLDivElement>(null)
const [expanded, setExpanded] = useState(false)
const [overflowing, setOverflowing] = useState(false)
useLayoutEffect(() => {
const el = innerRef.current
if (!el) {return}
const measure = () => setOverflowing(el.scrollHeight > 121)
measure()
const observer = new ResizeObserver(measure)
observer.observe(el)
return () => observer.disconnect()
}, [])
return (
<div className="relative">
<div
className={cn('overflow-y-auto', expanded ? 'max-h-[40dvh]' : 'max-h-[7.5rem]', className)}
ref={innerRef}
>
{children}
</div>
{overflowing && (
<button
aria-expanded={expanded}
aria-label={expanded ? 'Collapse' : 'Expand'}
className="absolute inset-x-0 bottom-0 flex h-7 cursor-pointer items-end justify-center bg-linear-to-t from-(--ui-chat-surface-background) to-transparent pb-1 text-muted-foreground/70 transition-colors hover:text-foreground"
onClick={() => setExpanded(v => !v)}
type="button"
>
<ChevronDown className={cn('size-3.5 transition-transform', expanded && 'rotate-180')} />
</button>
)}
</div>
)
}

View File

@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest'
import { chunkByLines, exceedsHighlightBudget } from '@/components/chat/shiki-highlighter'
describe('exceedsHighlightBudget', () => {
it('highlights normal-sized blocks', () => {
expect(exceedsHighlightBudget('const x = 1\n'.repeat(100))).toBe(false)
})
it('skips highlighting past the line budget', () => {
expect(exceedsHighlightBudget('x\n'.repeat(5_000))).toBe(true)
})
it('skips highlighting past the char budget on few lines', () => {
expect(exceedsHighlightBudget('a'.repeat(200_000))).toBe(true)
})
it('short-circuits on char budget before line loop', () => {
expect(exceedsHighlightBudget('y\n'.repeat(250_000))).toBe(true)
})
})
describe('chunkByLines', () => {
it('keeps a small block as a single chunk', () => {
const code = 'a\nb\nc'
expect(chunkByLines(code, 200)).toEqual([{ text: code, lines: 3 }])
})
it('splits a large block and reconstructs it losslessly', () => {
const code = Array.from({ length: 1000 }, (_, i) => `line ${i}`).join('\n')
const chunks = chunkByLines(code, 200)
expect(chunks).toHaveLength(5)
expect(chunks.map(chunk => chunk.text).join('\n')).toBe(code)
expect(chunks.reduce((sum, chunk) => sum + chunk.lines, 0)).toBe(1000)
})
})

View File

@ -1,7 +1,7 @@
'use client' 'use client'
import type { SyntaxHighlighterProps } from '@assistant-ui/react-streamdown' import type { SyntaxHighlighterProps } from '@assistant-ui/react-streamdown'
import type { FC } from 'react' import { type FC, useMemo } from 'react'
import ShikiHighlighter from 'react-shiki' import ShikiHighlighter from 'react-shiki'
import { import {
@ -12,6 +12,7 @@ import {
CodeCardSubtitle, CodeCardSubtitle,
CodeCardTitle CodeCardTitle
} from '@/components/chat/code-card' } from '@/components/chat/code-card'
import { ExpandableBlock } from '@/components/chat/expandable-block'
import { CopyButton } from '@/components/ui/copy-button' import { CopyButton } from '@/components/ui/copy-button'
import { useI18n } from '@/i18n' import { useI18n } from '@/i18n'
import { codiconForLanguage, isLikelyProseCodeBlock, sanitizeLanguageTag } from '@/lib/markdown-code' import { codiconForLanguage, isLikelyProseCodeBlock, sanitizeLanguageTag } from '@/lib/markdown-code'
@ -43,6 +44,74 @@ const SHIKI_COLOR_REPLACEMENTS: Record<string, Record<string, string>> = {
'github-light-default': { '#6e7781': '#57606a' } 'github-light-default': { '#6e7781': '#57606a' }
} }
const MAX_HIGHLIGHT_CHARS = 150_000
const MAX_HIGHLIGHT_LINES = 3_000
const CHUNK_LINES = 200
const EST_LINE_PX = 16
export function exceedsHighlightBudget(code: string): boolean {
if (code.length > MAX_HIGHLIGHT_CHARS) {
return true
}
let lines = 1
let idx = code.indexOf('\n')
while (idx !== -1) {
if ((lines += 1) > MAX_HIGHLIGHT_LINES) {
return true
}
idx = code.indexOf('\n', idx + 1)
}
return false
}
interface CodeChunk {
text: string
lines: number
}
export function chunkByLines(code: string, perChunk: number): CodeChunk[] {
const lines = code.split('\n')
if (lines.length <= perChunk) {
return [{ text: code, lines: lines.length }]
}
const chunks: CodeChunk[] = []
for (let i = 0; i < lines.length; i += perChunk) {
const slice = lines.slice(i, i + perChunk)
chunks.push({ text: slice.join('\n'), lines: slice.length })
}
return chunks
}
const PlainCode: FC<{ code: string }> = ({ code }) => {
const chunks = useMemo(() => chunkByLines(code, CHUNK_LINES), [code])
if (chunks.length === 1) {
return <code className="block whitespace-pre">{code}</code>
}
return (
<>
{chunks.map((chunk, index) => (
<code
className="block whitespace-pre [content-visibility:auto]"
key={index}
style={{ containIntrinsicSize: `auto ${chunk.lines * EST_LINE_PX}px` }}
>
{chunk.text}
</code>
))}
</>
)
}
export const SyntaxHighlighter: FC<HermesSyntaxHighlighterProps> = ({ export const SyntaxHighlighter: FC<HermesSyntaxHighlighterProps> = ({
components: { Pre }, components: { Pre },
language, language,
@ -64,6 +133,7 @@ export const SyntaxHighlighter: FC<HermesSyntaxHighlighterProps> = ({
const cleanLanguage = sanitizeLanguageTag(language || '') const cleanLanguage = sanitizeLanguageTag(language || '')
const label = cleanLanguage && cleanLanguage !== 'unknown' ? cleanLanguage : '' const label = cleanLanguage && cleanLanguage !== 'unknown' ? cleanLanguage : ''
const plain = defer || exceedsHighlightBudget(trimmed)
return ( return (
<CodeCard data-streaming={defer ? 'true' : undefined}> <CodeCard data-streaming={defer ? 'true' : undefined}>
@ -83,24 +153,26 @@ export const SyntaxHighlighter: FC<HermesSyntaxHighlighterProps> = ({
/> />
</CodeCardHeader> </CodeCardHeader>
<CodeCardBody> <CodeCardBody>
<Pre className="aui-shiki m-0 overflow-hidden bg-transparent p-0"> <ExpandableBlock>
{defer ? ( <Pre className="aui-shiki m-0 overflow-hidden bg-transparent p-0">
<code className="block whitespace-pre">{trimmed}</code> {plain ? (
) : ( <PlainCode code={trimmed} />
<ShikiHighlighter ) : (
addDefaultStyles={false} <ShikiHighlighter
as="div" addDefaultStyles={false}
colorReplacements={SHIKI_COLOR_REPLACEMENTS} as="div"
defaultColor="light-dark()" colorReplacements={SHIKI_COLOR_REPLACEMENTS}
delay={120} defaultColor="light-dark()"
language={language || 'text'} delay={120}
showLanguage={false} language={language || 'text'}
theme={SHIKI_THEME} showLanguage={false}
> theme={SHIKI_THEME}
{trimmed} >
</ShikiHighlighter> {trimmed}
)} </ShikiHighlighter>
</Pre> )}
</Pre>
</ExpandableBlock>
</CodeCardBody> </CodeCardBody>
</CodeCard> </CodeCard>
) )

View File

@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query'
import { useState } from 'react' import { useState } from 'react'
import { useI18n } from '@/i18n' import { useI18n } from '@/i18n'
import { currentPickerSelection } from '@/lib/model-status-label'
import type { ModelOptionProvider, ModelOptionsResponse, ModelPricing } from '@/types/hermes' import type { ModelOptionProvider, ModelOptionsResponse, ModelPricing } from '@/types/hermes'
import type { HermesGateway } from '../hermes' import type { HermesGateway } from '../hermes'
@ -11,7 +12,6 @@ import { startManualOnboarding } from '../store/onboarding'
import { InlineNotice } from './notifications' import { InlineNotice } from './notifications'
import { Button } from './ui/button' import { Button } from './ui/button'
import { Checkbox } from './ui/checkbox'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from './ui/command' import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from './ui/command'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog'
import { Skeleton } from './ui/skeleton' import { Skeleton } from './ui/skeleton'
@ -23,7 +23,7 @@ interface ModelPickerDialogProps {
sessionId?: string | null sessionId?: string | null
currentModel: string currentModel: string
currentProvider: string currentProvider: string
onSelect: (selection: { provider: string; model: string; persistGlobal: boolean }) => void onSelect: (selection: { provider: string; model: string }) => void
/** /**
* Optional class to apply to DialogContent. Use to override z-index when * Optional class to apply to DialogContent. Use to override z-index when
* stacking the picker on top of another fixed overlay (e.g. the desktop * stacking the picker on top of another fixed overlay (e.g. the desktop
@ -45,7 +45,6 @@ export function ModelPickerDialog({
}: ModelPickerDialogProps) { }: ModelPickerDialogProps) {
const { t } = useI18n() const { t } = useI18n()
const copy = t.modelPicker const copy = t.modelPicker
const [persistGlobal, setPersistGlobal] = useState(!sessionId)
// Own the search term so we can filter manually. cmdk's built-in // Own the search term so we can filter manually. cmdk's built-in
// shouldFilter reorders items by its fuzzy-match score (≈alphabetical with // shouldFilter reorders items by its fuzzy-match score (≈alphabetical with
// an empty query), which destroys the backend's curated order. We disable // an empty query), which destroys the backend's curated order. We disable
@ -68,8 +67,13 @@ export function ModelPickerDialog({
}) })
const providers = modelOptions.data?.providers ?? [] const providers = modelOptions.data?.providers ?? []
const optionsModel = String(modelOptions.data?.model ?? currentModel ?? '')
const optionsProvider = String(modelOptions.data?.provider ?? currentProvider ?? '') const { model: optionsModel, provider: optionsProvider } = currentPickerSelection(
!!sessionId,
{ model: currentModel, provider: currentProvider },
modelOptions.data
)
const loading = modelOptions.isPending && !modelOptions.data const loading = modelOptions.isPending && !modelOptions.data
const error = modelOptions.error const error = modelOptions.error
@ -79,11 +83,7 @@ export function ModelPickerDialog({
: null : null
const selectModel = (provider: ModelOptionProvider, model: string) => { const selectModel = (provider: ModelOptionProvider, model: string) => {
onSelect({ onSelect({ provider: provider.slug, model })
provider: provider.slug,
model,
persistGlobal: persistGlobal || !sessionId
})
onOpenChange(false) onOpenChange(false)
} }
@ -128,24 +128,13 @@ export function ModelPickerDialog({
</CommandList> </CommandList>
</Command> </Command>
<DialogFooter className="flex-row items-center justify-between gap-3 bg-card p-3 sm:justify-between"> <DialogFooter className="flex-row items-center justify-end gap-2 bg-card p-3">
<label className="flex cursor-pointer select-none items-center gap-2 text-xs text-muted-foreground"> <Button onClick={addProvider} variant="ghost">
<Checkbox {copy.addProvider}
checked={persistGlobal || !sessionId} </Button>
disabled={!sessionId} <Button onClick={() => onOpenChange(false)} variant="outline">
onCheckedChange={checked => setPersistGlobal(checked === true)} {t.common.cancel}
/> </Button>
{sessionId ? copy.persistGlobalSession : copy.persistGlobal}
</label>
<div className="flex items-center gap-2">
<Button onClick={addProvider} variant="ghost">
{copy.addProvider}
</Button>
<Button onClick={() => onOpenChange(false)} variant="outline">
{t.common.cancel}
</Button>
</div>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@ -538,6 +538,10 @@ export const en: Translations = {
provider: 'Provider', provider: 'Provider',
model: 'Model', model: 'Model',
applying: 'Applying...', applying: 'Applying...',
defaultsLabel: 'Defaults',
reasoning: 'Reasoning',
reasoningOff: 'Off',
defaultsFailed: 'Failed to save model defaults',
auxiliaryTitle: 'Auxiliary models', auxiliaryTitle: 'Auxiliary models',
resetAllToMain: 'Reset all to main', resetAllToMain: 'Reset all to main',
auxiliaryDesc: 'Helper tasks run on the main model by default. Assign a dedicated model to any task to override.', auxiliaryDesc: 'Helper tasks run on the main model by default. Assign a dedicated model to any task to override.',
@ -565,9 +569,14 @@ export const en: Translations = {
collapse: 'Collapse', collapse: 'Collapse',
connectAnother: 'Connect another provider', connectAnother: 'Connect another provider',
otherProviders: 'Other providers', otherProviders: 'Other providers',
disconnect: 'Disconnect',
disconnectInTerminal: 'Disconnect (runs the removal command in the terminal)',
removeConfirm: provider => `Remove ${provider}?`, removeConfirm: provider => `Remove ${provider}?`,
removeExternal: (provider, command) => `${provider} is managed outside Hermes. Remove it with ${command}.`, removeExternalGeneric: provider => `${provider} is managed by its own CLI — remove it there.`,
removeKeyManaged: provider => `${provider} is configured from an API key. Remove it from API Keys.`, removeKeyManaged: provider => `${provider} is configured from an API key. Remove it from API Keys.`,
removeTerminalConfirm: (provider, command) =>
`Disconnect ${provider}? This runs "${command}" in the terminal to clear the credential.`,
removeTerminalRunning: provider => `Running ${provider} disconnect in the terminal…`,
removedTitle: 'Account removed', removedTitle: 'Account removed',
removedMessage: provider => `${provider} was removed.`, removedMessage: provider => `${provider} was removed.`,
failedRemove: provider => `Could not remove ${provider}`, failedRemove: provider => `Could not remove ${provider}`,
@ -1498,8 +1507,6 @@ export const en: Translations = {
unknown: '(unknown)', unknown: '(unknown)',
search: 'Filter providers and models...', search: 'Filter providers and models...',
noModels: 'No models found.', noModels: 'No models found.',
persistGlobalSession: 'Persist globally (otherwise this session only)',
persistGlobal: 'Persist globally',
addProvider: 'Add provider', addProvider: 'Add provider',
loadFailed: 'Could not load models', loadFailed: 'Could not load models',
noAuthenticatedProviders: 'No authenticated providers.', noAuthenticatedProviders: 'No authenticated providers.',
@ -1726,6 +1733,7 @@ export const en: Translations = {
refresh: 'Refresh', refresh: 'Refresh',
moreActions: 'More actions', moreActions: 'More actions',
branchNewChat: 'Branch in new chat', branchNewChat: 'Branch in new chat',
dismissError: 'Dismiss error',
readAloudFailed: 'Read aloud failed', readAloudFailed: 'Read aloud failed',
preparingAudio: 'Preparing audio...', preparingAudio: 'Preparing audio...',
stopReading: 'Stop reading', stopReading: 'Stop reading',
@ -1835,6 +1843,9 @@ export const en: Translations = {
regenerateFailed: 'Regenerate failed', regenerateFailed: 'Regenerate failed',
editFailed: 'Edit failed', editFailed: 'Edit failed',
resumeFailed: 'Resume failed', resumeFailed: 'Resume failed',
resumeStrandedTitle: "Couldn't load this session",
resumeStrandedBody: 'The connection to this session failed and automatic retries gave up. Check that the gateway is running, then try again.',
resumeRetry: 'Retry',
nothingToBranch: 'Nothing to branch', nothingToBranch: 'Nothing to branch',
branchNeedsChat: 'Start or resume a chat before branching.', branchNeedsChat: 'Start or resume a chat before branching.',
sessionBusy: 'Session busy', sessionBusy: 'Session busy',

View File

@ -695,7 +695,6 @@ export const ja = defineLocale({
connectAnother: '別のプロバイダーを接続', connectAnother: '別のプロバイダーを接続',
otherProviders: 'その他のプロバイダー', otherProviders: 'その他のプロバイダー',
removeConfirm: provider => `${provider} を削除しますか?`, removeConfirm: provider => `${provider} を削除しますか?`,
removeExternal: (provider, command) => `${provider} は Hermes の外部で管理されています。${command} で削除してください。`,
removeKeyManaged: provider => `${provider} は API キーで設定されています。API Keys から削除してください。`, removeKeyManaged: provider => `${provider} は API キーで設定されています。API Keys から削除してください。`,
removedTitle: 'アカウントを削除しました', removedTitle: 'アカウントを削除しました',
removedMessage: provider => `${provider} を削除しました。`, removedMessage: provider => `${provider} を削除しました。`,
@ -1638,8 +1637,6 @@ export const ja = defineLocale({
unknown: '(不明)', unknown: '(不明)',
search: 'プロバイダーとモデルをフィルター...', search: 'プロバイダーとモデルをフィルター...',
noModels: 'モデルが見つかりません。', noModels: 'モデルが見つかりません。',
persistGlobalSession: 'グローバルに保持(それ以外はこのセッションのみ)',
persistGlobal: 'グローバルに保持',
addProvider: 'プロバイダーを追加', addProvider: 'プロバイダーを追加',
loadFailed: 'モデルを読み込めませんでした', loadFailed: 'モデルを読み込めませんでした',
noAuthenticatedProviders: '認証済みプロバイダーがありません。', noAuthenticatedProviders: '認証済みプロバイダーがありません。',
@ -1867,6 +1864,7 @@ export const ja = defineLocale({
refresh: '更新', refresh: '更新',
moreActions: 'その他のアクション', moreActions: 'その他のアクション',
branchNewChat: '新しいチャットでブランチ', branchNewChat: '新しいチャットでブランチ',
dismissError: 'エラーを閉じる',
readAloudFailed: '読み上げに失敗しました', readAloudFailed: '読み上げに失敗しました',
preparingAudio: '音声を準備中...', preparingAudio: '音声を準備中...',
stopReading: '読み上げを停止', stopReading: '読み上げを停止',
@ -1976,6 +1974,9 @@ export const ja = defineLocale({
regenerateFailed: '再生成に失敗しました', regenerateFailed: '再生成に失敗しました',
editFailed: '編集に失敗しました', editFailed: '編集に失敗しました',
resumeFailed: '再開に失敗しました', resumeFailed: '再開に失敗しました',
resumeStrandedTitle: 'このセッションを読み込めませんでした',
resumeStrandedBody: 'このセッションへの接続に失敗し、自動再試行も停止しました。ゲートウェイが実行中か確認してから、もう一度お試しください。',
resumeRetry: '再試行',
nothingToBranch: 'ブランチするものがありません', nothingToBranch: 'ブランチするものがありません',
branchNeedsChat: 'ブランチする前にチャットを開始または再開してください。', branchNeedsChat: 'ブランチする前にチャットを開始または再開してください。',
sessionBusy: 'セッションが使用中', sessionBusy: 'セッションが使用中',

View File

@ -430,6 +430,10 @@ export interface Translations {
provider: string provider: string
model: string model: string
applying: string applying: string
defaultsLabel: string
reasoning: string
reasoningOff: string
defaultsFailed: string
auxiliaryTitle: string auxiliaryTitle: string
resetAllToMain: string resetAllToMain: string
auxiliaryDesc: string auxiliaryDesc: string
@ -447,9 +451,13 @@ export interface Translations {
collapse: string collapse: string
connectAnother: string connectAnother: string
otherProviders: string otherProviders: string
disconnect: string
disconnectInTerminal: string
removeConfirm: (provider: string) => string removeConfirm: (provider: string) => string
removeExternal: (provider: string, command: string) => string removeExternalGeneric: (provider: string) => string
removeKeyManaged: (provider: string) => string removeKeyManaged: (provider: string) => string
removeTerminalConfirm: (provider: string, command: string) => string
removeTerminalRunning: (provider: string) => string
removedTitle: string removedTitle: string
removedMessage: (provider: string) => string removedMessage: (provider: string) => string
failedRemove: (provider: string) => string failedRemove: (provider: string) => string
@ -1141,8 +1149,6 @@ export interface Translations {
unknown: string unknown: string
search: string search: string
noModels: string noModels: string
persistGlobalSession: string
persistGlobal: string
addProvider: string addProvider: string
loadFailed: string loadFailed: string
noAuthenticatedProviders: string noAuthenticatedProviders: string
@ -1367,6 +1373,7 @@ export interface Translations {
refresh: string refresh: string
moreActions: string moreActions: string
branchNewChat: string branchNewChat: string
dismissError: string
readAloudFailed: string readAloudFailed: string
preparingAudio: string preparingAudio: string
stopReading: string stopReading: string
@ -1474,6 +1481,9 @@ export interface Translations {
regenerateFailed: string regenerateFailed: string
editFailed: string editFailed: string
resumeFailed: string resumeFailed: string
resumeStrandedTitle: string
resumeStrandedBody: string
resumeRetry: string
nothingToBranch: string nothingToBranch: string
branchNeedsChat: string branchNeedsChat: string
sessionBusy: string sessionBusy: string

View File

@ -672,7 +672,6 @@ export const zhHant = defineLocale({
connectAnother: '連結其他提供方', connectAnother: '連結其他提供方',
otherProviders: '其他提供方', otherProviders: '其他提供方',
removeConfirm: provider => `移除 ${provider}`, removeConfirm: provider => `移除 ${provider}`,
removeExternal: (provider, command) => `${provider} 由 Hermes 外部管理。請使用 ${command} 移除。`,
removeKeyManaged: provider => `${provider} 由 API 金鑰設定。請從 API Keys 中移除。`, removeKeyManaged: provider => `${provider} 由 API 金鑰設定。請從 API Keys 中移除。`,
removedTitle: '帳號已移除', removedTitle: '帳號已移除',
removedMessage: provider => `${provider} 已移除。`, removedMessage: provider => `${provider} 已移除。`,
@ -1582,8 +1581,6 @@ export const zhHant = defineLocale({
unknown: '(未知)', unknown: '(未知)',
search: '篩選提供方和模型...', search: '篩選提供方和模型...',
noModels: '找不到模型。', noModels: '找不到模型。',
persistGlobalSession: '全域儲存(否則僅限此工作階段)',
persistGlobal: '全域儲存',
addProvider: '新增提供方', addProvider: '新增提供方',
loadFailed: '無法載入模型', loadFailed: '無法載入模型',
noAuthenticatedProviders: '沒有已驗證的提供方。', noAuthenticatedProviders: '沒有已驗證的提供方。',
@ -1809,6 +1806,7 @@ export const zhHant = defineLocale({
refresh: '重新整理', refresh: '重新整理',
moreActions: '更多動作', moreActions: '更多動作',
branchNewChat: '在新聊天中分支', branchNewChat: '在新聊天中分支',
dismissError: '关闭错误',
readAloudFailed: '朗讀失敗', readAloudFailed: '朗讀失敗',
preparingAudio: '正在準備音訊...', preparingAudio: '正在準備音訊...',
stopReading: '停止朗讀', stopReading: '停止朗讀',
@ -1916,6 +1914,9 @@ export const zhHant = defineLocale({
regenerateFailed: '重新生成失敗', regenerateFailed: '重新生成失敗',
editFailed: '編輯失敗', editFailed: '編輯失敗',
resumeFailed: '繼續失敗', resumeFailed: '繼續失敗',
resumeStrandedTitle: '無法載入此工作階段',
resumeStrandedBody: '與此工作階段的連線失敗,自動重試已停止。請確認閘道正在執行,然後重試。',
resumeRetry: '重試',
nothingToBranch: '沒有可分支的內容', nothingToBranch: '沒有可分支的內容',
branchNeedsChat: '分支前請先開始或繼續一個聊天。', branchNeedsChat: '分支前請先開始或繼續一個聊天。',
sessionBusy: '工作階段忙碌中', sessionBusy: '工作階段忙碌中',

View File

@ -733,6 +733,10 @@ export const zh: Translations = {
provider: '提供方', provider: '提供方',
model: '模型', model: '模型',
applying: '应用中...', applying: '应用中...',
defaultsLabel: '默认值',
reasoning: '推理',
reasoningOff: '关闭',
defaultsFailed: '保存模型默认值失败',
auxiliaryTitle: '辅助模型', auxiliaryTitle: '辅助模型',
resetAllToMain: '全部重置为主模型', resetAllToMain: '全部重置为主模型',
auxiliaryDesc: '辅助任务默认使用主模型。你可以为任意任务指定专用模型。', auxiliaryDesc: '辅助任务默认使用主模型。你可以为任意任务指定专用模型。',
@ -759,9 +763,13 @@ export const zh: Translations = {
collapse: '收起', collapse: '收起',
connectAnother: '连接其他提供方', connectAnother: '连接其他提供方',
otherProviders: '其他提供方', otherProviders: '其他提供方',
disconnect: '断开连接',
disconnectInTerminal: '断开连接(在终端中运行移除命令)',
removeConfirm: provider => `移除 ${provider}`, removeConfirm: provider => `移除 ${provider}`,
removeExternal: (provider, command) => `${provider} 由 Hermes 外部管理。请使用 ${command} 移除。`, removeExternalGeneric: provider => `${provider} 由其自身的 CLI 管理 — 请在那里移除。`,
removeKeyManaged: provider => `${provider} 由 API 密钥配置。请从 API Keys 中移除。`, removeKeyManaged: provider => `${provider} 由 API 密钥配置。请从 API Keys 中移除。`,
removeTerminalConfirm: (provider, command) => `断开 ${provider}?这将在终端中运行 "${command}" 以清除凭据。`,
removeTerminalRunning: provider => `正在终端中断开 ${provider}`,
removedTitle: '账号已移除', removedTitle: '账号已移除',
removedMessage: provider => `${provider} 已移除。`, removedMessage: provider => `${provider} 已移除。`,
failedRemove: provider => `无法移除 ${provider}`, failedRemove: provider => `无法移除 ${provider}`,
@ -1679,8 +1687,6 @@ export const zh: Translations = {
unknown: '(未知)', unknown: '(未知)',
search: '筛选提供方和模型...', search: '筛选提供方和模型...',
noModels: '未找到模型。', noModels: '未找到模型。',
persistGlobalSession: '全局保存 (否则仅当前会话)',
persistGlobal: '全局保存',
addProvider: '添加提供方', addProvider: '添加提供方',
loadFailed: '无法加载模型', loadFailed: '无法加载模型',
noAuthenticatedProviders: '没有已认证的提供方。', noAuthenticatedProviders: '没有已认证的提供方。',
@ -1906,6 +1912,7 @@ export const zh: Translations = {
refresh: '刷新', refresh: '刷新',
moreActions: '更多操作', moreActions: '更多操作',
branchNewChat: '在新对话中分支', branchNewChat: '在新对话中分支',
dismissError: '关闭错误',
readAloudFailed: '朗读失败', readAloudFailed: '朗读失败',
preparingAudio: '正在准备音频...', preparingAudio: '正在准备音频...',
stopReading: '停止朗读', stopReading: '停止朗读',
@ -2014,6 +2021,9 @@ export const zh: Translations = {
regenerateFailed: '重新生成失败', regenerateFailed: '重新生成失败',
editFailed: '编辑失败', editFailed: '编辑失败',
resumeFailed: '恢复失败', resumeFailed: '恢复失败',
resumeStrandedTitle: '无法加载此会话',
resumeStrandedBody: '与此会话的连接失败,自动重试已停止。请确认网关正在运行,然后重试。',
resumeRetry: '重试',
nothingToBranch: '没有可分支的内容', nothingToBranch: '没有可分支的内容',
branchNeedsChat: '分支前请先开始或恢复一个对话。', branchNeedsChat: '分支前请先开始或恢复一个对话。',
sessionBusy: '会话忙碌中', sessionBusy: '会话忙碌中',

View File

@ -151,12 +151,18 @@ function normalizeVisibleProse(text: string): string {
.join('') .join('')
} }
function extend(out: string[], lines: string[]) {
for (const line of lines) {
out.push(line)
}
}
function pushProseFence(out: string[], indent: string, info: string, lines: string[]) { function pushProseFence(out: string[], indent: string, info: string, lines: string[]) {
if (info) { if (info) {
out.push(`${indent}${info}`.trimEnd()) out.push(`${indent}${info}`.trimEnd())
} }
out.push(...lines) extend(out, lines)
} }
function findClosingFence(lines: string[], start: number, marker: string): number { function findClosingFence(lines: string[], start: number, marker: string): number {
@ -241,7 +247,7 @@ function normalizeFenceBlocks(text: string): string {
} }
if (closeIndex !== -1 && isUrlOnlyBlock(bodyLines)) { if (closeIndex !== -1 && isUrlOnlyBlock(bodyLines)) {
out.push(...bodyLines) extend(out, bodyLines)
index = closeIndex + 1 index = closeIndex + 1
continue continue
@ -264,10 +270,10 @@ function normalizeFenceBlocks(text: string): string {
// any literal `$$` characters in the body don't collide with // any literal `$$` characters in the body don't collide with
// an outer math wrapper. No close emitted yet — streaming. // an outer math wrapper. No close emitted yet — streaming.
out.push(`${indent}${marker}math`) out.push(`${indent}${marker}math`)
out.push(...bodyLines) extend(out, bodyLines)
} else { } else {
out.push(`${indent}${marker}${language}`) out.push(`${indent}${marker}${language}`)
out.push(...bodyLines) extend(out, bodyLines)
} }
break break
@ -288,7 +294,7 @@ function normalizeFenceBlocks(text: string): string {
// colliding with our wrapper. Without this rewrite the block // colliding with our wrapper. Without this rewrite the block
// would render as a syntax-highlighted "latex" code listing. // would render as a syntax-highlighted "latex" code listing.
out.push(`${indent}${marker}math`) out.push(`${indent}${marker}math`)
out.push(...bodyLines) extend(out, bodyLines)
out.push(`${indent}${marker}`) out.push(`${indent}${marker}`)
index = closeIndex + 1 index = closeIndex + 1
@ -296,7 +302,7 @@ function normalizeFenceBlocks(text: string): string {
} }
out.push(`${indent}${marker}${language}`) out.push(`${indent}${marker}${language}`)
out.push(...bodyLines) extend(out, bodyLines)
out.push(`${indent}${marker}`) out.push(`${indent}${marker}`)
index = closeIndex + 1 index = closeIndex + 1
} }

View File

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { displayModelName, formatModelStatusLabel, reasoningEffortLabel } from './model-status-label' import { currentPickerSelection, displayModelName, formatModelStatusLabel, reasoningEffortLabel } from './model-status-label'
describe('model-status-label', () => { describe('model-status-label', () => {
it('formats display names consistently', () => { it('formats display names consistently', () => {
@ -10,6 +10,11 @@ describe('model-status-label', () => {
expect(displayModelName('openai/gpt-5.5')).toBe('GPT-5.5') expect(displayModelName('openai/gpt-5.5')).toBe('GPT-5.5')
}) })
it('strips trailing date-pin snapshots from the display name', () => {
expect(displayModelName('claude-opus-4-5-20251101')).toBe('Opus 4 5')
expect(displayModelName('anthropic/claude-haiku-4-5-20251001')).toBe('Haiku 4 5')
})
it('maps reasoning effort to compact labels', () => { it('maps reasoning effort to compact labels', () => {
expect(reasoningEffortLabel('high')).toBe('High') expect(reasoningEffortLabel('high')).toBe('High')
expect(reasoningEffortLabel('xhigh')).toBe('Max') expect(reasoningEffortLabel('xhigh')).toBe('Max')
@ -30,4 +35,25 @@ describe('model-status-label', () => {
it('returns just the placeholder name when there is no model', () => { it('returns just the placeholder name when there is no model', () => {
expect(formatModelStatusLabel('')).toBe('No model') expect(formatModelStatusLabel('')).toBe('No model')
}) })
describe('currentPickerSelection', () => {
const store = { model: 'opus', provider: 'anthropic' }
const options = { model: 'hermes-4', provider: 'nous' }
it('prefers the sticky composer pick over the profile default pre-session', () => {
expect(currentPickerSelection(false, store, options)).toEqual(store)
})
it('lets the live session model.options win when a session exists', () => {
expect(currentPickerSelection(true, store, options)).toEqual(options)
})
it('falls back to options when the store is empty', () => {
expect(currentPickerSelection(false, { model: '', provider: '' }, options)).toEqual(options)
})
it('falls back to the store while options are still loading', () => {
expect(currentPickerSelection(true, store, undefined)).toEqual(store)
})
})
}) })

View File

@ -17,6 +17,22 @@ export function reasoningEffortLabel(effort: string): string {
return REASONING_LABELS[key] ?? effort return REASONING_LABELS[key] ?? effort
} }
/** Which model/provider a picker should mark "current". With a live session the
* gateway's `model.options` is authoritative; pre-session there is no server
* "current", so the sticky composer pick wins over the profile default the
* global options query returns else the checkmark snaps back to the default
* and the pick looks ignored. */
export function currentPickerSelection(
hasSession: boolean,
store: { model: string; provider: string },
options?: { model?: string; provider?: string }
): { model: string; provider: string } {
return {
model: String((hasSession && options?.model) || store.model || options?.model || ''),
provider: String((hasSession && options?.provider) || store.provider || options?.provider || '')
}
}
/** Strip provider prefix and normalize for display. */ /** Strip provider prefix and normalize for display. */
export function modelBaseId(model: string): string { export function modelBaseId(model: string): string {
const trimmed = model.trim() const trimmed = model.trim()
@ -68,6 +84,9 @@ export function modelDisplayParts(model: string): { name: string; tag: string }
} }
} }
// Drop a trailing date-pin (`…-20251101`) — snapshot noise, not a name.
base = base.replace(/-\d{8}$/, '')
return { name: prettifyBase(base) || model.trim() || 'No model', tag } return { name: prettifyBase(base) || model.trim() || 'No model', tag }
} }

View File

@ -0,0 +1,51 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { $modelPresets, applyModelPreset, getModelPreset, modelPresetKey, setModelPreset } from './model-presets'
describe('model presets', () => {
beforeEach(() => $modelPresets.set({}))
it('round-trips a preset and merges patches without dropping prior fields', () => {
setModelPreset('anthropic', 'claude-opus-4-8', { effort: 'high' })
setModelPreset('anthropic', 'claude-opus-4-8', { fast: true })
expect(getModelPreset('anthropic', 'claude-opus-4-8')).toEqual({ effort: 'high', fast: true })
})
it('returns an empty preset for unknown models', () => {
expect(getModelPreset('x', 'y')).toEqual({})
})
it('keys by provider::model', () => {
expect(modelPresetKey('openai', 'gpt-5.5')).toBe('openai::gpt-5.5')
})
it('pushes only the provided dimensions to the gateway', async () => {
const calls: { method: string; params?: Record<string, unknown> }[] = []
const request = async <T>(method: string, params?: Record<string, unknown>) => {
calls.push({ method, params })
return {} as T
}
await applyModelPreset({ effort: 'high' }, { failMessage: 'x', request, sessionId: 's1' })
await applyModelPreset({}, { failMessage: 'x', request, sessionId: 's1' })
expect(calls).toEqual([{ method: 'config.set', params: { key: 'reasoning', session_id: 's1', value: 'high' } }])
})
it('no-ops without a session so selecting a model cannot mutate global config', async () => {
const calls: { method: string; params?: Record<string, unknown> }[] = []
const request = async <T>(method: string, params?: Record<string, unknown>) => {
calls.push({ method, params })
return {} as T
}
await applyModelPreset({ effort: 'high', fast: true }, { failMessage: 'x', request, sessionId: null })
expect(calls).toEqual([])
})
})

View File

@ -0,0 +1,86 @@
import { atom } from 'nanostores'
import { persistString, storedString } from '@/lib/storage'
import { notifyError } from './notifications'
import { setCurrentFastMode, setCurrentReasoningEffort } from './session'
const STORAGE_KEY = 'hermes.desktop.model-presets'
/** Per-model reasoning/fast preset, remembered globally across sessions and
* re-applied to the session whenever that model is selected. Unset dimensions
* fall back to the Hermes default (medium effort, no fast). */
export interface ModelPreset {
effort?: string
fast?: boolean
}
type RequestGateway = <T>(method: string, params?: Record<string, unknown>) => Promise<T>
/** Stable `provider::model` key (matches the visibility-store format). */
export const modelPresetKey = (provider: string, model: string): string => `${provider}::${model}`
function load(): Record<string, ModelPreset> {
const raw = storedString(STORAGE_KEY)
if (!raw) {
return {}
}
try {
const parsed = JSON.parse(raw)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record<string, ModelPreset>) : {}
} catch {
return {}
}
}
export const $modelPresets = atom<Record<string, ModelPreset>>(load())
export function getModelPreset(provider: string, model: string): ModelPreset {
return $modelPresets.get()[modelPresetKey(provider, model)] ?? {}
}
/** Merge a partial preset for one model and persist. */
export function setModelPreset(provider: string, model: string, patch: ModelPreset): void {
const key = modelPresetKey(provider, model)
const next = { ...$modelPresets.get(), [key]: { ...$modelPresets.get()[key], ...patch } }
$modelPresets.set(next)
persistString(STORAGE_KEY, JSON.stringify(next))
}
/** Push a model's preset onto the active session (optimistic + gateway).
* `undefined` skips that dimension; values are capability-gated upstream.
* No-ops without a session the gateway's `config.set` reasoning/fast fall
* back to persistent (global/profile) config when none matches, so selecting
* a model must not reach it (else it rewrites `agent.*`, defaults included). */
export async function applyModelPreset(
{ effort, fast }: ModelPreset,
ctx: { failMessage: string; request: RequestGateway; sessionId: null | string }
): Promise<void> {
if (!ctx.sessionId) {
return
}
if (effort !== undefined) {
setCurrentReasoningEffort(effort)
}
if (fast !== undefined) {
setCurrentFastMode(fast)
}
try {
if (effort !== undefined) {
await ctx.request('config.set', { key: 'reasoning', session_id: ctx.sessionId, value: effort })
}
if (fast !== undefined) {
await ctx.request('config.set', { key: 'fast', session_id: ctx.sessionId, value: fast ? 'fast' : 'normal' })
}
} catch (err) {
notifyError(err, ctx.failMessage)
}
}

View File

@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
import type { ModelOptionProvider } from '@/types/hermes' import type { ModelOptionProvider } from '@/types/hermes'
import { import {
collapseModelFamilies,
effectiveVisibleKeys, effectiveVisibleKeys,
emptyProviderSentinelKey, emptyProviderSentinelKey,
isProviderSentinel, isProviderSentinel,
@ -78,6 +79,18 @@ describe('model visibility', () => {
expect(visible.has(modelVisibilityKey('nous', 'hermes-3-llama-3.1-8b'))).toBe(false) expect(visible.has(modelVisibilityKey('nous', 'hermes-3-llama-3.1-8b'))).toBe(false)
}) })
it('folds a date-pinned snapshot into its rolling alias when present', () => {
const families = collapseModelFamilies(['claude-opus-4-5', 'claude-opus-4-5-20251101'])
expect(families.map(f => f.id)).toEqual(['claude-opus-4-5'])
})
it('keeps a date-pinned snapshot standing alone when it has no alias', () => {
const families = collapseModelFamilies(['claude-opus-4-5-20251101', 'claude-haiku-4-5-20251001'])
expect(families.map(f => f.id)).toEqual(['claude-opus-4-5-20251101', 'claude-haiku-4-5-20251001'])
})
it('sentinel key helper produces correct format', () => { it('sentinel key helper produces correct format', () => {
expect(emptyProviderSentinelKey('openai')).toBe('openai::') expect(emptyProviderSentinelKey('openai')).toBe('openai::')
expect(isProviderSentinel('openai::')).toBe(true) expect(isProviderSentinel('openai::')).toBe(true)

View File

@ -51,6 +51,11 @@ export function collapseModelFamilies(models: readonly string[]): ModelFamily[]
continue continue
} }
if (/-\d{8}$/.test(model) && present.has(model.replace(/-\d{8}$/, ''))) {
// A date-pinned snapshot superseded by its rolling alias — drop the dupe.
continue
}
const fastId = `${model}-fast` const fastId = `${model}-fast`
const hasFast = present.has(fastId) const hasFast = present.has(fastId)
families.push({ fastId: hasFast ? fastId : null, id: model }) families.push({ fastId: hasFast ? fastId : null, id: model })

View File

@ -4,13 +4,23 @@ import { lastVisibleMessageIsUser } from '@/app/chat/thread-loading'
import type { ContextSuggestion } from '@/app/types' import type { ContextSuggestion } from '@/app/types'
import type { HermesConnection } from '@/global' import type { HermesConnection } from '@/global'
import type { ChatMessage } from '@/lib/chat-messages' import type { ChatMessage } from '@/lib/chat-messages'
import { persistString, storedString } from '@/lib/storage' import { persistBoolean, persistString, storedBoolean, storedString } from '@/lib/storage'
import type { SessionInfo, UsageStats } from '@/types/hermes' import type { SessionInfo, UsageStats } from '@/types/hermes'
type Updater<T> = T | ((current: T) => T) type Updater<T> = T | ((current: T) => T)
const WORKSPACE_CWD_KEY = 'hermes.desktop.workspace-cwd' const WORKSPACE_CWD_KEY = 'hermes.desktop.workspace-cwd'
// The composer's model/effort/fast is sticky UI state, NOT the profile default
// (that lives in Settings → Model). Persisting it in localStorage makes a pick
// follow across Cmd+N and app restarts instead of snapping back to the default.
// It's deliberately global (not per-profile): a profile switch force-reseeds to
// that profile's default, while within a profile new chats keep your last pick.
const COMPOSER_MODEL_KEY = 'hermes.desktop.composer.model'
const COMPOSER_PROVIDER_KEY = 'hermes.desktop.composer.provider'
const COMPOSER_EFFORT_KEY = 'hermes.desktop.composer.reasoning-effort'
const COMPOSER_FAST_KEY = 'hermes.desktop.composer.fast'
let configuredDefaultProjectDir = '' let configuredDefaultProjectDir = ''
function workspaceCwdKey(connection: HermesConnection | null = $connection.get()): string { function workspaceCwdKey(connection: HermesConnection | null = $connection.get()): string {
@ -208,11 +218,28 @@ export const $lastVisibleMessageIsUser = computed($messages, lastVisibleMessageI
export const $freshDraftReady = atom(false) export const $freshDraftReady = atom(false)
export const $busy = atom(false) export const $busy = atom(false)
export const $awaitingResponse = atom(false) export const $awaitingResponse = atom(false)
export const $currentModel = atom('') // Stored-session id whose most recent resume FAILED terminally (the gateway RPC
export const $currentProvider = atom('') // rejected AND the REST transcript fallback also failed), leaving the window
export const $currentReasoningEffort = atom('') // with no runtime and an empty transcript. Drives use-route-resume's self-heal:
// while this matches the routed session the loader would otherwise latch
// forever (messagesEmpty && !activeSessionId), so the hook re-attempts the
// resume on the next render/focus/reconnect instead of stranding the window.
// Null whenever the active route has a healthy (or in-flight) resume.
export const $resumeFailedSessionId = atom<string | null>(null)
// Stored-session id whose resume has EXHAUSTED its bounded auto-retries (the
// terminal-failure latch above kept failing through all MAX_RESUME_RETRIES
// attempts). Distinct from $resumeFailedSessionId, which is armed *during* the
// backoff window too: this fires only once auto-recovery has given up, so the
// chat view can swap the perpetual loader for an explicit error + manual Retry
// affordance. A fresh resumeSession() (manual Retry, reconnect, reselect)
// clears it and resets the retry counter. Null whenever the active route has a
// healthy, in-flight, or still-auto-retrying resume.
export const $resumeExhaustedSessionId = atom<string | null>(null)
export const $currentModel = atom(storedString(COMPOSER_MODEL_KEY) ?? '')
export const $currentProvider = atom(storedString(COMPOSER_PROVIDER_KEY) ?? '')
export const $currentReasoningEffort = atom(storedString(COMPOSER_EFFORT_KEY) ?? '')
export const $currentServiceTier = atom('') export const $currentServiceTier = atom('')
export const $currentFastMode = atom(false) export const $currentFastMode = atom(storedBoolean(COMPOSER_FAST_KEY, false))
// Effective approval-bypass state mirrored from the gateway (session.info). // Effective approval-bypass state mirrored from the gateway (session.info).
// Persistence lives in the backend config (approvals.mode), so this is a plain // Persistence lives in the backend config (approvals.mode), so this is a plain
// reflection of the truth the gateway reports rather than its own store. // reflection of the truth the gateway reports rather than its own store.
@ -252,13 +279,33 @@ export const setActiveSessionId = (next: Updater<string | null>) => updateAtom($
export const setSelectedStoredSessionId = (next: Updater<string | null>) => updateAtom($selectedStoredSessionId, next) export const setSelectedStoredSessionId = (next: Updater<string | null>) => updateAtom($selectedStoredSessionId, next)
export const setMessages = (next: Updater<ChatMessage[]>) => updateAtom($messages, next) export const setMessages = (next: Updater<ChatMessage[]>) => updateAtom($messages, next)
export const setFreshDraftReady = (next: Updater<boolean>) => updateAtom($freshDraftReady, next) export const setFreshDraftReady = (next: Updater<boolean>) => updateAtom($freshDraftReady, next)
export const setResumeFailedSessionId = (next: Updater<string | null>) => updateAtom($resumeFailedSessionId, next)
export const setResumeExhaustedSessionId = (next: Updater<string | null>) => updateAtom($resumeExhaustedSessionId, next)
export const setBusy = (next: Updater<boolean>) => updateAtom($busy, next) export const setBusy = (next: Updater<boolean>) => updateAtom($busy, next)
export const setAwaitingResponse = (next: Updater<boolean>) => updateAtom($awaitingResponse, next) export const setAwaitingResponse = (next: Updater<boolean>) => updateAtom($awaitingResponse, next)
export const setCurrentModel = (next: Updater<string>) => updateAtom($currentModel, next)
export const setCurrentProvider = (next: Updater<string>) => updateAtom($currentProvider, next) export const setCurrentModel = (next: Updater<string>) => {
export const setCurrentReasoningEffort = (next: Updater<string>) => updateAtom($currentReasoningEffort, next) updateAtom($currentModel, next)
persistString(COMPOSER_MODEL_KEY, $currentModel.get() || null)
}
export const setCurrentProvider = (next: Updater<string>) => {
updateAtom($currentProvider, next)
persistString(COMPOSER_PROVIDER_KEY, $currentProvider.get() || null)
}
export const setCurrentReasoningEffort = (next: Updater<string>) => {
updateAtom($currentReasoningEffort, next)
persistString(COMPOSER_EFFORT_KEY, $currentReasoningEffort.get() || null)
}
export const setCurrentServiceTier = (next: Updater<string>) => updateAtom($currentServiceTier, next) export const setCurrentServiceTier = (next: Updater<string>) => updateAtom($currentServiceTier, next)
export const setCurrentFastMode = (next: Updater<boolean>) => updateAtom($currentFastMode, next)
export const setCurrentFastMode = (next: Updater<boolean>) => {
updateAtom($currentFastMode, next)
persistBoolean(COMPOSER_FAST_KEY, $currentFastMode.get())
}
export const setYoloActive = (next: Updater<boolean>) => updateAtom($yoloActive, next) export const setYoloActive = (next: Updater<boolean>) => updateAtom($yoloActive, next)
export const setCurrentCwd = (next: Updater<string>) => { export const setCurrentCwd = (next: Updater<string>) => {

View File

@ -5,6 +5,9 @@ import type { DesktopUpdateStatus } from '@/global'
const storage = new Map<string, string>() const storage = new Map<string, string>()
vi.mock('@/lib/storage', () => ({ vi.mock('@/lib/storage', () => ({
persistBoolean: (key: string, value: boolean) => {
storage.set(key, String(value))
},
persistString: (key: string, value: null | string) => { persistString: (key: string, value: null | string) => {
if (value === null) { if (value === null) {
storage.delete(key) storage.delete(key)
@ -12,6 +15,11 @@ vi.mock('@/lib/storage', () => ({
storage.set(key, value) storage.set(key, value)
} }
}, },
storedBoolean: (key: string, fallback: boolean) => {
const value = storage.get(key)
return value === undefined ? fallback : value === 'true'
},
storedString: (key: string) => storage.get(key) ?? null storedString: (key: string) => storage.get(key) ?? null
})) }))
@ -33,7 +41,7 @@ vi.mock('@/hermes', () => ({
getActionStatus: (...args: unknown[]) => getActionStatusSpy(...args) getActionStatus: (...args: unknown[]) => getActionStatusSpy(...args)
})) }))
const { maybeNotifyUpdateAvailable, checkBackendUpdates, $backendUpdateStatus, applyBackendUpdate, $backendUpdateApply } = await import('./updates') const { maybeNotifyUpdateAvailable, checkBackendUpdates, $backendUpdateStatus, applyBackendUpdate, $backendUpdateApply, reportBackendContract } = await import('./updates')
const { setConnection } = await import('./session') const { setConnection } = await import('./session')
const status = (over: Partial<DesktopUpdateStatus> = {}): DesktopUpdateStatus => ({ const status = (over: Partial<DesktopUpdateStatus> = {}): DesktopUpdateStatus => ({
@ -87,6 +95,61 @@ describe('maybeNotifyUpdateAvailable', () => {
}) })
}) })
describe('reportBackendContract', () => {
beforeEach(() => {
storage.clear()
notifySpy.mockClear()
dismissSpy.mockClear()
vi.useRealTimers()
})
it('dismisses the toast when the backend meets the contract', () => {
reportBackendContract(2)
expect(dismissSpy).toHaveBeenCalledWith('backend-contract-skew')
expect(notifySpy).not.toHaveBeenCalled()
})
it('warns when the backend is behind (or reports no contract)', () => {
reportBackendContract(undefined)
expect(notifySpy).toHaveBeenCalledTimes(1)
reportBackendContract(1)
expect(notifySpy).toHaveBeenCalledTimes(2)
})
it('stays quiet on later session opens once the user closed it', () => {
reportBackendContract(1)
lastToast().onDismiss() // user closes it → cooldown starts
notifySpy.mockClear()
// Opening another pre-existing session re-runs the check within cooldown.
reportBackendContract(1)
expect(notifySpy).not.toHaveBeenCalled()
})
it('reminds again after the cooldown elapses', () => {
vi.useFakeTimers()
vi.setSystemTime(0)
reportBackendContract(1)
lastToast().onDismiss()
notifySpy.mockClear()
vi.setSystemTime(25 * 60 * 60 * 1000) // > 24h cooldown
reportBackendContract(1)
expect(notifySpy).toHaveBeenCalledTimes(1)
})
it('clears the snooze once the backend catches up, so a regression warns again', () => {
reportBackendContract(1)
lastToast().onDismiss()
notifySpy.mockClear()
reportBackendContract(2) // backend updated → satisfied, snooze cleared
reportBackendContract(1) // a later regression must warn immediately
expect(notifySpy).toHaveBeenCalledTimes(1)
})
})
describe('checkBackendUpdates', () => { describe('checkBackendUpdates', () => {
beforeEach(() => { beforeEach(() => {
storage.clear() storage.clear()

View File

@ -91,26 +91,60 @@ function isUpdateToastSnoozed(): boolean {
// v2: requires the file.attach RPC (remote-gateway non-image file upload). // v2: requires the file.attach RPC (remote-gateway non-image file upload).
const REQUIRED_BACKEND_CONTRACT = 2 const REQUIRED_BACKEND_CONTRACT = 2
const SKEW_TOAST_ID = 'backend-contract-skew' const SKEW_TOAST_ID = 'backend-contract-skew'
// The contract check runs on every session.resume (applyRuntimeInfo), so
// without a snooze the warning re-popped on every thread the user opened, even
// right after they closed it. Mirror the update toast: persist a cooldown when
// the user dismisses it. It still reminds again after the window if the backend
// is still behind, and clears immediately once the backend catches up.
const SKEW_TOAST_SNOOZE_KEY = 'hermes:backend-skew-toast-snooze-until'
const SKEW_TOAST_COOLDOWN_MS = 24 * 60 * 60 * 1000
function snoozeSkewToast(): void {
persistString(SKEW_TOAST_SNOOZE_KEY, String(Date.now() + SKEW_TOAST_COOLDOWN_MS))
}
function isSkewToastSnoozed(): boolean {
const until = Number(storedString(SKEW_TOAST_SNOOZE_KEY) || 0)
return Number.isFinite(until) && Date.now() < until
}
/** /**
* Guard against a desktop GUI talking to a backend that predates its contract * Guard against a desktop GUI talking to a backend that predates its contract
* (e.g. a bb/gui-built app pointed at a `main` checkout). Rather than failing * (e.g. a bb/gui-built app pointed at a `main` checkout). Rather than failing
* cryptically downstream, surface a persistent warning with a one-click align * cryptically downstream, surface a warning with a one-click align that runs
* that runs the normal update flow (which self-heals to the right branch). * the normal update flow (which self-heals to the right branch).
*
* Runs on every session open; closing the toast snoozes it for a cooldown so it
* doesn't nag on every thread switch.
*/ */
export function reportBackendContract(contract: number | undefined): void { export function reportBackendContract(contract: number | undefined): void {
if ((contract ?? 0) >= REQUIRED_BACKEND_CONTRACT) { if ((contract ?? 0) >= REQUIRED_BACKEND_CONTRACT) {
dismissNotification(SKEW_TOAST_ID) dismissNotification(SKEW_TOAST_ID)
// Backend caught up — forget any prior snooze so a future regression warns
// immediately rather than staying silent for the rest of the window.
persistString(SKEW_TOAST_SNOOZE_KEY, null)
return return
} }
if (isSkewToastSnoozed()) {
return
}
notify({ notify({
action: { label: translateNow('notifications.updateHermes'), onClick: () => void applyBackendUpdate() }, action: {
label: translateNow('notifications.updateHermes'),
onClick: () => {
snoozeSkewToast()
void applyBackendUpdate()
}
},
durationMs: 0, durationMs: 0,
id: SKEW_TOAST_ID, id: SKEW_TOAST_ID,
kind: 'warning', kind: 'warning',
message: translateNow('notifications.backendOutOfDateMessage'), message: translateNow('notifications.backendOutOfDateMessage'),
onDismiss: () => snoozeSkewToast(),
title: translateNow('notifications.backendOutOfDateTitle') title: translateNow('notifications.backendOutOfDateTitle')
}) })
} }

View File

@ -47,6 +47,9 @@ export interface OAuthProviderStatus {
export interface OAuthProvider { export interface OAuthProvider {
cli_command: string cli_command: string
/** Shell command that clears an external provider's credentials, run in the
* embedded terminal. Null when Hermes doesn't know how to remove it. */
disconnect_command?: null | string
disconnect_hint?: null | string disconnect_hint?: null | string
disconnectable?: boolean disconnectable?: boolean
docs_url: string docs_url: string

680
cli.py
View File

@ -1984,6 +1984,24 @@ _ACCENT = _SkinAwareAnsi("response_border", "#FFD700", bold=True)
_DIM = "\x1b[2;3m" _DIM = "\x1b[2;3m"
def _b(s: str) -> str:
"""Bold if stdout is a real TTY; plain text otherwise (slash-worker safe)."""
import sys as _sys
try:
return f"\x1b[1m{s}\x1b[0m" if _sys.stdout.isatty() else str(s)
except Exception:
return str(s)
def _d(s: str) -> str:
"""Dim-italic if stdout is a real TTY; plain text otherwise."""
import sys as _sys
try:
return f"\x1b[2;3m{s}\x1b[0m" if _sys.stdout.isatty() else str(s)
except Exception:
return str(s)
def _accent_hex() -> str: def _accent_hex() -> str:
"""Return the active skin accent color for legacy CLI output lines.""" """Return the active skin accent color for legacy CLI output lines."""
try: try:
@ -3664,7 +3682,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
if getattr(self, "_resize_recovery_pending", False): if getattr(self, "_resize_recovery_pending", False):
return return
now = time.monotonic() now = time.monotonic()
if hasattr(self, "_app") and self._app and (now - self._last_invalidate) >= min_interval: if hasattr(self, "_app") and self._app and (now - getattr(self, "_last_invalidate", 0.0)) >= min_interval:
self._last_invalidate = now self._last_invalidate = now
self._app.invalidate() self._app.invalidate()
@ -5957,6 +5975,18 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
old_session_id = self.session_id old_session_id = self.session_id
if self._session_db and old_session_id: if self._session_db and old_session_id:
# Flush any un-persisted messages from the current turn to the
# old session *before* rotating. /new can be called mid-turn
# when _flush_messages_to_session_db() has not yet run — without
# this, messages generated during the current turn are silently
# lost on session rotation (#47202).
if self.agent:
try:
self.agent._flush_messages_to_session_db(
self.conversation_history
)
except Exception:
pass # best-effort
try: try:
self._session_db.end_session(old_session_id, "new_session") self._session_db.end_session(old_session_id, "new_session")
except Exception: except Exception:
@ -6359,6 +6389,17 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
in_main_thread = threading.current_thread() is threading.main_thread() in_main_thread = threading.current_thread() is threading.main_thread()
# Slash-worker guard (#23185 / billing auto-reload hang): when a
# prompt_toolkit app is running but we're on a non-main thread (the
# process_loop / TUI slash-worker daemon thread), stdin is owned by the
# event loop / JSON-RPC pipe. A bare input() there blocks forever until
# the worker's 45s timeout fires. We cannot safely prompt off the main
# thread, so cancel cleanly (None) instead of hanging — mirrors the
# _stdin_fallback discipline in _prompt_text_input_modal.
if self._app and not in_main_thread:
self._invalidate()
return None
if self._app and in_main_thread: if self._app and in_main_thread:
from prompt_toolkit.application import run_in_terminal from prompt_toolkit.application import run_in_terminal
was_visible = self._status_bar_visible was_visible = self._status_bar_visible
@ -6930,7 +6971,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
try: try:
if ctx is None: if ctx is None:
raise RuntimeError("inventory context unavailable") raise RuntimeError("inventory context unavailable")
providers = build_models_payload(ctx, max_models=50)["providers"] providers = build_models_payload(ctx)["providers"]
except Exception: except Exception:
providers = [] providers = []
@ -7506,6 +7547,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
self._show_usage() self._show_usage()
elif canonical == "credits": elif canonical == "credits":
self._show_credits() self._show_credits()
elif canonical == "billing":
self._show_billing(cmd_original)
elif canonical == "insights": elif canonical == "insights":
self._show_insights(cmd_original) self._show_insights(cmd_original)
elif canonical == "copy": elif canonical == "copy":
@ -8425,7 +8468,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
if not view.logged_in: if not view.logged_in:
print() print()
print(f" 💳 {_DIM}Not logged into Nous Portal.{_RST}") _cprint(f" 💳 {_d('Not logged into Nous Portal.')}")
print(" Run `hermes portal` to log in, then /credits.") print(" Run `hermes portal` to log in, then /credits.")
return return
@ -8487,6 +8530,628 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
else: else:
print(" 🟡 Cancelled. No credits added.") print(" 🟡 Cancelled. No credits added.")
# ------------------------------------------------------------------
# /billing — Phase 2b terminal billing (CLI surface, all 5 screens)
# ------------------------------------------------------------------
def _show_billing(self, command: str = "/billing"):
"""`/billing` — terminal billing for Nous (one interactive modal).
ZERO sub-commands: any argument is ignored. Bare ``/billing`` always
opens the Overview (Screen 1), whose numbered menu is the *only* way to
reach the Buy / Auto-reload / Monthly-limit sub-screens. (Per the unified
UX spec §0.4 ``/billing buy`` etc. are gone; we don't error on a stray
arg, we just open the menu.)
Interactive CLI uses the prompt_toolkit modal; non-interactive contexts
(TUI slash-worker / no live app) render text + the portal deep-link, never
prompting (the URL is the affordance), same discipline as ``_show_credits``.
All money is Decimal end-to-end; the terminal never collects card details.
"""
from agent.billing_view import build_billing_state
state = build_billing_state()
if not state.logged_in:
print()
if state.error:
_msg = f"Couldn't load billing: {state.error}"
_cprint(f" 💳 {_d(_msg)}")
else:
_cprint(f" 💳 {_d('Not logged into Nous Portal.')}")
print(" Run `hermes portal` to log in, then /billing.")
return
# Any sub-arg is intentionally ignored — always open the menu.
self._billing_overview(state)
def _billing_portal_hint(self, state, *, reason: str = "") -> None:
"""Print a portal deep-link line (the funnel for portal-only actions)."""
url = getattr(state, "portal_url", None)
if not url:
return
if reason:
print(f" {reason}")
print(f" Manage on portal: {url}")
def _billing_overview(self, state):
"""Screen 1 — overview: balance, spend bar, role-gated action menu."""
from agent.billing_view import format_money
print()
_cprint(f" 💳 {_b('Usage credits')}")
print(f" {'' * 41}")
cap = state.monthly_cap
if cap is not None and cap.limit_usd is not None:
spent = format_money(cap.spent_this_month_usd)
limit = format_money(cap.limit_usd)
ceiling = " (default ceiling)" if cap.is_default_ceiling else ""
bar, pct = self._billing_spend_bar(
cap.spent_this_month_usd, cap.limit_usd
)
print(f" {spent} of {limit} used{ceiling} {bar} {pct}%")
print(f" Balance: {format_money(state.balance_usd)}")
ar = state.auto_reload
if ar is not None:
if ar.enabled:
print(
f" Auto-reload: on — below {format_money(ar.threshold_usd)} "
f"→ reload to {format_money(ar.reload_to_usd)}"
)
else:
print(" Auto-reload: off")
if state.org_name:
role = (state.role or "").title()
_org_line = f"Org: {state.org_name}{f' · {role}' if role else ''}"
_cprint(f" {_d(_org_line)}")
print(f" {'' * 41}")
# Action gating: admin + kill-switch for charge/auto-reload; everyone gets portal.
if not state.is_admin:
_cprint(f" {_d('Billing actions require an org admin/owner.')}")
self._billing_portal_hint(state)
return
if not state.cli_billing_enabled:
_cprint(f" {_d('Terminal billing is turned off for this org.')}")
self._billing_portal_hint(state, reason="Enable it on the portal to buy credits here.")
return
# Optimistic funnel: no card on file → a charge will 403 no_payment_method.
# Surface that up front (with the portal link) but DON'T hide Buy — /state.card
# can't fully prove CLI-chargeability, so we advise rather than gate.
if state.card is None:
_cprint(
f" {_d('No saved card for terminal charges yet — set one up on the portal first.')}"
)
self._billing_portal_hint(state)
# Non-interactive (slash-worker / no live app): no modal, no sub-command
# advertising — just the portal funnel (the URL is the affordance).
if not getattr(self, "_app", None):
self._billing_portal_hint(state)
return
choices = [
("buy", "Buy credits", "purchase a one-time credit top-up"),
("auto", "Adjust auto-reload", "configure automatic top-ups"),
("limit", "Adjust monthly limit", "show the monthly spend cap (read-only)"),
("portal", "Manage on portal", "open the billing page in your browser"),
("cancel", "Cancel", "do nothing"),
]
# The overview summary is already printed above; the modal only needs to
# present the action menu — repeating the title/balance reads as a dupe.
raw = self._prompt_text_input_modal(
title="💳 Choose an action", detail="",
choices=choices,
)
choice = self._normalize_slash_confirm_choice(raw, choices)
if choice == "buy":
self._billing_buy_flow(state)
elif choice == "auto":
self._billing_auto_reload_flow(state)
elif choice == "limit":
self._billing_limit_screen(state)
elif choice == "portal":
self._billing_open_portal(state)
else:
print(" 🟡 Cancelled.")
def _billing_spend_bar(self, spent, limit, *, cells: int = 10):
"""Render a 10-cell `█`/`░` spend bar + integer percent from spent/limit.
Returns ``(bar, pct)`` where ``bar`` is like ``[]`` and ``pct``
is the spent/limit percentage clamped to 0..100. Box-drawing glyphs are
not SGR codes, so this is leak-safe even without ``_b()``/``_d()``.
"""
from decimal import Decimal
try:
s = Decimal(str(spent)) if spent is not None else Decimal("0")
l = Decimal(str(limit)) if limit is not None else Decimal("0")
except Exception:
s, l = Decimal("0"), Decimal("0")
if l <= 0:
pct = 0
else:
pct = int((s / l) * 100)
pct = max(0, min(100, pct))
filled = int(round(pct / 100 * cells))
filled = max(0, min(cells, filled))
bar = ("" * filled) + ("" * (cells - filled))
return bar, pct
def _billing_open_portal(self, state):
url = getattr(state, "portal_url", None)
if not url:
print(" No portal URL available.")
return
opened = False
try:
import webbrowser
opened = webbrowser.open(url)
except Exception:
opened = False
if not opened:
print(f" Open this URL: {url}")
print(" Complete billing changes in the browser.")
def _billing_require_admin(self, state) -> bool:
"""Guard charge/auto-reload entry points; print + return False if blocked."""
if not state.is_admin:
print()
_cprint(f" 💳 {_d('Billing actions require an org admin/owner.')}")
self._billing_portal_hint(state)
return False
if not state.cli_billing_enabled:
print()
_cprint(f" 💳 {_d('Terminal billing is turned off for this org.')}")
self._billing_portal_hint(state, reason="Enable it on the portal first.")
return False
return True
def _billing_buy_flow(self, state):
"""Screen 2 (preset select) → Screen 3 (confirm + charge + poll)."""
from agent.billing_view import format_money, validate_charge_amount
if not self._billing_require_admin(state):
return
# Screen 3 — preset selection.
if not getattr(self, "_app", None):
presets = ", ".join(format_money(p) for p in state.charge_presets)
print()
_cprint(f" 💳 {_b('Buy usage credits')}")
print(f" Presets: {presets}")
print(" Run this in the interactive CLI to complete a purchase.")
self._billing_portal_hint(state)
return
preset_choices = []
for p in state.charge_presets:
preset_choices.append((str(p), format_money(p), "one-time credit purchase"))
preset_choices.append(("custom", "Custom amount…", "enter your own amount"))
preset_choices.append(("cancel", "Cancel", "do nothing"))
card = state.card
detail = f"Payment: {card.masked}" if card else "No saved card on file"
raw = self._prompt_text_input_modal(
title="💳 Buy usage credits", detail=detail, choices=preset_choices,
)
choice = self._normalize_slash_confirm_choice(raw, preset_choices)
if not choice or choice == "cancel":
print(" 🟡 Cancelled. No credits added.")
return
from decimal import Decimal
if choice == "custom":
entered = self._prompt_text_input(" Amount (USD): ")
if entered is None:
# None = cancelled (e.g. slash-worker can't prompt off-thread).
print(" 🟡 Cancelled. No credits added.")
return
v = validate_charge_amount(
entered or "", min_usd=state.min_usd, max_usd=state.max_usd
)
if not v.ok:
print(f" 🔴 {v.error}")
return
amount = v.amount
else:
try:
amount = Decimal(choice)
except Exception:
print(" 🔴 Invalid selection.")
return
self._billing_confirm_and_charge(state, amount)
def _billing_confirm_and_charge(self, state, amount):
"""Screen 3 — confirm total + consent, charge, then poll to settlement."""
from agent.billing_view import format_money, new_idempotency_key
card = state.card
print()
_cprint(f" 💳 {_b('Confirm purchase')}")
print(f" {'' * 41}")
print(f" Total: {format_money(amount)}")
if card:
print(f" Payment: {card.masked}")
print(f" {'' * 41}")
_consent = (
"By confirming, you allow Nous Research to charge your card."
)
_cprint(f" {_d(_consent)}")
confirm_choices = [
("pay", f"Pay {format_money(amount)} now", "submit the charge"),
("cancel", "Go back", "do not charge"),
]
if not getattr(self, "_app", None):
print(" Run in the interactive CLI to confirm a purchase.")
return
raw = self._prompt_text_input_modal(
title=f"💳 Pay {format_money(amount)}?",
detail=(card.masked if card else "no saved card"),
choices=confirm_choices,
)
choice = self._normalize_slash_confirm_choice(raw, confirm_choices)
if choice != "pay":
print(" 🟡 Cancelled. No credits added.")
return
# Submit the charge with a fresh idempotency key (reused on retry).
from hermes_cli.nous_billing import (
BillingError,
BillingScopeRequired,
post_charge,
)
key = new_idempotency_key()
try:
result = post_charge(amount_usd=amount, idempotency_key=key)
except BillingScopeRequired:
self._billing_handle_scope_required(state)
return
except BillingError as exc:
self._billing_render_charge_error(state, exc)
return
charge_id = result.get("chargeId")
if not charge_id:
print(" 🔴 No charge id returned; please check the portal.")
return
_cprint(f" {_d('Charge submitted — confirming settlement…')}")
self._billing_poll_charge(state, charge_id, amount)
def _billing_poll_charge(self, state, charge_id, amount):
"""Poll loop: 2s interval, 5-min cap, cancellable. settled = ledger truth."""
import time as _time
from agent.billing_view import format_money
from hermes_cli.nous_billing import (
BillingError,
BillingRateLimited,
get_charge_status,
)
deadline = _time.time() + 300 # 5-minute cap
interval = 2.0
while _time.time() < deadline:
try:
status = get_charge_status(charge_id)
except BillingRateLimited as exc:
# Retry-after, NOT a failure — back off and keep polling.
wait = exc.retry_after or 5
_time.sleep(min(wait, 30))
continue
except BillingError as exc:
print(f" 🔴 Could not check the charge: {exc}")
return
state_str = status.get("status")
if state_str == "settled":
amt = status.get("amountUsd")
from agent.billing_view import parse_money
shown = format_money(parse_money(amt)) if amt else format_money(amount)
print(f"{shown} in credits added.")
return
if state_str == "failed":
self._billing_render_charge_failed(state, status.get("reason"))
return
# pending → wait and poll again
_time.sleep(interval)
# Past the cap with no terminal state = timeout (not an error).
print(f" 🟡 Still processing after 5 minutes — this is a timeout, not a "
f"failure. Check /billing or the portal shortly.")
self._billing_portal_hint(state)
def _billing_render_charge_failed(self, state, reason):
"""Branch the poll `failed` reasons to the right copy + portal funnel."""
reason = (reason or "").strip()
if reason == "authentication_required":
print(" 🔴 Your bank requires verification (3DS). Complete it on the "
"portal to finish this purchase.")
elif reason == "payment_method_expired":
print(" 🔴 Your card has expired. Update it on the portal.")
elif reason == "card_declined":
print(" 🔴 Your card was declined. Try another card on the portal.")
else:
print(f" 🔴 The charge didn't go through ({reason or 'processing_error'}).")
self._billing_portal_hint(state)
def _billing_render_charge_error(self, state, exc):
"""Render a typed BillingError at submit time (pre-poll)."""
from hermes_cli.nous_billing import BillingRateLimited
code = getattr(exc, "error", None)
portal_url = getattr(exc, "portal_url", None) or getattr(state, "portal_url", None)
if code == "no_payment_method":
print(" 💳 No saved card for terminal charges yet. Set one up on the "
"portal (one-time credit buys don't save a reusable card).")
elif code == "cli_billing_disabled":
print(" 🔴 Terminal billing is turned off for this org — an admin must enable it on the portal.")
elif code == "monthly_cap_exceeded":
remaining = (getattr(exc, "payload", {}) or {}).get("remainingUsd")
if remaining is not None:
print(f" 🔴 Monthly spend cap reached — ${remaining} headroom left.")
else:
print(" 🔴 Monthly spend cap reached.")
elif isinstance(exc, BillingRateLimited):
wait = getattr(exc, "retry_after", None)
mins = f" (try again in ~{max(1, round(wait / 60))} min)" if wait else ""
print(f" 🟡 Too many charges right now{mins}. This isn't a payment failure.")
else:
print(f" 🔴 {exc}")
if portal_url:
print(f" Portal: {portal_url}")
def _billing_handle_scope_required(self, state):
"""403 insufficient_scope → lazy step-up re-auth (plan D-A)."""
print()
print(" 💳 Terminal billing needs an extra permission (billing:manage).")
_scope_msg = (
"An org admin/owner must tick \"Allow terminal billing\" during "
"login."
)
_cprint(f" {_d(_scope_msg)}")
if not getattr(self, "_app", None):
print(" Run `hermes portal` and approve terminal billing, then retry.")
return
confirm_choices = [
("yes", "Re-authorize now", "open the portal to grant billing access"),
("no", "Not now", "cancel"),
]
raw = self._prompt_text_input_modal(
title="💳 Grant terminal billing access?",
detail="Opens the portal device-authorization page.",
choices=confirm_choices,
)
choice = self._normalize_slash_confirm_choice(raw, confirm_choices)
if choice != "yes":
print(" 🟡 Cancelled.")
return
try:
from hermes_cli.auth import step_up_nous_billing_scope
granted = step_up_nous_billing_scope(open_browser=True)
except Exception as exc:
print(f" 🔴 Re-authorization failed: {exc}")
return
if granted:
print(" ✅ Billing permission granted.")
# Step-up only grants the billing:manage TOKEN scope; the ORG
# kill-switch (cli_billing_enabled) is a separate gate. Re-fetch
# /state so we don't over-promise when a charge would still hit
# cli_billing_disabled.
from agent.billing_view import build_billing_state
fresh = build_billing_state()
if fresh.logged_in and fresh.cli_billing_enabled:
print(" Run /billing buy again to continue.")
else:
print(" 🟡 Permission granted, but terminal billing is still turned "
"off for this org. Enable it in the portal, then run /billing again.")
self._billing_portal_hint(fresh)
else:
print(" 🟡 Terminal billing was not granted (an admin must tick the box).")
def _billing_auto_reload_flow(self, state):
"""Screen 4 — auto-reload config: threshold + reload-to → PATCH.
Prefills the current values from ``state.auto_reload``. Validates both
amounts (2dp, within bounds, ``reload_to > threshold``). When auto-reload
is already on, offers a "Turn off" path (PATCH ``enabled:false``).
"""
from agent.billing_view import format_money, validate_charge_amount
if not self._billing_require_admin(state):
return
card = state.card
ar = state.auto_reload
currently_on = bool(ar and ar.enabled)
print()
_cprint(f" 💳 {_b('Auto-reload')}")
print(f" {'' * 41}")
_cprint(f" {_d('Automatically buy more credits when your balance is low.')}")
if card:
print(f" Card on file: {card.masked}")
else:
print(" No saved card — set one up on the portal first.")
self._billing_portal_hint(state)
return
if currently_on:
print(
f" Currently: below {format_money(ar.threshold_usd)}"
f"reload to {format_money(ar.reload_to_usd)}"
)
if not getattr(self, "_app", None):
print(" Run in the interactive CLI to configure auto-reload.")
self._billing_portal_hint(state)
return
# When already enabled, let the user turn it off without re-entering values.
if currently_on:
top_choices = [
("edit", "Edit thresholds", "change when / how much to reload"),
("off", "Turn off", "disable auto-reload"),
("cancel", "Cancel", "do nothing"),
]
raw = self._prompt_text_input_modal(
title="💳 Auto-reload",
detail=(
f"On — below {format_money(ar.threshold_usd)}"
f"reload to {format_money(ar.reload_to_usd)}"
),
choices=top_choices,
)
top = self._normalize_slash_confirm_choice(raw, top_choices)
if top == "off":
self._billing_auto_reload_disable(state)
return
if top != "edit":
print(" 🟡 Cancelled.")
return
# Field 1 — threshold (prefilled when editing an existing config).
cur_thr = format_money(ar.threshold_usd) if currently_on else None
thr_prompt = " When balance falls below (USD)"
thr_prompt += f" [{cur_thr}]: " if cur_thr else ": "
threshold_raw = self._prompt_text_input(thr_prompt)
if threshold_raw is None:
# None = cancelled (e.g. slash-worker can't prompt off-thread).
print(" 🟡 Cancelled.")
return
if not (threshold_raw or "").strip() and currently_on:
threshold_amt = ar.threshold_usd # keep current value on empty input
else:
tv = validate_charge_amount(
threshold_raw or "", min_usd=state.min_usd, max_usd=state.max_usd
)
if not tv.ok or tv.amount is None:
print(f" 🔴 {tv.error}")
return
threshold_amt = tv.amount
# Field 2 — reload-to (prefilled when editing an existing config).
cur_rel = format_money(ar.reload_to_usd) if currently_on else None
rel_prompt = " Reload balance to (USD)"
rel_prompt += f" [{cur_rel}]: " if cur_rel else ": "
reload_raw = self._prompt_text_input(rel_prompt)
if reload_raw is None:
print(" 🟡 Cancelled.")
return
if not (reload_raw or "").strip() and currently_on:
reload_amt = ar.reload_to_usd # keep current value on empty input
else:
rv = validate_charge_amount(
reload_raw or "", min_usd=state.min_usd, max_usd=state.max_usd
)
if not rv.ok or rv.amount is None:
print(f" 🔴 {rv.error}")
return
reload_amt = rv.amount
if reload_amt is None or threshold_amt is None or reload_amt <= threshold_amt:
print(" 🔴 Reload-to amount must be greater than the threshold.")
return
print()
_ar_consent = (
f"By confirming, you authorize Nous Research to charge {card.masked} "
f"whenever your balance reaches {format_money(threshold_amt)}. "
f"Turn off any time here or on the portal."
)
_cprint(f" {_d(_ar_consent)}")
confirm_choices = [
("agree", "Agree and turn on", "enable auto-reload"),
("cancel", "Cancel", "do nothing"),
]
raw = self._prompt_text_input_modal(
title="💳 Turn on auto-reload?",
detail=f"Below {format_money(threshold_amt)} → reload to {format_money(reload_amt)}",
choices=confirm_choices,
)
choice = self._normalize_slash_confirm_choice(raw, confirm_choices)
if choice != "agree":
print(" 🟡 Cancelled.")
return
from hermes_cli.nous_billing import (
BillingError,
BillingScopeRequired,
patch_auto_top_up,
)
try:
patch_auto_top_up(
enabled=True, threshold=float(threshold_amt), top_up_amount=float(reload_amt)
)
except BillingScopeRequired:
self._billing_handle_scope_required(state)
return
except BillingError as exc:
self._billing_render_charge_error(state, exc)
return
print(f" ✅ Auto-reload on: below {format_money(threshold_amt)}"
f"reload to {format_money(reload_amt)}.")
def _billing_auto_reload_disable(self, state):
"""Turn off auto-reload (PATCH ``enabled:false``).
The endpoint requires ``threshold``/``topUpAmount`` in the body even when
disabling, so we echo back the current values (falling back to 0).
"""
from hermes_cli.nous_billing import (
BillingError,
BillingScopeRequired,
patch_auto_top_up,
)
ar = state.auto_reload
thr = float(ar.threshold_usd) if ar and ar.threshold_usd is not None else 0.0
rel = float(ar.reload_to_usd) if ar and ar.reload_to_usd is not None else 0.0
try:
patch_auto_top_up(enabled=False, threshold=thr, top_up_amount=rel)
except BillingScopeRequired:
self._billing_handle_scope_required(state)
return
except BillingError as exc:
self._billing_render_charge_error(state, exc)
return
print(" ✅ Auto-reload turned off.")
def _billing_limit_screen(self, state):
"""Screen 5 — monthly spend limit (read-only; cap is portal-only)."""
from agent.billing_view import format_money
print()
_cprint(f" 💳 {_b('Monthly spend limit')}")
print(f" {'' * 41}")
cap = state.monthly_cap
if cap is None or cap.limit_usd is None:
_cprint(f" {_d('No monthly cap visible (managed on the portal).')}")
else:
spent = format_money(cap.spent_this_month_usd)
limit = format_money(cap.limit_usd)
ceiling = " (default ceiling)" if cap.is_default_ceiling else ""
print(f" {spent} of {limit} used this month{ceiling}")
_limit_note = (
"The monthly limit is set on the portal — the terminal shows "
"it read-only."
)
_cprint(f" {_d(_limit_note)}")
self._billing_portal_hint(state)
def _show_insights(self, command: str = "/insights"): def _show_insights(self, command: str = "/insights"):
"""Show usage insights and analytics from session history.""" """Show usage insights and analytics from session history."""
# Parse optional --days flag # Parse optional --days flag
@ -12024,6 +12689,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
# Create the input area with multiline (Alt+Enter), autocomplete, and paste handling # Create the input area with multiline (Alt+Enter), autocomplete, and paste handling
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
from prompt_toolkit.completion import ThreadedCompleter
_completer = SlashCommandCompleter( _completer = SlashCommandCompleter(
@ -12039,7 +12705,13 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
wrap_lines=True, wrap_lines=True,
read_only=Condition(lambda: bool(cli_ref._command_running)), read_only=Condition(lambda: bool(cli_ref._command_running)),
history=FileHistory(str(self._history_file)), history=FileHistory(str(self._history_file)),
completer=_completer, # complete_while_typing fires the completer on every keystroke. The
# completer does blocking work — fuzzy @-file indexing shells out to
# rg/fd (up to a 2s timeout) and path completion hits os.listdir/stat
# — so running it inline would stall the render loop on each key (very
# noticeable on WSL2/slow filesystems). ThreadedCompleter moves it off
# the UI event loop, keeping typing responsive.
completer=ThreadedCompleter(_completer),
complete_while_typing=True, complete_while_typing=True,
auto_suggest=SlashCommandAutoSuggest( auto_suggest=SlashCommandAutoSuggest(
history_suggest=AutoSuggestFromHistory(), history_suggest=AutoSuggestFromHistory(),

View File

@ -28,14 +28,13 @@ as_hermes() { [ "$(id -u)" = 0 ] || { "$@"; return; }; s6-setuidgid hermes "$@";
# arbitrary host UID (the classic `--user $(id -u):$(id -g)` invocation people # arbitrary host UID (the classic `--user $(id -u):$(id -g)` invocation people
# used in the tini era to make container-written files match their host user). # used in the tini era to make container-written files match their host user).
# #
# Under s6-overlay this no longer works: the bootstrap (UID remap, volume + # Under s6-overlay this no longer works: the bootstrap (UID remap, data-volume
# build-tree chown, config seeding) all require root, and they're skipped when # ownership, config seeding) requires root, and it is skipped when the container
# the container starts non-root. The baked image trees (/opt/data, /opt/hermes/ # starts non-root. The baked install tree under /opt/hermes is intentionally
# .venv, ui-tui, node_modules) stay owned by the hermes build UID (10000), so an # root-owned and non-writable; mutable runtime state must live under
# arbitrary `--user` UID can't write them — the runtime then fails with EACCES # $HERMES_HOME. An arbitrary `--user` UID therefore cannot repair or populate
# on a bind mount, or hard-crashes on a named volume (Docker initialises the # the data volume, and startup fails with EACCES. See #34837 for the
# volume from the image as UID 10000, and the non-root start can't even `cd` # supervision-tree side of this.
# into $HERMES_HOME). See #34837 for the supervision-tree side of this.
# #
# The supported way to match host-side ownership is to start as root (the image # The supported way to match host-side ownership is to start as root (the image
# default) and pass HERMES_UID/HERMES_GID — or the PUID/PGID aliases — which the # default) and pass HERMES_UID/HERMES_GID — or the PUID/PGID aliases — which the
@ -53,9 +52,10 @@ if [ "$cur_uid" != 0 ] && [ "$cur_uid" != "$(id -u hermes)" ]; then
[stage2] ERROR: container started with --user $cur_uid (an arbitrary, non-hermes UID). [stage2] ERROR: container started with --user $cur_uid (an arbitrary, non-hermes UID).
This is not supported under the s6-overlay image. The container bootstrap This is not supported under the s6-overlay image. The container bootstrap
(UID remap, volume ownership, dependency installs) needs to start as root, (UID remap, data-volume ownership, config seeding) needs to start as root,
and the baked image directories are owned by the hermes user (UID $(id -u hermes)), and the baked /opt/hermes install tree is intentionally root-owned and
so a pinned --user UID cannot write them — startup will fail. non-writable, so a pinned --user UID cannot repair startup state — startup
will fail.
To make container-written files match your HOST user, DON'T use --user. To make container-written files match your HOST user, DON'T use --user.
Start the container as root (the default) and pass your host UID/GID instead: Start the container as root (the default) and pass your host UID/GID instead:
@ -207,49 +207,13 @@ if [ "$needs_chown" = true ]; then
done done
fi fi
# --- Fix ownership of build trees under $INSTALL_DIR --- # --- Immutable install tree ---
# Hermes-owned trees under $INSTALL_DIR must be re-chowned whenever the # Do not chown runtime code or dependency trees under $INSTALL_DIR back to the
# runtime hermes UID no longer owns them — otherwise: # hermes user. Hosted/container instances keep mutable state under
# - .venv: lazy_deps.py cannot install platform packages (discord.py, # $HERMES_HOME (/opt/data) and run with PYTHONDONTWRITEBYTECODE plus
# telegram, slack, etc.) with EACCES (#15012, #21100) # HERMES_DISABLE_LAZY_INSTALLS=1. Keeping /opt/hermes root-owned and
# - ui-tui: esbuild rebuilds dist/entry.js on every TUI launch (when # non-writable prevents an agent session from self-modifying the installed
# the source mtime is newer than dist/ or when HERMES_TUI_FORCE_BUILD # source, venv, TUI bundle, or node_modules and bricking the gateway.
# is set) and writes to ui-tui/dist/. Without this chown the new
# hermes UID can't write the build output (#28851).
# - gateway: Python writes __pycache__ and runtime artifacts beneath the
# gateway package on first import. After a UID remap those source-owned
# paths still belong to the build-time UID (10000) unless repaired here,
# producing EACCES for the supervised gateway (#27221).
# - node_modules: root-level dependencies (puppeteer, web tooling)
# that runtime code may walk/update.
# The set mirrors the build-time `chown -R hermes:hermes` line in the
# Dockerfile — keep them in sync if the Dockerfile chown set changes.
# These are under $INSTALL_DIR (not $HERMES_HOME), so the bind-mount
# concern doesn't apply — recursive is fine.
#
# This MUST be gated independently of the $HERMES_HOME ownership check
# above. `usermod -u <new> hermes` re-chowns the hermes home dir
# ($HERMES_HOME == /opt/data) to the new UID as a side effect, so after a
# HERMES_UID/PUID remap `stat $HERMES_HOME` always already matches the new
# UID and `needs_chown` is false — but the build trees under /opt/hermes
# are NOT touched by usermod and remain owned by the build-time UID
# (10000). Gating them on $HERMES_HOME ownership (as #35027 did) silently
# skipped this chown on the common PUID/NAS path, regressing lazy installs
# and TUI rebuilds. Probe the build trees directly instead: chown only
# when the venv is not already owned by the runtime hermes UID. Idempotent
# and skips the expensive recursive chown on every restart once ownership
# is settled.
venv_owner=$(stat -c %u "$INSTALL_DIR/.venv" 2>/dev/null || echo "")
if [ -n "$venv_owner" ] && [ "$venv_owner" != "$actual_hermes_uid" ]; then
echo "[stage2] Fixing ownership of build trees under $INSTALL_DIR to hermes ($actual_hermes_uid)"
chown -R hermes:hermes \
"$INSTALL_DIR/.venv" \
"$INSTALL_DIR/ui-tui" \
"$INSTALL_DIR/gateway" \
"$INSTALL_DIR/node_modules" \
2>/dev/null || \
echo "[stage2] Warning: chown of build trees failed (rootless container?) — continuing"
fi
# Always reset ownership of $HERMES_HOME/profiles to hermes on every # Always reset ownership of $HERMES_HOME/profiles to hermes on every
# boot. Profile dirs and files can land owned by root when commands # boot. Profile dirs and files can land owned by root when commands
@ -327,13 +291,25 @@ as_hermes mkdir -p \
"$HERMES_HOME/pairing" \ "$HERMES_HOME/pairing" \
"$HERMES_HOME/platforms/pairing" "$HERMES_HOME/platforms/pairing"
# --- Install-method stamp (read by detect_install_method() in hermes status) --- # --- Install-method stamp ---
# Preserved from the tini-era entrypoint (PR #27843). Must be written as # The 'docker' stamp is baked into the immutable install tree at
# the hermes user so ownership matches the file's documented owner. # /opt/hermes/.install_method (see Dockerfile), NOT written here into
# tee is invoked directly via s6-setuidgid (no `sh -c` wrapper) for the # $HERMES_HOME. detect_install_method() reads the code-scoped stamp first.
# same shell-metacharacter safety described above. #
printf 'docker\n' | as_hermes tee "$HERMES_HOME/.install_method" >/dev/null \ # Why we no longer stamp $HERMES_HOME: it is a shared DATA volume, commonly
|| true # bind-mounted from the host (~/.hermes:/opt/data) and sometimes shared with a
# host-side Desktop/CLI install. Stamping 'docker' here clobbered that host
# install's marker, so its in-app updater read 'docker' and refused to run
# 'hermes update'. To heal homes already poisoned by older images, remove a
# stale 'docker' stamp from $HERMES_HOME if one is present (the host install's
# own installer re-creates its code-scoped stamp; a genuine container relies on
# the baked /opt/hermes stamp, so deleting the data-dir copy is safe).
if [ -f "$HERMES_HOME/.install_method" ]; then
stamped="$(tr -d '[:space:]' < "$HERMES_HOME/.install_method" 2>/dev/null || true)"
if [ "$stamped" = "docker" ]; then
rm -f "$HERMES_HOME/.install_method" 2>/dev/null || true
fi
fi
# --- Seed config files (only on first boot) --- # --- Seed config files (only on first boot) ---
seed_one() { seed_one() {

View File

@ -1,68 +0,0 @@
# Ink TUI — diagnostic environment flags
Non-secret behavioral knobs for the Ink engine (`ui-tui/`). These are
**environment overrides**, not `.env` secrets — set them in your shell for a
session, or `export` them in your shell rc to make them sticky. They mirror the
OpenTUI engine's flags (`docs/opentui-env-flags.md`) so a single switch covers
both engines.
| Flag | Default | What it does |
|---|---|---|
| `HERMES_TUI_DIAGNOSTICS` | off | Master diagnostics switch. Turning it on enables the developer/profiling surface across the TUI — including the memory self-sampler below. One `export HERMES_TUI_DIAGNOSTICS=1` in your shell rc covers **every** session you start, on **either** engine. |
| `HERMES_TUI_MEMLOG` | = `HERMES_TUI_DIAGNOSTICS` | In-process 1Hz memory self-sampling (`ui-tui/src/lib/memlog.ts`) → `~/.hermes/logs/memwatch/<boot>-<pid>.jsonl`. Defaults to the master switch; set `=1` / `=0` to force it on/off independently. |
## What the memory trace captures
Each Ink session, when sampling is enabled, appends one JSON line per second to
its own file under `~/.hermes/logs/memwatch/`, keyed by boot time + pid:
```json
{"t":1781514892,"rss_kb":92148,"heap_used_kb":7234,"external_kb":2378}
```
- `t` — unix seconds.
- `rss_kb` — resident set size (the number that matters for the native-RSS-gap
story: rss climbing while heap stays flat is the #15141-class signal).
- `heap_used_kb` — V8 heap in use.
- `external_kb` — off-heap (buffers, native allocations).
**Ink emits no `mounted` / `peak_mounted` field.** Those are OpenTUI's
windowing dev counters; Ink has no windowing, so it logs the rss/heap/external
core only. `memwatch-report.mjs` treats `mounted` as optional, so Ink lines
aggregate cleanly alongside OpenTUI's.
## Why this exists — cross-engine memory comparison
The filename scheme, directory, and line schema are **byte-compatible with
OpenTUI's collector** (`ui-opentui/src/boundary/memlog.ts`). Both engines write
to the same `~/.hermes/logs/memwatch/` directory, so one aggregator reads both:
```sh
# enable on either/both engines (master switch covers both)
export HERMES_TUI_DIAGNOSTICS=1
HERMES_TUI_ENGINE=ink hermes --tui # Ink session → its own .jsonl
HERMES_TUI_ENGINE=opentui hermes --tui # OpenTUI session → its own .jsonl
# fleet table across BOTH engines' sessions:
cd ~/github/tui-bench && node memwatch-report.mjs
```
This is what makes a true side-by-side **real-world** memory arc possible —
cold floor → load → plateau/leak — instead of comparing OpenTUI dogfood traces
against an Ink harness with no equivalent data.
## Cost & safety
- ~50 bytes/s when on; one `process.memoryUsage()` + one short append per
second. The interval is **unref'd** — it never keeps the process alive.
- 14-day retention: older traces are pruned (best-effort) at start.
- **Every failure path disables the logger silently.** Diagnostics must never
break the TUI — this is the one place the "errors propagate" rule is
intentionally inverted, matching the OpenTUI collector.
- Off by default: regular users write nothing.
## Getting a meaningful trace
A short scroll-through won't show growth. For a comparison against OpenTUI's
45h sessions, drive a tool-heavy 23h Ink session as the floor (see
`docs/plans/opentui-ink-asymmetry-note.md` for why the harness ≠ dogfood data).

View File

@ -1,120 +0,0 @@
# Handoff — OpenTUI memory + UX, continuing on the canonical branch
**You are continuing the Hermes OpenTUI engine work.** This is the base operating manual; the
user (glitch) appends specific tasks on top. Read it, then read the repo docs it points to. It
assumes NO prior transcript/memory.
## Where things are
- **Canonical branch: `feat/opentui-native-engine`** (the draft PR to main, #42922).
`feat/opentui-memory-window` is a synonym at the *same tip* — they were consolidated. Treat
native-engine as canonical; if you work from memory-window, periodically
`git push origin HEAD:feat/opentui-native-engine` to keep them in sync, or just use native-engine.
- The native engine source is **`ui-opentui/`**; the legacy Ink engine is `ui-tui/` (shipping
default, untouched by this campaign). The Python gateway is `tui_gateway/`, launcher
`hermes_cli/main.py`.
- **The worktree is often the user's LIVE global `hermes`** (`~/.local/bin/hermes` symlinks into a
worktree's `.venv`). Consequences: (1) NEVER leave the worktree in a half-merged/conflicted state
— a new `hermes` session would fail to build; (2) after you land source changes, rebuild
`dist/main.js` so the next session picks them up; (3) `hermes-stable` is the flip-back to the
stock `~/.hermes/hermes-agent` install if you need to bypass the worktree.
- Backups of pre-merge branch states exist as `backup/*` refs (recoverable via `git reset`).
## Runtime, build, gate (Node 26 — NOT Bun; the port is done)
```sh
export PATH="$HOME/.local/share/fnm/node-versions/v26.3.0/installation/bin:$PATH"
cd ui-opentui && node scripts/build.mjs # → dist/main.js (esbuild + Solid/JSX)
HERMES_TUI_MOUSE=1 node --experimental-ffi --no-warnings dist/main.js # launch; quit = double Ctrl+C
cd ui-opentui && npm run check # THE GATE: prettier+eslint(typed)+vitest (~700). Judge by `echo $?`, never a piped tail.
```
Never run bun here. Never run `hermes update` in the worktree (it flips the branch — recovery is
painful). Never broad-pkill tui_gateway (other live sessions). Host RAM ~15GB, often <5GB free
run benches SEQUENTIALLY (the harness already wraps SUTs in `systemd-run … MemoryMax=2G`).
## The docs that are the source of truth (read, and KEEP UPDATED as you change things)
- `docs/opentui-memory-story.md` — ELI5 of the whole memory architecture (primitives + every decision).
- `docs/plans/opentui-transcript-windowing.md` — windowing design (S1 spacers, S2 append-time), the
`correctionIsLegal` zero-jank law, pre-registered gates, SHIPPED status + S3 backlog.
- `docs/opentui-env-flags.md` — the consolidated env-flag ledger (master switch / user / dev / plumbing).
- `docs/opentui-upstream-alignment.md` — forkless invariant, `boundary/` shim ledger, the per-release
OpenTUI upgrade playbook (native-yoga is coming upstream — re-tune windowing margins when it lands).
- the bench suite (cells, harness, live-attach, memwatch) now lives in its own
repo: **tui-bench** (`github.com/NousResearch/tui-bench`); see its `README.md`.
- `ui-opentui/README.md` — Node 26 onboarding (fnm setup that doesn't disturb other projects).
- `docs/plans/ink-memory-adversarial-review.md` — Ink's memory weaknesses (F1F10, the turnabout).
- `docs/plans/gateway-death-forensics.md`, `docs/plans/workorder-2026-06-11-results.md`,
`docs/plans/rebase-from-main-spec.md` — forensics, the merge-bar verdict, the rebase plan.
## Workflow (this is how the last 60+ commits were produced with ~zero rework)
1. **Subagent-driven** (skill: `subagent-driven-development`): one implementer per task with a TIGHT
file fence ("you own exactly these files; `git diff --cached --stat` before commit, abort on
out-of-fence"), a mandatory `opentui` skill read FIRST for any renderable work, and a gate judged
by exit code. Verify the self-report YOURSELF (re-run the gate, read the riskiest hunks, check the
commit file-list) — a subagent "✅ done" is a claim, not a fact.
2. **Adversarial review** after a task: a fresh read-only reviewer (Explore-type) with NAMED attack
surfaces. Then ADJUDICATE in code — reviewers over-flag; ~half of "blockers" don't survive a read.
3. **Parallel implementers are safe ONLY with disjoint file fences.** Read-only recon agents
parallelize freely.
4. **Live smoke catches what headless can't** — tmux + the `tmux-pane-screenshot` skill for real
colored frames. The demo: `node scripts/build.mjs scripts/demo.tsx .demo` then
`DEMO_TOTAL=2000 … node --experimental-ffi --no-warnings .demo/demo.js`.
5. Commit format `opentui(v6): …`, **NO attribution lines**. The user's standing instruction is
"commit + push as you land things" — honor it; otherwise don't push without asking. Edit large
load-bearing files (the Python launcher, `store.ts`) DIRECTLY, never via subagent.
## Dogfooding (the user works on this FROM the hermes TUI)
`export HERMES_TUI_DIAGNOSTICS=1` in the shell rc turns on, for every session: the `/mem` +
`/heapdump` slash commands, window-stats, and **fleet memory self-logging** to
`~/.hermes/logs/memwatch/<boot>-<pid>.jsonl`. Aggregate all sessions with
`node memwatch-report.mjs` from the **tui-bench** repo
(`github.com/NousResearch/tui-bench`) (per-session baseline/peak/slope + SLOPE/PEAK/MOUNTED anomaly
flags). Chase a flagged session with tui-bench's `live-attach.sh <pid> --heap`. The discipline: live
anomaly → encode as a bench cell → fix → validate against live sessions again.
## Current state (2026-06) + the ranked backlog
Windowing SHIPPED: 2k-msg peak ~300MB (was 686; Ink 234), scroll p99 6ms, cap restored 1000→3000,
determinism digest unchanged, peak mounted ~31 rows. Live sessions peak <200MB. The transcript is no
longer the biggest lever — the ~160MB floor is ≈104MB Node+OpenTUI runtime + **≈55MB tool/skill
catalogs hydrated at boot**. Ranked next levers:
1. **W3 — 1GB V8 heap default** (small, ~free): set the unconstrained default in
`_resolve_tui_heap_mb`; both engines are Node now so both inherit it. Ink half = separate gated
commit (shipping engine). Measured 90MB at bench scale.
2. **cg_peak harness fix** (small): the cgroup `memory.peak` field is polluted (shared across runs) —
reset/scope it before quoting tui-bench's `report.html` again. Trust `vmhwm_kb` + `samples[].rss_kb`.
3. **New bench cells** (before W1, as its baselines): `resume-1900` (real p99 shape: time-to-first-
paint + post-hydration RSS) and `10MB-tool-output` (the F1 byte-unbounded class). Run BOTH engines.
4. **Catalog lazy-load** (new, promoted by live data): don't hydrate 1,185 tools at boot — fetch on
picker-open. Attacks the ≈55MB floor; pays on EVERY session (median is 20 msgs). Likely cheaper
than W1.
5. **W1 thin renderer** (structural, biggest): bodies live in the gateway (SQLite); TUI keeps ~300B
stubs + fetches bodies for the window only. Design the gateway windowed-read RPC FIRST. WATCH: `/copy`
and the ⧉ block-copy read store parts — they need a fetch-on-demand fallback or W1 ships a copy regression.
6. **Standing**: when native-yoga OpenTUI ships, run the upgrade playbook (re-bench, re-tune margins,
audit the shim ledger). Three questions to relay to the OpenTUI maintainer are in the alignment doc.
## What NOT to do
- Don't copy opencode's 100-msg store cap (user's p90 session is 182 msgs — it would truncate normal use).
- Don't reintroduce estimate-correction scroll jank (the user explicitly vetoed it; `correctionIsLegal` forbids it).
- Don't cite the obsolete "~210MB bun renderer / +120MB" memory figures — pre-port, pre-windowing, wrong.
- Don't push/PR without the standing OK; don't commit `.plans/` scratch unless asked.
## Suggested skills
(All available from the Hermes TUI agent too — this is the dogfooding surface. Curated to the load-bearing set, not the full ~40-skill catalog.)
- `opentui-tui-engineering` — the workflow/architecture/pitfalls layer for `ui-opentui/` (just updated).
- `hermes-tui-architecture` — the Hermes-specific TUI facts (launch pipeline, both engines; just updated).
- `opentui` — the offline renderable-API doc set; mandatory `skill_view` before any view/renderable code.
- `subagent-driven-development` — the process spine for parallel/heavy work.
- `tmux-pane-screenshot` — real colored PNG of a tmux pane for visual verification (ported
into hermes skills 2026-06-13). Use: `bash ~/.hermes/skills/software-development/
tmux-pane-screenshot/scripts/tshot.sh <session:win.pane> out.png 2`, then Read the PNG.
`freeze` (~/go/bin) + the resvg rasterizer are shared/system-wide — works as-is.
- `effect-ts` — for the Effect-at-boundary entry/lifecycle code.
- `superpowers:brainstorming` — before committing to a memory-architecture design (e.g. W1's store split).
- `systematic-debugging` — if a gate fails; root-cause before patching.

View File

@ -1,81 +0,0 @@
# OpenTUI env flags — the consolidated ledger
Every environment variable the OpenTUI TUI reads (grep-verified 2026-06-12),
classified by who should ever touch it. The design rule shipped with this doc:
**regular users see zero diagnostic surface by default; one master switch
(`HERMES_TUI_DIAGNOSTICS=1`) turns all of it on when needed.**
## 1. The master switch
| var | default | effect |
|---|---|---|
| `HERMES_TUI_DIAGNOSTICS` | **off** | Enables the diagnostic slash commands (`/mem`, `/heapdump`). While off they're hidden from `/help` (client-side filter) and invoking them prints the enable hint rather than executing. They never appear in slash *completion* in either state — completion is gateway-driven and these are client-only commands the gateway doesn't know (an adversarial review confirmed there's no bypass path; if a SERVER command named `mem`/`heapdump` is ever added it must be gated gateway-side too — the client gate would shadow but not hide it). Also flips the *default* of `HERMES_TUI_WINDOW_STATS` to on. Not a secret — support flows are "relaunch with `HERMES_TUI_DIAGNOSTICS=1`". |
## 2. User-facing configuration (fine to document publicly)
| var | default | effect |
|---|---|---|
| `HERMES_TUI_ENGINE` | auto (`opentui` if Node≥26.3 + built, else `ink`) | Engine pick; also `display.tui_engine` in config.yaml. |
| `HERMES_TUI_MOUSE` / `HERMES_TUI_MOUSE_TRACKING` / `HERMES_TUI_DISABLE_MOUSE` | on | Mouse support (wheel scroll, selection, click-to-expand). **Defers to Ink's env surface (`logic/env.ts` `resolveMouseEnabled`):** precedence is `HERMES_TUI_MOUSE_TRACKING` (toggle, force knob) > `HERMES_TUI_DISABLE_MOUSE=1` (legacy kill switch) > `HERMES_TUI_MOUSE` (OpenTUI-native alias, kept — also what the launcher sets) > default on. OpenTUI's renderer mouse is a single boolean, so Ink's granular off\|wheel\|buttons\|all collapses to on/off (the granular mode lives in `display.mouse_tracking` config). |
| `HERMES_TUI_SCROLL_SPEED` (alias `CLAUDE_CODE_SCROLL_SPEED`) | native | Wheel-scroll speed multiplier (Ink parity). UNSET → OpenTUI's native scroll acceleration (untouched). A positive value (clamped to (0,20]) installs a constant-multiplier `ScrollAcceleration` on the transcript scrollbox (`view/transcript.tsx`). |
| `HERMES_TUI_NO_CONFIRM` | off | Skip the destructive-action confirm step (`/clear`, `/new`) and run immediately (Ink parity, `NO_CONFIRM_DESTRUCTIVE`). Wired at the `confirm` seam (`entry/main.tsx`). |
| `HERMES_TUI_MAX_MESSAGES` | ceiling | Scrollback rows kept in the TUI. Can LOWER the ceiling, never raise: 3000 with windowing, 1000 with windowing off (handle-table safety). |
| `HERMES_TUI_TOOL_OUTPUT_LINES` | unlimited | Cap expanded tool-output lines (set a number to restore a cap). |
| `HERMES_TUI_TOOL_OUTPUTS` | **on** | Keep rich tool-call OUTPUTS (full result body + raw result/args dicts). `=off` drops both the RENDER and the STORE of those bodies (Ink parity: only a one-line context preview + name/duration/error/diff survive) — the memory lever for the OpenTUI-vs-Ink retention asymmetry, and what the bench launches OpenTUI with for the fair engine-overhead comparison (W3). Diffs (file-edit) are KEPT either way. |
| `HERMES_TUI_HEAP_MB` | cgroup-aware (default 8192) | V8 `--max-old-space-size` (MB) for BOTH engines. Highest precedence (then `display.tui_heap_mb` config, then the cgroup-75% fallback). Set it LOW for a low-mem session (still cgroup-clamped on top so it never exceeds the container); raise it to lift the ceiling. The low-mem opt-in signal that also arms `HERMES_TUI_PROACTIVE_GC` (W1). |
| `HERMES_TUI_PROACTIVE_GC` | = low-`HERMES_TUI_HEAP_MB` (≤4096) | Idle-gated `global.gc()` for the low-mem path. Defaults ON only when a low heap cap is set (so the knobs compose); `=on`/`=off` forces it. Needs `--expose-gc` (the OpenTUI argv now carries it). Never runs mid-stream; tightens cadence above 400MB RSS but stays idle-gated. OpenTUI-only — Ink never GCs proactively (W2). |
| `HERMES_TUI_COMPOSER_ROWS` | default rows | Composer height. |
## 3. Escape hatches & tuning (dev-facing, individually settable)
| var | default | effect |
|---|---|---|
| `HERMES_TUI_WINDOWING` | **on** | `0` = bit-exact pre-windowing renderer (every row mounts; cap clamps back to 1000). The A/B + regression escape hatch. |
| `HERMES_TUI_WINDOW_IDLE_MS` | ~1000 | Idle-measure pulse cadence (the spacer-exactness march). Test knob. |
| `HERMES_TUI_WINDOW_STATS` | = `HERMES_TUI_DIAGNOSTICS` | Exposes live/peak mounted-row counters (`globalThis.__hermesTuiWindowStats`) for tui-bench's live-attach reads. |
| `HERMES_TUI_MEMLOG` | = `HERMES_TUI_DIAGNOSTICS` | In-process 1Hz memory self-sampling (`boundary/memlog.ts`) → `~/.hermes/logs/memwatch/<boot>-<pid>.jsonl` (rss/heap/external + mounted rows; 14-day retention). Fleet view: `node memwatch-report.mjs` from the tui-bench repo (`github.com/NousResearch/tui-bench`). The "monitor all my sessions" answer: one `export HERMES_TUI_DIAGNOSTICS=1` in your shell rc covers every session. |
| `HERMES_TUI_LOG_LEVEL` / `HERMES_TUI_LOG_FILE` | engine defaults | Logging verbosity/destination (`/logs` reads the ring buffer regardless). Deliberately independent of the master switch — support often wants logs without the full diag surface. |
| `HERMES_HEAPDUMP_ON_START` | off | Write one V8 heap snapshot at boot (Ink parity). A deliberate baseline-capture escape hatch that BYPASSES the diagnostics master switch; lands at `$HERMES_HOME/logs/opentui-heap-<ts>.heapsnapshot` and echoes the path as a system line (`entry/main.tsx`). |
| `HERMES_TUI_NOTIFY` | on | Desktop-notification kill switch (`=0`/`false`/`off` silences the "waiting on you" pings). The ping itself goes through the renderer's native `triggerNotification` (protocol detection + tmux/Zellij wrapping); the window title is not gated by this. |
## 4. Internal plumbing (set by the launcher/tui-bench/tests — humans never set these)
| var | set by | effect |
|---|---|---|
| `HERMES_PYTHON`, `HERMES_PYTHON_SRC_ROOT`, `HERMES_CWD` | launcher / bench | Which gateway python + repo root + cwd the TUI spawns against (the bench's fake-gateway seam). |
| `HERMES_TUI_ACTIVE_SESSION_FILE` | launcher/bench | Session handoff file. |
| `HERMES_TUI_RESUME`, `HERMES_TUI_QUERY`, `HERMES_TUI_PROMPT`, `HERMES_TUI_IMAGE`, `HERMES_TUI_FAKE` | launcher/tests | Resume-at-boot; seeded prompt (`--tui "prompt"`: launcher sets `HERMES_TUI_QUERY`, the engine reads QUERY > the `HERMES_TUI_PROMPT` alias > a bare argv tail — `logic/env.ts` `startupPrompt`); seeded image PATH (`--image`: `HERMES_TUI_IMAGE`, `image.attach`ed before the prompt — `startupImage`, attach in `postSessionSetup`); fake-mode. |
| `HERMES_AUTO_HEAPDUMP*` (`_COOLDOWN_MS`/`_MAX_BYTES`), `HERMES_HEAPDUMP_DIR`, `HERMES_HEAPDUMP_MAX_BYTES` | — | **NOT read by the OpenTUI engine (deliberate).** The engine ports Ink's #34095 silent-death early-WARNING (a transcript system line, `boundary/memoryMonitor.ts`) but NOT the auto heap-SNAPSHOT capture — the always-on memlog NDJSON trace is the diagnosis path, and its rss-vs-heap divergence is the better diagnostic for the native-RSS leak class (#15141) a V8 snapshot captures poorly. So the #41948 disk-fill safety set (gate/cooldown/byte-cap/dir) has no consumer here. `HERMES_HEAPDUMP_ON_START` (manual one-shot, §3) is the only heapdump knob the engine honors. |
| `HERMES_TUI_RPC_TIMEOUT_MS`, `HERMES_TUI_STARTUP_TIMEOUT_MS` | tests/CI | Protocol timeouts. |
| (`ui-tui` only) `HERMES_TUI_MEMSAMPLE_FD/MS` | bench | Ink fd-3 node sampler. |
## 5. Ink flags NOT ported — handled natively or out of scope
These exist on the legacy Ink TUI (`ui-tui/`) and are deliberately **not** read
by the OpenTUI engine. Documented so a missing flag reads as a decision, not a gap.
| Ink flag | why not ported |
|---|---|
| `HERMES_TUI_TRUECOLOR` | OpenTUI core does COLORTERM/truecolor detection natively — the Ink force-truecolor hack is a fork workaround we shed. |
| `HERMES_TUI_FORCE_OSC52` | OpenTUI core owns OSC52 clipboard as a primitive; no fallback hint needed. |
| `HERMES_TUI_INLINE` / `HERMES_TUI_TERMUX_MODE` / `HERMES_TUI_TERMUX_FAST_ECHO` | Termux/primary-buffer accommodations. OpenTUI's native FFI floor (Node ≥26.3 + `--experimental-ffi`) is absent on Termux, so those sessions stay on **Ink** — these are correctly N/A for the OpenTUI engine. |
| `HERMES_TUI_FPS` | Ink FPS overlay; the OpenTUI equivalent is the diag/window-stats surface (`HERMES_TUI_WINDOW_STATS`). Not parity-critical. |
| `HERMES_DEV_CREDITS` / `HERMES_DEV_PERF*` | Dev-only throwaway scaffolding (live-spend readout, perf logging) — not user parity. |
| `HERMES_BIN` / `HERMES_TUI_GATEWAY_URL` / `HERMES_TUI_SIDECAR_URL` | External-CLI / remote-gateway-URL overrides. OpenTUI spawns its gateway via the Effect boundary (`liveGateway.ts`) and does not shell out to `hermes` or take an external gateway URL. |
| `HERMES_VOICE` | Voice mode is tracked on the OpenTUI parity backlog separately, not here. |
## How the pieces compose (the support script)
- Regular user, normal day: zero flags, zero diagnostic commands visible.
- "My TUI feels heavy" support flow: `HERMES_TUI_DIAGNOSTICS=1 hermes``/mem`
for the live numbers, `/heapdump` for a snapshot to attach, window stats
exposed for tui-bench's `live-attach.sh <pid>` to read.
- Developer profiling: same master switch + the individual knobs
(`HERMES_TUI_WINDOWING=0` A/B, `WINDOW_IDLE_MS` tuning) as needed.
- Anything in section 4 appearing in a user-facing doc is a bug.
Gating implementation: `logic/env.ts` (`diagnosticsEnabled()`),
`logic/slash.ts` (`DIAGNOSTIC_COMMANDS` — dispatch hint, help + completion
filtering), `view/transcript.tsx` (stats default). Tests:
`slash.test.ts` (gating both states), `utilityCommands.test.ts` (commands
themselves, gate enabled suite-wide).

View File

@ -1,207 +0,0 @@
# How the OpenTUI transcript got from 686MB to ~300MB — the full story
*For: glitch. Branch: `feat/opentui-memory-window`. Everything here is measured,
not vibes; every number has a result JSON in the **tui-bench** repo's `results/` (`github.com/NousResearch/tui-bench`).*
---
## 1. The cast of characters (the primitives, bottom-up)
To understand where the memory went, you need to know who's holding it. Six
layers, from the screen up:
**The terminal grid.** Your terminal is a spreadsheet of character cells.
Nobody pays per-message here — tmux holds ~5MB flat no matter how long the
session is (we measured). The terminal is never the problem.
**The OpenTUI native renderer (Zig).** A compiled library that owns the
"frame buffer" — the grid of cells about to be painted. Every piece of text the
TUI shows lives in a native **TextBuffer** (the characters + their colors),
viewed through a **TextBufferView**, styled by a **SyntaxStyle**. Each of those
is a **native handle** — a ticket into one global table that has only **65,535
slots, total, ever** (16-bit indices — like a coat check with 65k hooks).
Destroying a renderable returns its tickets, so the constraint is not "how much
have you ever created" but **"how much is alive right now."**
**Renderables.** OpenTUI's UI objects — `<text>`, `<box>`, `<markdown>`,
`<code>`, `<scrollbox>`. One transcript row (a message with its tool calls,
markdown, code blocks, copy chips) is a *tree* of these: **~16 text renderables
≈ 47 native handles ≈ ~250340KB of RSS, per row.** This is the number that
drives everything. 1,400 mounted rows × 47 handles = table full = the crash we
root-caused last week.
**Yoga (the layout engine, WASM).** Every renderable also has a Yoga node —
Yoga is the flexbox calculator that decides where boxes go. OpenTUI ships it
compiled to **WebAssembly**, and WASM has a brutal property: its memory can
**grow but never shrink** back to the OS. So the peak number of
*simultaneously-mounted* renderables sets a high-water mark you pay **forever**,
even after everything is destroyed. (Fun fact from this week's forensics: we
spent two days believing Ink had this disease. It doesn't — our Ink fork swapped
Yoga-WASM for a plain TypeScript port at fork creation. **We** are the ones
running layout in WASM. The accusation was true; we just had the defendant
wrong.)
**Solid (the view framework).** Renders each store message into a row via
`<For>`. The property we exploit: Solid mounts/unmounts *surgically* — remove a
row from what the component returns and Solid destroys exactly that row's
renderables (returning its handles and freeing its Yoga nodes), touching
nothing else. No virtual-DOM diffing, no collateral re-renders.
**V8 (the JavaScript engine) + the store.** The store keeps every message as JS
strings/objects. V8's garbage collector is *lazy by design*: with the default
8GB ceiling we launch with, it sees no reason to clean up aggressively, so RSS
includes a lot of "collectible but not yet collected" garbage. Cheap to fix,
worth real MB (measured below).
**The scrollbox.** One detail that fooled everyone at some point:
`viewportCulling` (on by default) skips *drawing* offscreen rows — but they stay
fully **mounted**: handles held, Yoga nodes alive, memory paid. Culling saves
paint time, not memory. That misunderstanding is half the reason the "rolling
store cap" was expected to be enough, and wasn't.
## 2. Why it was 686MB
Simple arithmetic. The old TUI mounted **every message in the store** as a full
renderable tree. 2,000 messages × ~16 renderables × (handles + Yoga nodes +
text buffers + V8 objects) ≈ 670690MB, growing ~300MB per 1,000 messages. And
at ~1,400 rows the handle table filled: first a hard crash (exit 7), then —
after our containment fix — survival with **unstyled text** past that point,
plus a cap clamped from 3,000 rows down to 1,000 as the price of not crashing.
Ink, meanwhile, sat at ~234MB at the same workload, because Ink only ever
mounts the rows near your viewport (~84400 live nodes). Its memory is the
*data* plus some caches — not the *view*.
## 3. The decisions, in order
### Decision 1: virtualize the view, don't starve the store
Two ways to cut view memory: keep fewer messages (opencode's answer — they keep
100 and delete the rest from memory; transcript truth lives on their server), or
keep all messages but only *materialize* the ones near the viewport. You vetoed
the first (your p90 session is 182 messages — a 100-row store truncates normal
sessions), so: **windowing**. Notably the OpenTUI devs confirmed this week that
framework-level virtualization is the intended path — the engine doesn't ship
it out of the box, and opencode never built it. We did.
### Decision 2: exact heights, recorded at unmount — never estimates in your face
This is the load-bearing idea, and it's where we beat Ink at its own game.
The hard problem of any virtualized list: an unmounted row still needs to
occupy its correct *height*, or the scrollbar lies and content jumps. Ink
solves it by **guessing** heights and correcting after measurement — those
corrections are precisely the 83101ms scroll stutters you hate. You explicitly
vetoed "estimate-correction jank" as a model.
Our advantage: OpenTUI lays out with real, queryable heights. So when a row
scrolls out of the window, we record its **exact laid-out height** (an
`onSizeChange` hook fires inside layout, pre-paint) and replace the row with an
empty `<box height={exactly-that}/>` — a **spacer**: one Yoga node, zero text
buffers, zero native handles. Think of a bookshelf where books you're not
reading are swapped for cardboard sleeves cut to *exactly* the book's
thickness: the shelf never shifts, and you can't tell from across the room.
The window is your viewport ± one viewport of margin (plus hysteresis so it
doesn't thrash at the edges). Scroll near a spacer and the real row remounts —
at the recorded height, so nothing moves.
And one **law**, written into the code as `correctionIsLegal`: a spacer's
height may only ever be corrected where you *cannot see it* — fully above the
viewport (with the scroll position compensated in the same frame, so the world
doesn't move) or fully below it. A correction that would shift visible content
is forbidden, structurally. Jank isn't tuned down; it's outlawed.
### Decision 3 (the S2 insight): adjudicate on *append*, not just on scroll
S1 alone got 686 → 518MB. Why not more? Because of *when* windowing decided.
S1 re-decided the window when you **scrolled**. But during a streaming burst —
an agent turn dumping hundreds of rows — you don't scroll; rows arrive, each
mounting fully, and only get demoted later. That transient pile-up is mostly
invisible in steady-state numbers… except for Yoga-WASM, where **the transient
peak is permanent** (memory never shrinks). The burst was quietly ratcheting
the floor.
S2 makes the window recompute on **transcript growth**: while you're pinned at
the bottom, the window anchors to the content *bottom*, so a row that falls
more than a margin behind the live edge becomes a spacer the moment it's
measured — not whenever you next scroll. Measured result: across a 1,500-row
burst, the peak number of simultaneously-mounted rows is **31**.
Same trick for **resume**: opening a 2,000-message session used to mount all of
it (transient peak again — paid forever). Now resume mounts only the bottom
window; everything above starts as spacers using a line-count estimate, and an
idle-time "measure march" quietly mounts ten rows at a time near the window
edge, records their true heights, and swaps them back — all outside the
viewport, all invisible by the law above.
### Decision 4: rows that must never be windowed
Windowing has to know what it's not allowed to touch:
- **Streaming rows** — the native markdown renderer streams incrementally;
unmounting mid-stream would restart it visibly.
- **The bottom 30 rows** — the region you actually live in.
- **Rows under a mouse selection** — the review caught that a lingering
highlight originally froze windowing *forever* (memory regrowing silently).
Fixed: only an active drag pauses swaps, and selected rows get pinned, so
copy is byte-exact while everything else keeps windowing.
### Decision 5: give back the scrollback (cap 1,000 → 3,000)
The 1,000-row clamp existed only because mounted-rows == stored-rows and the
handle table dies at ~1,400. With windowing, mounted ≈ 31 regardless of store
size — so the cap went back to the originally-shipped 3,000. It's
windowing-aware: the `HERMES_TUI_WINDOWING=0` escape hatch (which mounts
everything again) keeps the safe 1,000.
### Decision 6 (measured, not yet shipped as default): right-size the V8 heap
Running the windowed TUI with a 512MB heap ceiling instead of 8GB forced V8 to
actually collect: another 90MB with zero latency cost. That's queued as a
launcher default change (~1GB), for both engines.
## 4. The scoreboard
At 2,000 messages (your real p99 session size — yes, we checked your DB:
median session is 20 messages, p99 is 1,941):
| | peak memory | scroll p99 (slowest 1-in-100) |
|---|---|---|
| OpenTUI before | 686MB | 16ms |
| + S1 windowing | 518MB | 16ms |
| + S2 append/resume windowing | **300375MB** | **6ms** |
| Ink (reference) | 229246MB | ~100ms |
At the **3,000-message stress** with the restored triple-size scrollback:
**360MB, fully styled, scroll p99 8ms** — a workload that six days ago crashed
the process, and three days ago survived only by dropping syntax colors.
Scroll got *faster* because there are simply fewer live renderables to walk.
The determinism gate stayed **byte-identical** — the windowed TUI's settled
frame is provably the same pixels as before. And the live smoke (2,000-message
session: full sweep to the top, resize storm, back to bottom) returned a frame
pixel-identical to boot, with deep history fully syntax-highlighted — something
the pre-windowing TUI literally could not do.
## 5. What's honestly still open
- The remaining ~60120MB over Ink is mostly the **store's JS strings** and
process baseline — the view is no longer the problem. The structural fix is
the **thin renderer** (W1): bodies live in the Python gateway (which already
has them in SQLite); the TUI keeps ~300-byte stubs and fetches bodies only
for the window. That also fixes the class of problem neither engine handles
today: a single 10MB tool output.
- Two accepted, documented limits: scrollbar-*jumping* deep into a freshly
resumed session can land on estimate-height rows that snap to true height as
they enter view (normal scrolling doesn't — the margin pre-measures; the idle
march erodes the exposure over time), and a tool you expanded, scrolled far
away from, then returned to will have re-collapsed (state is component-local;
hoisting it to the store is queued).
- Everything is behind `HERMES_TUI_WINDOWING` (default on, `0` = bit-exact old
behavior) — a one-env escape hatch if anything feels off in real use.
*Where to verify: the **tui-bench** repo's `results/` (`github.com/NousResearch/tui-bench`; every number above), the design+gates doc
`docs/plans/opentui-transcript-windowing.md`, tests in
`ui-opentui/src/test/window.test.ts` and `transcriptWindow.test.tsx` (the
zero-jank invariants are literal assertions: identical scrollHeight windowed
vs not, byte-stable frames across corrections).*

View File

@ -1,432 +0,0 @@
# OpenTUI native engine — PR documentation
**Branch:** `feat/opentui-native-engine` · **Base:** `origin/main` (merged in; HEAD is at `~main`)
**New engine root:** `ui-opentui/` (Node 26 + `@opentui/core` 0.4.1 + `@opentui/solid`, Effect at the boundary)
**Legacy engine root:** `ui-tui/` (React + the `@hermes/ink` fork at `ui-tui/packages/hermes-ink/`)
> This is the canonical in-repo doc for the PR. The companion interactive HTML
> write-up (`~/projects/opentui-perf-writeup/index.html`) is the case/benchmark
> deep-dive; this doc is the reviewable text version + the four things review
> actually needs: **(1) the LoC reduction math, (2) the measured perf deltas,
> (3) the real UI divergence (with screenshots), (4) the non-core / kitchen-sink
> change audit.**
This PR adds a from-scratch native terminal UI built on OpenTUI, intended to
replace the React/Ink TUI **and the Ink fork we maintain alone**. It currently
ships as a parallel engine (Ink untouched, auto-fallback), selected by
`HERMES_TUI_ENGINE` env > `display.tui_engine` config > auto (OpenTUI when the
host is Node ≥ 26.3 with the built bundle, else Ink). **100% parity with the Ink
TUI is the bar.**
---
## 1. Line-of-code reduction (the headline maintenance win)
All counts are **git-tracked files only** (respects `.gitignore`; `dist/` and
`node_modules/` are untracked and excluded). Measured live on this branch at
`~HEAD`. "Code" = `.ts/.tsx/.js/.jsx` only; "total" includes config/json/md.
### What gets *removed* when Ink is retired
| Area | Files | Total lines | Code lines (ts/tsx/js) | Non-blank code |
|---|---:|---:|---:|---:|
| `ui-tui/src/` — Ink **consumer app** (our React/Ink view code) | 204 | 40,422 | 40,422 | 33,550 |
| `ui-tui/packages/hermes-ink/`**the fork** (`@hermes/ink`) | 148 | 28,167 | 28,113 | 23,718 |
| **`ui-tui/` whole tree (tracked)** | **362** | **69,320** | **68,831** | **57,545** |
The `ui-tui/` whole-tree number (69,320) also folds in a handful of build
scripts, `.prettierrc`, `package.json`, etc. The two rows above it are the
load-bearing split:
- **The fork alone is 28,167 LOC across 148 files** — code we own and can never
sync from upstream. Upstream Ink v6.8.0 `src/` is ~7,259 LOC, so the fork's
renderer core is **~3.2× the size of stock Ink**. (Cross-checked against the
HTML write-up's `ink-fork-analysis.json`: 28,111 LOC / 148 files — the 56-line
delta is a single tracked JSON the file-level count includes.)
- **The consumer app is another 40,422 LOC** — React components/hooks that only
exist to drive Ink.
### What gets *added*
| Area | Files | Total lines | Code lines | Non-blank code |
|---|---:|---:|---:|---:|
| `ui-opentui/src/` — new engine (app code **+ its own tests**) | 153 | 28,763 | 28,763 | 26,495 |
| &nbsp;&nbsp;↳ non-test (app code only) | 97 | 16,628 | 16,628 | 15,450 |
| &nbsp;&nbsp;↳ tests (`src/test/`) | 56 | 12,135 | 12,135 | 11,045 |
| Tree-sitter grammars (`python``toml`) | 0 | 0 | 0 | 0 |
| **`ui-opentui/` whole tree (tracked)** | **~170** | **~34,800** | **29,614** | **27,283** |
> Tree-sitter grammars carry **zero repo lines**: the engine declares the 10
> extra grammars as remote URLs (`src/boundary/parsers.manifest.json`) and
> OpenTUI fetches+caches each `.wasm`/`.scm` on first use into
> `~/.hermes/cache/opentui-parsers/` (à la opencode, which vendors none). An
> earlier revision vendored them as 37,302 checked-in binary lines (10 `.wasm` +
> 10 `.scm`); that's gone — code lines and total lines now move together.
### The net reduction (code lines, the honest comparison)
| Comparison | Removed (ts/tsx/js) | Added (ts/tsx/js) | Net change |
|---|---:|---:|---:|
| **Incl. fork** — retire all of `ui-tui/` vs add `ui-opentui/src` | 68,831 | +28,763 | **40,068 LOC (58%)** |
| **Incl. fork, app-vs-app** (exclude both test suites) | 56,463¹ | +16,628 | **39,835 LOC (71%)** |
| **Excl. fork** — only the Ink *consumer app* vs new engine | 40,422 | +28,763 | **11,659 LOC (29%)** |
| **The fork in isolation** (the unsyncable liability we shed) | 28,113 | — | **28,113 code lines deleted outright (28,167 incl. its 1 config file)** |
¹ `ui-tui/src` non-test = 28,350 LOC + fork (≈ all 28,113 code lines are non-test;
it carries only ~54 config lines) = 56,463. (`ui-tui/src` carries 80 test files /
12,072 LOC; the new engine carries 56 test files / 12,135 LOC.)
**Read it this way:**
- **The cleanest single number: ~40k code lines net** (retire all of `ui-tui/`,
add `ui-opentui/src`). That is a **~58% reduction in the TUI's
hand-maintained surface**, and it *includes* the new engine's full 56-file test
suite.
- **The most important number is the fork: 28,167 LOC of unsyncable engine
code** disappears. That is the load-bearing maintenance win — it's not just
fewer lines, it's lines we are the *sole* maintainer of (own reconciler, ANSI
parser, scrollbox, selection/OSC52, hand-rolled memory eviction, Yoga binding).
- **Even excluding the fork** — i.e. if you imagine upstream Ink were free — the
app rewrite is still a net reduction (11,659 LOC) because the new engine
mounts OpenTUI built-ins instead of hand-building components.
### Caveat on the comparison (keep it honest for review)
- These are **whole-tree retirements vs a single source dir add.** If/when Ink is
deleted, the `ui-tui/` `package.json`, lockfile, and build scripts go too; the
table counts `ui-tui/src` + the fork as the apples-to-apples "hand-maintained
TS" figure.
- **Tree-sitter grammars are NOT vendored.** The 10 extra grammars are declared
as remote URLs (`src/boundary/parsers.manifest.json`); OpenTUI fetches each
`.wasm`/`.scm` on first use of a language and caches it under
`~/.hermes/cache/opentui-parsers/` (profile-aware, set via
`HERMES_TUI_PARSER_CACHE` by the launcher). Registration does **zero** network;
the fetch is lazy and off the boot critical path, and an unreachable
GitHub/air-gapped env degrades that language to plain text — never a throw. This
replaces an earlier revision that vendored 37k binary lines, so the repo no
longer grows on disk for syntax highlighting. (Trade-off: first-use-per-language
needs network to `github.com`/`raw.githubusercontent.com`; pre-seed the cache in
a Docker build if you need offline highlighting.)
- Python/backend LoC is **not** part of this reduction: `tui_gateway/` (~12k LOC)
is **shared by both engines** and stays. See §4.
---
## 2. Performance (CPU / latency / memory)
Measured with the `tui-bench` harness driving **both engines on a real PTY
120×40**, fake gateway feeding deterministic events, `/proc`-sampled identically,
each SUT under `systemd-run --scope -p MemoryMax=2G -p MemorySwapMax=0`,
sequential with a load-gate + 10s cooldown. Determinism gate **GREEN**, 71 result
files, 0 cell errors, 3 reps/cell, `@opentui/core` 0.4.1 native-yoga
(`libopentui.so`, no `yoga.wasm`). Every number traces to a `summary.<field>` in
a result dir. Source: `~/projects/opentui-html/bench-numbers.json` (frozen
2026-06-14, build under test `1ddf7a102` + WIP).
### Scorecard
| Dimension | Winner | Margin | Source cell |
|---|---|---|---|
| Streaming frame rate | **OpenTUI** | **~3×** (43 vs 14 fps) | `cpu800.frame_pacing` |
| Streaming smoothness (interframe p95) | **OpenTUI** | **40ms vs ~220ms** (no ¼-second stalls) | `cpu800.frame_pacing` |
| Scroll CPU | **OpenTUI** | **~2.7× cheaper** (134155 vs 403416 ticks) | `scroll3000.scroll.cpu_ticks` |
| Cold-start floor | **OpenTUI** | ~97103 vs ~107109 MB | `startup.vmhwm_kb` |
| Session-create latency | **OpenTUI** | ~151177 vs ~204229 ms | `startup.session_create_ms` |
| First-byte paint | Ink | ~93 vs ~122 ms | `startup.first_byte_ms` |
| Memory @ small/typical | Ink | OpenTUI +3050 MB | `mem50/100/300.vmhwm` |
| Memory @ heavy tool output | **OpenTUI** | **crossover** (258265 vs 280290 MB) | `results-fat-mem-*` |
| Layout reflow latency | **Ink** | **~0ms vs ~13ms** (OpenTUI's one honest loss) | `resize3000.resize.reflow_ms` |
### The honest reading
- **OpenTUI wins everything you feel continuously** — frame rate (~3×), scroll
CPU (~2.7×), and smoothness (no 200ms hitches; p95 40ms vs ~220ms). This is the
lead. The single most user-perceptible difference is the stall-free stream.
- **Memory: lead with smoothness, not raw RSS.** Ink is lighter at small/typical
sizes (OpenTUI carries a ~102 MB irreducible Node+V8+`libopentui.so` floor, so
it sits +3050 MB above Ink there). But it **crosses over** under heavy tool
output (mem300: 258265 MB OpenTUI vs 280290 MB Ink) because windowing beats
Ink's mount-every-row. Real-world: 20 memwatch sessions show a flat ~108 MB
floor and ~0 MB/h on long sessions (one 15h session, 0 MB/h; one 4.4h session
plateaus flat at ~237 MB with mounted rows pinned at 33).
- **The one outright loss is layout reflow** (~13ms p50 vs Ink's ~0ms; under a
resize storm OpenTUI degrades to ~14fps/~197ms vs Ink ~26fps/~100ms). Heavier
native renderables vs Ink's string nodes. This is a real, quantified
optimization target — **not** a regression vs current behavior, and **not** the
"halved 0.4.0→0.4.1" delta (we measured the absolute 1215ms only; do not quote
"halved" from this run).
- **The memory fix is engine-agnostic** — a rolling display cap
(`HERMES_TUI_MAX_MESSAGES=3000` default) that is display-only and never touches
the model's context. Uncapped is a stress config, not real usage (10k msgs
uncapped: 793 MB; capped sessions are flat MB/h).
- **Gut-check vs upstream/opencode: no bugs.** Exactly one frame callback
(early-exits cheaply), zero `writeToScrollback` for the transcript (one sticky
`<scrollbox>` + reactive `<For>`), native `<markdown streaming>` byte-for-byte
parity with live opencode, no reactive-read-outside-tracking-scope (the #1 Solid
trap). Source: `docs/plans/opentui-gutcheck-verification.md`.
Full methodology + every cell: see the HTML write-up's benchmark sections and
`docs/plans/opentui-endgame-benchmark-report.md`.
---
## 3. UI parity — and where the two engines genuinely diverge visually
100% *feature* parity is the bar (matrix in §6), but the two engines are **not**
visually identical. The Ink TUI renders the transcript as a **box-drawing tree**;
OpenTUI renders it **flat and marker-based**. This is a deliberate design
divergence, captured in `ui-opentui/src/view/messageLine.tsx`:
> *"the view is a dark room and gold is the single lamp — it sits on the NEWEST
> answer's `⚕` and the user's ``, nowhere else (older assistant glyphs demote to
> grey: they merely happened)."*
Real screenshots (saved under `docs/research/opentui-screenshots/`), captured live
on a real PTY 120×40 via the `tmux-pane-screenshot` workflow — **same session
resumed in both engines** where possible.
### Legacy Ink — `docs/research/opentui-screenshots/ink-transcript.png`
![Ink transcript](research/opentui-screenshots/ink-transcript.png)
- **Box-drawing tree layout.** Each turn is a nested structure: `└─ Response`,
`└─ ▾ Tool calls (1)`, ` └─ ● Terminal("…")` — explicit corner rails and
disclosure triangles.
- **`┊` dotted quote-bar** prefixes assistant prose.
- **Tool calls collapse by default** behind a `▾ Tool calls (N)` disclosure,
nested one rail deeper.
- **Whole assistant message tinted gold/amber** (body text is colored, not just
the marker).
- Right-edge scrollbar: thin `│` track + `┃`/orange thumb.
- Status bar: `─ ready │ opus 4.8 fast high │ 0/1m │ [░░░░░░] 0% │ 25s │ voice off │ 1 session ─ ~`
— leading dash, pipe-delimited fields, trailing `~`.
- **No top header bar.**
### New OpenTUI — `docs/research/opentui-screenshots/opentui-transcript.png` (+ `opentui-toolcall.png`)
![OpenTUI transcript](research/opentui-screenshots/opentui-transcript.png)
![OpenTUI tool call](research/opentui-screenshots/opentui-toolcall.png)
- **Flat, marker-based layout.** No tree rails. Assistant = `⚕` (caduceus, gold
only on the newest answer), user = `` (gold chevron + gold text). Older
assistant glyphs demote to grey.
- **Neutral body text.** Gold is reserved for markers and inline-code accents;
prose is grey/white (the "single lamp" rule), so the screen reads calmer than
Ink's all-amber blocks.
- **Tool calls render inline, expanded, on one header line:**
`⚕ ▶ delegate_task Run the shell command `` (/agents to monitor) · 41s (11 lines)`
— marker, `▶` collapse triangle, bold tool name, grey arg preview, hint,
`· duration`, `(N lines)` — and the result flows flat directly below (no nesting
rail). Per-tool renderers exist (`view/tools/registry.tsx`) — bash/file+diff/
read/search/skill/clarify/todo each render differently, not a uniform dump.
- **Per-block `⧉ copy` affordance** on a quiet footer line under every settled
assistant block and user prompt (click → copies that block's source).
- **Top header bar:** `⚕ Hermes Agent · opentui · ready` + a gold horizontal rule
(Ink has none).
- Status bar (real backend): `● claude-fable-5 │ [▒▒▒] 4% │ …/lively-thrush/hermes-agent (feat/opentui-native-engine)`
— green status dot, model, context/token bar, **right-pinned cwd + branch**.
### Divergence summary table
| Aspect | Ink (legacy) | OpenTUI (new) |
|---|---|---|
| Transcript structure | Box-drawing **tree** (`└─`, rails) | **Flat**, indented, marker-based |
| Assistant marker | `└─ Response` rail + `┊` quote-bar | `⚕` caduceus glyph |
| User marker | (rail) | `` gold chevron |
| Assistant body color | Tinted gold/amber | Neutral grey/white (gold = accents only) |
| Tool calls | Collapsed `▾ Tool calls (N)`, nested | Inline expanded header + flat result |
| Per-tool rendering | Largely uniform | Dedicated renderers per tool |
| Copy affordance | `/copy` command | `/copy` **+ per-block `⧉ copy`** |
| Header bar | None | `⚕ Hermes Agent · opentui · ready` + rule |
| Status bar | `─`/`│`-delimited, trailing `~` | dot + bars + right-pinned cwd/branch |
**For review:** the divergence is intentional (a design pass, not an accident),
but it means "drop-in replacement" is true at the *feature* level, not the
*pixel* level. A user switching engines will immediately notice the flatter,
calmer transcript. Worth calling out explicitly so the swap isn't sold as
visually invisible.
---
## 4. Non-core / kitchen-sink change audit (what review should scrutinize)
Full report: **`docs/research/opentui-noncore-change-audit.md`** (file-by-file,
commit-by-commit, with `file:line` evidence). Summary below.
This PR's net footprint vs `origin/main` (two-dot diff = exactly this PR's adds,
no main work re-included):
| Bucket | Files | Net diff |
|---|---:|---:|
| UI (`ui-opentui/`, the engine + tests) | 197 | +36,001 / 1 |
| Docs | 8 | +1,164 / 0 |
| **Other (the review-flag surface)** | **28** | **+3,218 / 204** |
The 28 "other" files are the only place this PR touches shared Hermes core. They
classify as:
### ✅ CORE-OPENTUI-NECESSARY (the engine can't work without these; Ink path provably untouched)
- **`hermes_cli/main.py`** (+382/5) — dual-engine launcher (engine resolution,
Node 26 / fnm detection, `_make_opentui_argv`, heap override). Default falls
back to Ink unless the host is OpenTUI-ready (`main.py:1685`); OpenTUI is
dispatched *around* the Ink bootstrap, never through it (`main.py:1914-1922`).
- **`scripts/install.sh`** (+78/1) — `install_opentui` stage, **strictly
best-effort** (every failure returns 0; falls back to Ink; Windows/Termux
skipped). Ink install path unchanged.
- **`Dockerfile`** (+21/11) — Node 22→**26** bump (required by the `node:ffi`
renderer) + `ui-opentui` build step. Opt-in; Ink build line preserved. **Caveat:
the Node major bump affects the whole image (Ink + web + Playwright)** — the
diff self-flags "verify the full image build on Node 26 in CI."
- **`hermes_cli/_parser.py`** (+16/2) — bare `--resume` → OpenTUI session picker;
`--resume <id>` unchanged.
- **`tui_gateway/server.py`** (+612/40) — predominantly opt-in RPCs/fields the
new engine calls (`session.peek`, `session.list` filters, `startup.catalog`,
`diff_unified`, window-title, skin keys). Each is gated so **the Ink path is
byte-for-byte unchanged** (`server.py:3930`, `:4254`, `:10447`). *Note:* this
file also carries some of the cost-accounting code (below) — separable.
> `tui_gateway/` (~12k LOC Python) is **shared by both engines** and is **not**
> removed when Ink is retired. Only the `ui-tui/` frontend tree goes.
### 🚩 FLAG FOR REVIEW — Category C, separable from an OpenTUI PR
These do **not** need to ship with the engine and a reviewer should ask to split
them out:
1. **Provider-reported-cost accounting** (commits `85546bb9e` + `364b93a4b` +
`e01b04de4`) — a coherent feature spanning **11 files**: `agent/usage_pricing.py`,
`plugins/model-providers/openrouter/__init__.py`,
`agent/transports/chat_completions.py`, `agent/agent_init.py`, `run_agent.py`,
`agent/conversation_loop.py`, `agent/account_usage.py`, `hermes_state.py`,
`gateway/slash_commands.py`, the cost half of `cli.py`, and the
`_get_usage`/`_compact_usage_text` blocks of `tui_gateway/server.py` (+ 5 test
files). Strongest evidence: commit `85546bb9e` *"gateway: capture real
provider-reported cost (openrouter usage accounting)"* — a provider-accounting
rework, not a renderer.
2. **`plugins/model-providers/openrouter/__init__.py`** — sends
`usage:{include:true}`, a provider request-shape change affecting *all*
interfaces, not just the TUI (`openrouter/__init__.py:85-90` cites the
OpenRouter usage-accounting docs).
3. **Worktree lock / dirty-tree preservation** (commit `94765e48f`,
`cli.py` + `tests/cli/test_worktree.py`, ~145 lines) — git-worktree lifecycle
safety plumbing with **zero TUI references** (`cli.py:1391-1545`, `:1635-1713`).
4. **`tools/clarify_tool.py`** (+16/4) — docstring/schema-description-only fix
(commit `16e408f3f`); applies to every interface, trivially separable.
### ✅ Conversation-loop / role-alternation / prompt-cache correctness verdict: **NO RISK**
Verified: none of `run_agent.py`, `agent/conversation_loop.py`,
`agent/agent_init.py`, `agent/transports/chat_completions.py` touch
message-role alternation or the prompt-cache prefix. The
`conversation_loop.py` added lines grep clean for
`cache_control|alternation|prompt_cach|api_messages`; the cache/alternation
machinery (`:57`, `:660-674`, `:759`) is untouched; the PR's insertion at
`:1809-1879` is purely additive cost bookkeeping after `cost_result`. **Prompt
caching and strict role alternation are preserved.**
---
## 5. What this does and does NOT fix
**Fixes (structurally, by replacing the rendering substrate):** the renderer bug
class — layout/scroll/input/copy/mouse/markdown/resize — plus the
hand-maintained memory-eviction problem (windowing + Solid keyed `<For>`
unmount→`destroy()``free()`), and several long-open feature requests (mouse,
collapsible tool calls, session title/status bar, double-ESC, chronological
thinking/tool ordering).
**Does NOT fix:** the gateway is unchanged — the biggest single hotspot file in
triage is `tui_gateway/server.py`, and whole bug clusters are gateway/Python-side
(WS write-timeout/RPC pool, MCP-failure startup freezes, shell.exec denylist).
The engine swap addresses rendering/input/scroll/memory; **gateway bugs ride
along.** The Effect-boundary hardening does make those failures *visible* (typed
events → system lines instead of a frozen spinner) and the TUI auto-heals
(crash → backoff → respawn → resume, capped 3/60s).
---
## 6. Feature parity matrix (vs the Ink TUI)
Verbatim, detailed, surface-by-surface with `file:line` evidence:
**`docs/plans/opentui-ink-parity-matrix.md`** (interactive/filterable version in
the HTML write-up). Headline state:
| Surface | State |
|---|---|
| Transcript rendering (scrollbox, markdown, code, diffs, collapsible tools, reasoning, chronological order, windowing) | **full parity (9/9)** |
| Blocking prompts (approval/clarify/sudo/secret/confirm) | **full parity (5/5)** |
| Theming (skins, light/dark, ANSI-256 norm) | **full parity** |
| Mouse / copy (tracking, selection, multi-click, OSC52, click-to-expand, wheel accel) | **full parity** |
| Resilience (crash auto-heal + resume) | **parity++ (exponential backoff)** |
| Composer / input | near parity — **missing: external editor (Ctrl+G → `$EDITOR`)**; ghost-text autosuggest partial |
| Slash commands | core parity — **missing: `/setup`, `/redraw`, `/plugins`, `/voice`**; `/undo` prefill + `/image` partial |
| Status bar / header chrome | almost all closed — **missing: MCP-servers panel, profile-in-prompt** |
| Agent surfaces | most shipped — **missing: voice indicators, browser/CDP indicator** |
| Utility commands | **missing: `/redraw`, `/setup`**; rest present |
> The original PR-draft gap list was **substantially stale** — the WIP since
> shipped context %/token bar, cost, compressions, duration, update banner, todos
> panel, activity feed, notifications, background-task indicator, **and per-tool
> renderers** (the "every tool renders the same" claim is false:
> `view/tools/registry.tsx` has dedicated renderers).
### Genuinely-remaining parity gaps
- [ ] **External editor (Ctrl+G → `$EDITOR`)** — highest-impact missing composer affordance
- [ ] MCP-servers detail panel; profile-in-prompt marker
- [ ] Voice indicators (listening/transcribing/REC/STT) + `/voice`
- [ ] Browser/CDP connection indicator + `/browser`
- [ ] `/setup` wizard handoff, `/redraw`, `/plugins` hub
- [ ] Draggable scrollbar; sticky-prompt line
- [ ] `/undo` prefill into composer; model-picker persist-global toggle; skills-hub install/manage
---
## 7. Rollout, runtime & risks
- **Runtime:** plain Node 26 (FFI floor 26.3+) — one runtime, no Bun. (Note: the
upstream OpenTUI docs say "requires Bun"; this engine deliberately runs on Node
26's experimental `node:ffi` instead — that's the load-bearing runtime decision.)
- **Rollback:** Ink is untouched and remains the fallback; reverting is a launcher
decision, not a code revert.
- **Default-engine selection:** auto-picks OpenTUI only when the host is genuinely
set up (Node ≥ 26.3 + built bundle), else Ink; explicit env/config bypasses the
probe.
- **Known sharp edges:** `libopentui.so` native-lib distribution (P1 upstream:
copies can fill `/tmp`); the Dockerfile Node major bump needs full-image CI
verification; tree-sitter grammars are fetched from GitHub on first use and
cached in `~/.hermes/cache/opentui-parsers/` — air-gapped hosts get plain-text
highlighting until the cache is pre-seeded (the fetch never blocks boot and
never throws).
## 8. Try it
```bash
hermes # auto-selects OpenTUI when the host supports it
HERMES_TUI_ENGINE=opentui hermes # force the native engine
HERMES_TUI_ENGINE=ink hermes # force the legacy Ink engine
# preview standalone (no backend), Node 26:
cd ui-opentui && npm install
node scripts/build.mjs scripts/demo.tsx .demo
DEMO_TOTAL=120 HERMES_TUI_MAX_MESSAGES=80 \
node --experimental-ffi --no-warnings .demo/demo.js # inside a TTY
```
Requires Node 26.3+. On older Node / Windows / Termux it auto-falls-back to Ink.
---
## Appendix — source-of-truth files in this repo
| Topic | File |
|---|---|
| Non-core change audit (full) | `docs/research/opentui-noncore-change-audit.md` |
| Feature parity matrix (verbatim) | `docs/plans/opentui-ink-parity-matrix.md` |
| Benchmark report | `docs/plans/opentui-endgame-benchmark-report.md` |
| Gut-check verification | `docs/plans/opentui-gutcheck-verification.md` |
| Ink↔OpenTUI capture asymmetry | `docs/plans/opentui-ink-asymmetry-note.md` |
| UI screenshots | `docs/research/opentui-screenshots/{ink,opentui}-*.png` |
| PR description (prose) | `docs/pr-description-main-doc.md` |
| Interactive write-up | `~/projects/opentui-perf-writeup/index.html` (out-of-repo) |

View File

@ -1,73 +0,0 @@
# Upstream alignment — how we inherit OpenTUI's performance work for free
Context (maintainer, 2026-06-11): opencode's 100-message cap was a November-era
performance workaround, since obsoleted; the **next OpenTUI version ships
native yoga** (≥2× layout performance, more improvements building on it);
opencode does not use virtualization.
## The invariant that makes alignment free
**We are forkless and public-API-only.** The windowing layer (S1+S2) drives the
STOCK `<scrollbox>` through documented surface only — `onSizeChange`,
`setFrameCallback`, `scrollTop`/`viewport`/`scrollHeight`, Solid `<Show>`
mount/unmount. Zero patches to `@opentui/core`. Every upstream release
therefore drops in by bumping three pinned versions in `ui-opentui/package.json`
(`@opentui/{core,keymap,solid}`, currently 0.4.0). Keep it that way: any new
code that needs core behavior goes through a `boundary/` wrapper, never a
patched dependency.
## What native yoga changes for us (and what it doesn't)
- **Kills the WASM ratchet** (grow-only linear memory → freeable native
allocations). This retro-justifies S2 less, but S2's append-time windowing
remains correct: transient mounted peaks still cost handles and RSS.
- **Does NOT obsolete windowing.** The binding constraint is the 65,535-slot
native handle table: ~47 handles/row × 3,000 stored rows ≈ 141k handles —
over the table at ANY layout speed. Windowing is what makes the 3,000-row
scrollback possible; yoga's backend is irrelevant to that math.
- **Makes windowing feel even better**: 2× layout = cheaper margin remounts =
smaller window margins viable and less exposure for the one accepted limit
(estimate-height snap under scrollbar jumps). After the bump, re-tune margin/
hysteresis against the scroll cell.
## The shim ledger (delete-on-upstream-fix; all in `ui-opentui/src/boundary/`)
| shim | what it papers over | delete when |
|---|---|---|
| `ffiSafe.ts` | u32 draw coords go negative under Node FFI (Bun silently wraps) — ERR_INVALID_ARG_VALUE loop | upstream clamps, or Node FFI path is officially supported |
| `nativeHandles.ts` | SyntaxStyle exhaustion crashes mid-mount; degrade-to-unstyled | handle table widened (INDEX_BITS>16) or per-kind tables |
| `renderer.ts` exit-signal guard | core 0.4.0 treats SIGPIPE (clipboard spawn) as an exit signal; its own uncaughtException handler allocates a handle and dies (exit-7 masking) | both fixed upstream |
| `clipboard.ts` hardening | same SIGPIPE incident class | with the above |
Each is (a) isolated, (b) inert if upstream fixes the behavior, (c) worth
reporting upstream — four concrete, reproduced, root-caused issues. Filing them
is the cheapest alignment lever we have: it converts our workarounds into
upstream regression tests. (Needs glitch's go-ahead — public repo activity.)
## The upgrade playbook (per upstream release)
1. Branch `chore/opentui-X.Y.Z`, bump the three pins, `npm ci`.
2. `npm run check` (648 tests; the windowing invariants — identical
scrollHeight ON/OFF, byte-stable frames across corrections — are literal
assertions and will catch behavioral drift).
3. Bench acceptance, sequential: `--cell gate` (determinism digest; EXPECT a
new digest if upstream changed rendering — eyeball the frame, re-bless),
`--cell mem3000 --msgs 2000` + `--cell scroll --msgs 3000` vs current
numbers (300375MB / p99 68ms), `--cell pipeline` (frame pacing ≥22fps).
4. Shim audit: try each boundary shim OFF; delete the ones upstream fixed.
5. Live tmux smoke (scroll sweep / resize / selection-copy), screenshots.
6. Windowing re-tune if layout got faster: margins up or hysteresis down,
re-run scroll cell, keep p99 ≤ 17ms gate.
The bench suite IS the upgrade contract — it's exactly the harness that lets
us take every upstream improvement within a day of release, with proof.
## Questions worth relaying to the maintainer
1. Any plan to widen the 16-bit native handle table (or split per-kind)?
That's our hard ceiling, independent of yoga.
2. Is the Node `--experimental-ffi` path on their support radar, or Bun-only?
(Native yoga adds new FFI surface; we run Node.)
3. Would they take the windowing layer's core-agnostic pieces (exact-height
spacer pattern, correction-legality rule) as a documented recipe or
framework-level utility? We have it production-shaped with tests.

View File

@ -1,150 +0,0 @@
# OpenTUI — Background Activity: agents inspection, background panel, notifications + density
**Status:** SPEC (brainstormed with glitch 2026-06-13) · target branch `feat/opentui-native-engine`
**Hard constraint:** TUI-LAYER ONLY (`ui-opentui/`). **Zero changes to `tui_gateway/server.py` or
`run_agent.py` core.** Build only on gateway events/RPCs that already exist. Everything below was
feasibility-checked against the live gateway surface (see "Gateway surface" §).
## Why
Dogfeedback (screenshots `iznq/qxpe/rpiw/rplj`):
1. **Agents dashboard is too crowded** (`rplj`) — master rows dump each subagent's full multi-line
prompt; the trace pane is squished. Inspection + transcript reading is "not great."
2. **Background processes are basically invisible** (`qxpe`) — completions leak into the transcript
as plain lines that read like model output; no panel, no badge, notifications are non-existent.
3. **Input zone is too crowded** (`rpiw`) — status bar + composer + agents tray + completion menu +
shell note stack under the transcript.
## Design decisions (from the brainstorm)
- **Two SEPARATE surfaces, ONE shared substrate.** Background *agents* (delegated subagents) and
background *work* (detached runs + OS processes) are visually/feature-wise distinct, but share the
underlying tracking + notification + badge plumbing.
- **Notifications are multi-channel** on every relevant state change:
- **(C) inline card** in the transcript — a distinct, colored, collapsed *system card*, clearly
NOT model output (replaces today's plain-line leak).
- **(A) ambient badge** — a live count in chrome (status-bar `bg:`/the `⚡ N agents` tray) that
flashes on change; you pull-to-inspect. Stays visible while things run.
- **OSC desktop** — reuse the EXISTING `boundary/termChrome.ts` (`notify`, OSC 9/99/777, already
focus-gated so it only fires when the terminal is blurred).
- **Agents surface = inspection only.** No foregrounding / "become the subagent" (that would change
core subagent UX — explicitly out of scope). Scannable list + a faithful render of the *already-
tracked* live activity (goal/model/reasoning/tool calls/progress/summary). No new fetch.
- **Background surface = view + stop.** List runs + OS processes with status/uptime; cancel a run
(`session.interrupt`/`subagent.interrupt`); **stop-all** OS processes (`process.stop`). Per-process
kill and per-process logs are NOT exposed as RPCs → out of scope under the no-core rule (noted).
- **Input density is in scope** (own phase).
## Gateway surface we build on (verified — all already exist)
| Need | Mechanism (existing) |
|---|---|
| Background-run lifecycle | `prompt.background` (start), `background.complete` (event) |
| Notifications | `notification.show` / `notification.clear` events — payload `{text, level, kind, ttl_ms, key, id}` |
| Subagent stream | `subagent.spawn_requested/start/thinking/tool/progress/complete` events (store already consumes) |
| List OS processes | `agents.list` RPC → `{processes:[{session_id, command, status, uptime_seconds}]}` |
| Stop OS processes | `process.stop` RPC → `kill_all()` (**all**, not per-process) |
| Cancel a run / subagent | `session.interrupt`, `subagent.interrupt` |
| List active sessions/runs | `session.active_list`, `session.status` |
| Subagent trace (archived) | `spawn_tree.list/load` (already used by `/replay`) |
| OSC desktop notify | `boundary/termChrome.ts` `notify(TermNotification)` |
**Honest limits (no-core constraint):** OS processes get list + stop-all only — no per-process kill
(`process_registry.kill_process` exists but isn't an RPC) and no per-process log tail
(`read_log` isn't an RPC). If the no-core rule is ever relaxed, each is a ~5-line additive `@method`.
## Architecture (Approach 1 — substrate-first)
```
gateway events ──► store: backgroundActivity slice ──► derived counts/state
│ │
├─► notificationDispatcher ─────────┼─► (C) inline card (transcript)
│ (card + badge + OSC) ├─► (A) ambient badge (statusBar/tray)
│ └─► OSC via termChrome.notify
├─► Surface 1: AgentsDashboard (revamp) — list + rich activity pane
└─► Surface 2: BackgroundPanel (new) — runs + processes, stop
```
### Shared substrate (the "underneath" both surfaces use)
- **`logic/backgroundActivity.ts`** (new) — pure model + reducers. Types:
- `BackgroundRun` (from `prompt.background`/`background.complete`/`session.active_list`):
`{ id, label, status: 'running'|'complete'|'failed'|'cancelled', startedAt, summary? }`
- `BackgroundProcess` (from `agents.list`): `{ sessionId, command, status, uptimeSeconds }`
- `Notification` (from `notification.show`): `{ id, key?, text, level, kind, ttlMs?, at }`
- Pure helpers: `applyNotification`, `clearNotification(key)`, counts (`runningCount`),
`mergeProcessList`, dedupe by `key`/`id`. Fully unit-testable (no renderer).
- **`store.ts`** — a `backgroundActivity` slice + event handlers for `notification.show/clear`,
`background.complete`, and a polled `agents.list` snapshot (poll only while a panel/badge is live,
or piggyback existing cadence). Existing `subagent.*` handling is untouched.
- **`logic/notificationDispatcher.ts`** (new, pure) — given a state-change, decide the channels:
returns `{ card?: SystemCard, badge: delta, osc?: TermNotification }`. The boundary calls
`termChrome.notify` for the OSC part; the store appends the card + bumps the badge.
### Surface 1 — Agents inspection overlay (revamp `view/overlays/agentsDashboard.tsx`)
- **Master list rows = ONE line each:** `<statusGlyph> <truncated goal (truncRight to width)> · <model>`.
No multi-line prompt dump. Selected row highlighted (existing `▸` + accent).
- **Detail pane = faithful activity transcript** of the selected agent, styled like the main
transcript (not flat dumped lines): goal+model header, then the trace rendered by *type*
(reasoning / tool-call+result / progress / final summary), newest last, sticky-bottom, PgUp/PgDn.
- Requires giving `SubagentInfo.trace` light typing (`{ kind:'tool'|'reasoning'|'progress'|'summary', text }`)
instead of `string[]`, populated where `subagent.*` events are reduced. Internal data-shape
change only; no gateway change.
- Keep Esc/q close, ↑↓ select. Reuse theme + `truncRight` from statusBar.
### Surface 2 — Background panel (new `view/overlays/backgroundPanel.tsx`)
- **Two sections:** *Runs* (background agent runs) and *Processes* (OS processes from `agents.list`).
- Each row: status glyph + label/command (truncated) + uptime/elapsed + status.
- **Actions:** `↑↓` select; on a *run*`c` cancel (`session.interrupt`/`subagent.interrupt`);
global **stop-all processes** (`x``process.stop`, confirm). Esc/q close.
- **Access:** new client slash `/bg` (alias `/background`, `/jobs`) in `logic/slash.ts` CLIENT set →
`store.openBackgroundPanel()`. Also reachable from the ambient badge.
- Poll `agents.list` on open + on a light interval while open; stop polling on close.
### Notifications (the (C)+(A)+OSC wiring)
- **(C) inline card** — a new transcript element `view/notificationCard.tsx`: a bordered/colored,
`selectable:false` system card keyed by `notification.id`, level-tinted (`info/warn/error`),
collapsed to one line by default with the `kind` + `text`; clearable by `notification.clear` key.
Appended into the message stream as a distinct row type (NOT a plain `system` text line). Replaces
the current plain-line leak. (`/details` interplay: cards are chrome, always shown, never windowed.)
- **(A) ambient badge** — `statusBar.tsx` `bg: N` segment (already reserved) bound to
`runningCount()`; the `agentsTray.tsx` count already exists — extend it to "agents + background."
Flash/recolor on a fresh notification (brief).
- **OSC** — on `notification.show` with a terminal level (complete/failed), call
`termChrome.notify({title, body})` (already focus-gated). No new escape-sequence code.
### Input-zone density pass (`view/composer.tsx` / `view/App.tsx`)
- Audit what stacks under the transcript and collapse/gate: the `⚡ N agents` tray line folds into
the ambient badge (shrinks one line); ensure the shell-mode note, completion menu, and status bar
don't co-stack more than necessary. Concrete rules decided with a tmux density pass (ASCII-mocked,
approved) — kept minimal; no behavior change, just fewer competing chrome lines.
## Phases (implementation order — each gated + tmux-smoked + committed)
- **P1 — Notification substrate** (`backgroundActivity.ts` + `notificationDispatcher.ts` + store
slice + `notificationCard.tsx` + badge wiring + OSC call). Highest visible win; the shared core.
- **P2 — Agents inspection revamp** (`agentsDashboard.tsx` + typed `trace`). De-crowds `rplj`.
- **P3 — Background panel** (`backgroundPanel.tsx` + `/bg` + actions). New surface.
- **P4 — Input density pass.** Folds the tray into the badge; trims co-stacked chrome.
## Testing / gates (per phase)
- **Pure logic** (`backgroundActivity`, `notificationDispatcher`, slash `/bg` routing,
trace-typing) → vitest unit tests, TDD where natural.
- **Views** → headless frame tests (`renderProbe`) for the card, the de-crowded dashboard row
format, the background panel sections; + **live tmux smoke** (`tmux-pane-screenshot`) for each
surface using a seeded-store harness (the `uxSmoke` pattern: `store.apply`/`applyInfo`/
`commitSnapshot` + canned events).
- **Gate** `cd ui-opentui && npm run check` green (judge by real exit, not a piped tail) after each
phase; rebuild `dist/main.js`; commit `opentui(v6): …` (no attribution) and push per standing instr.
## Out of scope (explicit)
- Foregrounding / "becoming" a subagent (B/C from the brainstorm) — would change core subagent UX.
- Per-process kill + per-process log tail for OS processes — needs additive gateway RPCs (no-core veto).
- "Collect result into transcript" for finished runs — deferred (Q6=B, view+stop only).
- Any change to `tui_gateway/server.py` / `run_agent.py`.

View File

@ -1,248 +0,0 @@
# Plan — OpenTUI composer/UX batch (10 features)
> **STATUS: SHIPPED (2026-06-13).** All 10 features implemented, gate green
> (ui-opentui 714 tests + 316 gateway + 25 cost tests), F5/F6 verified live via
> tmux screenshot. Commits: `f4dacc68e` (F1/F2/F7/F8/F8b/F9/F10), `20d516ae9`
> (F4/F5/F6), `9aa5e54be` (F3). Decisions taken: **D1 = cursor-aware onType**
> (threaded `ta.cursorOffset`); **D2 = chrome cost is Nous-header-only via a new
> `nous_header_cost_usd`, `/usage` page kept full via `real_session_cost_usd`**.
> F10 (right-pinned cwd) was added mid-session by the user.
**Branch:** `feat/opentui-native-engine` · **Engine:** `ui-opentui/` (Node 26)
**Gate:** `cd ui-opentui && PATH="$HOME/.local/share/fnm/node-versions/v26.3.0/installation/bin:$PATH" npm run check` → exit 0.
## TL;DR
Nine UX fixes for the native composer + clarify prompt. **8 of 9 are front-end-only**
in `ui-opentui/`; only F3 (cost) touches the Python gateway. Every backend the new
behaviour needs (`shell.exec`, `complete.path` with `@file:`/`@folder:`/fuzzy) **already
exists** — most of this is client wiring, not new RPC surface. No new core tools, no new
`HERMES_*` env vars, no prompt-cache impact (composer/prompt are client-render only).
| # | Symptom | Fix site | Backend |
|---|---|---|---|
| F1 | bare `/` opens the modal | `logic/slash.ts:115` `planCompletion` | none |
| F2 | `/abs/path` text triggers slash | `logic/slash.ts:115` + `logic/skillMatch.ts` | none |
| F3 | cost wrong / shows for non-Nous | `tui_gateway/server.py` + `agent/usage_pricing.py` | gateway |
| F4 | can't paste until composer focused | `view/composer.tsx` onPaste/focus | none |
| F5 | clarify ugly (no wrap, weak diff, "Other" is a row) | `view/prompts/clarifyPrompt.tsx` rewrite | none |
| F6 | clarify arrows scroll the transcript | same rewrite (preventDefault) | none |
| F7 | slash highlight/menu dies after line 1 | `logic/slash.ts:114` | none |
| F8 | file mention dies after line 1 | `logic/slash.ts:114` | none |
| F8b | `@` should be the ONLY file-mention trigger | `logic/slash.ts:93` `isPathLike` | none |
| F9 | `!cmd` → run bash, show result | `entry/main.tsx` submit + new system render | uses existing `shell.exec` |
---
## F1 + F2 + F7 + F8 + F8b — the completion trigger (`logic/slash.ts`)
All five live in one ~10-line function, `planCompletion` (slash.ts:113-121). Current:
```ts
export function planCompletion(text: string): CompletionPlan | null {
if (text.includes('\n')) return null // ← F7/F8 die here
if (text.startsWith('/')) return { from: 0, method: 'complete.slash', params: { text } } // ← F1/F2
const word = /(\S+)$/.exec(text)?.[1]
if (word && isPathLike(word)) { ... complete.path ... } // ← F8b: too many triggers
return null
}
```
### F1/F2 — slash only for a real command token
- A bare `/` (no char yet) must **not** query. Require `/` + at least one name char.
- A `/abs/path` (slash followed by a path with more `/`) is **not** a command — it's
text. The slash menu should only fire when the FIRST token matches the command
grammar (`/[A-Za-z0-9][\w.-]*` — the `NAME_RE` already in skillMatch.ts:51, which
excludes `/`). `/usr/bin` fails NAME_RE → no slash menu.
- Concretely: replace `text.startsWith('/')` with: the text starts with `/`, and the
first whitespace-delimited token after the `/` is non-empty AND matches `NAME_RE`
(i.e. `/m`, `/model foo` → yes; `/`, `/usr/bin`, `/./x` → no). Reuse `slashTokens`
/`NAME_RE` from skillMatch.ts so the trigger and the highlighter share one grammar.
### F7/F8 — completion must survive newlines (shift+enter)
- `if (text.includes('\n')) return null` is the bug. It was a blunt guard so a multi-line
paste wouldn't spam path-completion. The right rule operates on the **current line /
current token at the cursor**, not the whole buffer.
- The composer passes the full `plainText` to `onType`. We don't currently pass the
cursor offset. **Decision D1 (below):** either (a) thread the cursor offset into
`onType` and complete the token under the cursor, or (b) cheap interim — slice to the
**last line** (`text.slice(text.lastIndexOf('\n')+1)`) and run the existing logic on
that. (a) is correct (mid-buffer edits), (b) is 1 line and covers the reported case
(typing at the end on line N). Recommend (a) for correctness; it also future-proofs
@-mention mid-line.
- Slash *highlighting* (skillMatch.ts `slashTokens`) **already scans multi-line text
correctly** (it iterates the whole string, newline-aware via `nativeCharOffset`). So
F7's "highlighting stopped" is really the same `planCompletion` newline bail starving
the menu; the highlight token itself still styles. Verify in the live smoke.
### F8b — `@` is the only mention trigger
- `isPathLike` (slash.ts:93) currently returns true for `@`, `~`, `./`, `../`, `/`, or
any word containing `/`. The user wants **`@`-only** (drop `~`/`./`/bare paths as
mention triggers). Narrow it to `word.startsWith('@')`.
- The gateway `complete.path` (server.py:8543) already special-cases `@` richly
(`@file:`, `@folder:`, `@diff`, `@staged`, `@url:`, `@git:`, fuzzy basename search).
Its `~`/`./` branches become dead trigger paths from this TUI — leave the gateway code
(Ink still uses the path forms; it's shared) but stop emitting those queries from
ui-opentui. **No gateway change.**
- Net: typing `@` (even bare) opens the mention menu via the `@`-bare branch at
server.py:8555. Picking splices `@file:rel/path` etc. (existing accept path,
`completionFrom` honoured).
**Tests:** extend `test/slash.test.ts``planCompletion('/')` → null; `planCompletion('/usr/bin')`
→ null; `planCompletion('/model')` → complete.slash; multi-line `"a\n/mod"` → complete.slash
on the trailing token; `"~/foo"` / `"./x"` → null (no longer path-like); `"@foo"` → complete.path.
Keep them as behaviour assertions, not snapshots.
---
## F3 — cost: Nous-portal headers only (`tui_gateway` + `agent/usage_pricing.py`)
**Current:** `_get_usage` (server.py:2157-2167) sets `cost_usd` from
`real_session_cost_usd(agent)` (usage_pricing.py:887), which sums **two** provider-reported
sources:
1. `agent.session_actual_cost_usd` — OpenRouter `usage.cost` accumulator.
2. `agent.get_credits_spent_micros()` — Nous `x-nous-credits-*` header delta.
The TUI already **hides** the cost segment when `cost_usd` is absent (statusBar.tsx:241-243,
`costText` returns '' when `costUsd === undefined`) — so this is purely "which sources count."
**User's intent (F3):** cost should come **only from the Nous portal headers**; suppress it
for every other route (cache-token pricing is unreliable across the model long tail).
**Change:** make the OpenRouter accumulator source conditional on the route being Nous, OR
drop source #1 entirely so only the header delta (source #2) feeds `cost_usd`. Source #2 is
intrinsically Nous-only (the header only exists on Nous-portal responses), so dropping #1
achieves "Nous-header-only" with one edit.
> **DECISION D2 (needs glitch's confirm):** Drop OpenRouter's `session_actual_cost_usd`
> source from `real_session_cost_usd`? Trade-off: OpenRouter's `usage.cost` is itself
> *provider-reported* (the real charged number, not a Hermes estimate), so OR users lose an
> accurate readout. But it removes the cache-token guesswork the user is worried about and
> matches "only via the headers when using nous portal" literally.
> **Recommended default (implementing unless told otherwise):** gate source #1 so it only
> contributes when the active route is the Nous portal (base_url == nous inference api),
> else it's dropped. This keeps the segment Nous-only AND avoids touching shared OR/CLI
> behaviour for the `/usage` page. If even Nous-route OR-accumulator is unwanted, collapse
> to header-only.
**Scope guard:** `real_session_cost_usd` is also consumed by `/usage` page rendering
(server.py:2237) and DB usage totals. Prefer a NEW, status-bar-specific helper
(e.g. `nous_header_cost_usd(agent)`) wired only into `_get_usage`'s `cost_usd`, leaving the
`/usage` accounting page untouched — so we don't regress the full cost report. Confirm with
the gate + a gateway unit test (`tui_gateway` tests) that a non-Nous session yields no
`cost_usd`.
---
## F4 — paste while composer unfocused (`view/composer.tsx`)
**Current:** the global keyboard handler reclaims focus on a *printable keystroke*
(`isPrintableKey`, composer.tsx:415-417). A **bracketed-paste event is not a keystroke**
it arrives at `onPaste` only if the textarea is focused, so an unfocused composer drops it;
the user has to click/type first.
**Fix:** the renderer delivers paste through the focused renderable. Two options:
- (a) Keep focus on the composer more aggressively (opencode keeps the prompt focused via a
reactive effect). Risky — fights transcript scroll focus.
- (b) **Recommended:** handle paste at the renderer/global level. Check whether OpenTUI
exposes a global paste hook (`renderer.on('paste')` or a keyboard event with
`key.name === 'paste'` / a paste event type). If a global paste signal exists, on paste:
`ta.focus()` then route the bytes into the existing `onPaste` logic (image / placeholder /
insert). **Must verify the API in the `opentui` skill before coding** (skill_view
references/docs). If only the focused-renderable paste exists, fall back to (a) scoped:
refocus the composer whenever no overlay/prompt is open and focus drifted (a
`createEffect` watching focus + `store.state.prompt`/overlay state).
**Verify in live smoke** (tmux + tmux-pane-screenshot): scroll the transcript to drop focus,
then paste — text must land without a prior click.
---
## F5 + F6 — clarify prompt rewrite (`view/prompts/clarifyPrompt.tsx`)
Screenshot `/tmp/screenshots/SCR-20260613-iznq.png` confirms: long options run off the right
edge (no wrap), options differ only by `▶`/`—` glyphs (no numbers, weak), and "✎ Other…" is
a `<select>` row that *switches* to an input on Enter rather than being an inline input.
**Current:** one native `<select>` over `[...choices, {Other}]` (clarifyPrompt.tsx:61-75).
Native `<select>` doesn't wrap long rows and (F6) doesn't `preventDefault` arrows, so they
leak to the transcript scrollbox.
**Rewrite plan (verify renderable API in `opentui` skill first):**
- Replace native `<select>` with a **custom keyboard-driven list** (a `For` over options +
a `selected` signal + `useKeyboard` with `key.preventDefault()` on up/down/enter — same
pattern the composer's `routeMenuKey` uses; F6 fixed by preventDefault so arrows never
reach the scrollbox).
- **Wrapping (F5):** render each option as a `<text>` that wraps to the box width (no fixed
single-line). Indent continuation lines under the option label. Confirm `<text>` soft-wrap
behaviour in the opentui skill (it wraps by default within a flex box of bounded width).
- **Differentiation (F5):** number every option `1.` `2.` … (digit hotkeys optional, nice-to-
have), and give the selected row the themed `selectionBg` + accent fg (the composer's
`completionCurrentBg` model), not just a glyph. Number + background + accent = three signals.
- **Inline custom answer (F5):** render the `<input>` **inside the same screen, always
present** as the last "row" (an `Other:` labeled input), instead of an item that toggles.
Selecting/focusing it lets the user type; Enter in it submits the free text. Keep the
existing `clarify.respond {answer}` wiring. Arrow-down past the last choice lands on the
input; arrow-up from the input returns to the list (focus handoff like the composer↔tray).
- Keep Esc/Ctrl+C → cancel (clarifyPrompt.tsx:31-33).
**Reference:** opencode's selection/list components in `~/github/opencode/packages/tui` for
the wrap + highlight + hotkey idiom; the composer dropdown (composer.tsx:441-458) for the
in-repo highlight/selectable pattern.
**Tests:** `test/render.test.tsx`-style headless frame — long option wraps (frame contains the
tail of a long choice on a 2nd line), selected row shows numbered + highlighted, custom input
present in the same frame, arrow keys don't change scrollTop (assert transcript scroll
unchanged), Enter on a choice → onAnswer(choice), Enter in input → onAnswer(typed).
---
## F9 — `!cmd` runs bash (`entry/main.tsx` + a system render)
**Backend exists:** `shell.exec` (server.py:10301) runs the command (30s timeout, dangerous/
hardline-command guards, returns `{stdout, stderr, code}`).
**Ink parity reference:** `ui-tui/src/app/useSubmission.ts:291``full.startsWith('!')`
`shellExec(full.slice(1).trim())` → appends a user line `!cmd` + a system line with output;
the prompt glyph flips while the buffer starts with `!` (appLayout.tsx:178).
**Plan (ui-opentui):**
- In the entry `submit` (main.tsx:517-520), add a branch BEFORE the slash check:
`if (text.startsWith('!')) { runShell(text.slice(1).trim()); return }`.
- `runShell(cmd)`: `store.pushUser('!' + cmd)` (echo the invocation in the transcript), then
`gateway.request('shell.exec', { command: cmd })`; on resolve, `store.pushSystem` the
combined `stdout`/`stderr` (or the error message / non-zero `code`); on reject,
pushSystem the error. Detached `runFork` like `submitPrompt`. No session turn, no model call.
- Empty `!` (just the bang) → no-op (or a hint), matching Ink.
- **Optional polish (parity, not required):** flip the composer prompt glyph (or tint) while
the buffer starts with `!`, like Ink's appLayout. Low-risk; do only if cheap.
**Tests:** entry-level/logic test that a `!`-prefixed submit routes to `shell.exec` (not
`prompt.submit`), and the system line renders stdout. Mirror the slashMenu.test harness
(fake gateway capturing the method).
---
## Sequencing & fences (subagent-driven; disjoint files)
Parallel-safe groups (disjoint file fences):
1. **slash trigger**`logic/slash.ts` (+ `logic/skillMatch.ts` reuse) + `test/slash.test.ts`. (F1/F2/F7/F8/F8b)
2. **clarify**`view/prompts/clarifyPrompt.tsx` + a clarify test. (F5/F6)
3. **shell-exec**`entry/main.tsx` (edit DIRECTLY — load-bearing) + system render + test. (F9)
4. **paste focus**`view/composer.tsx` (edit directly; verify opentui paste API first). (F4)
5. **cost**`tui_gateway/server.py` + `agent/usage_pricing.py` + gateway test. (F3) — Python, isolated.
`entry/main.tsx` and `store.ts` are edited directly, never via subagent (handoff rule).
Each renderable change: `skill_view(opentui, references/docs/...)` FIRST. Verify every
subagent self-report (re-run `npm run check` exit code, read the diff).
## Open decisions (need glitch)
- **D1 (F7/F8):** thread cursor offset into `onType` (correct) vs. last-line slice (cheap)?
Recommend cursor offset.
- **D2 (F3):** drop OpenRouter cost source entirely, or gate it to the Nous route? Recommend
Nous-route gate via a status-bar-only helper, leaving `/usage` accounting intact.
## Invariants to preserve
- Per-conversation prompt caching untouched (all client-render or post-hoc gateway usage).
- No new `HERMES_*` env var (these are behaviour, not secrets).
- Strict no change-detector tests — assert behaviour/invariants.
- Don't regress the `/usage` accounting page when narrowing the chrome cost source.

View File

@ -1,217 +0,0 @@
# OpenTUI — usage/credits notice in the composer chrome
**Status:** spec (not started) · **Engine:** `ui-opentui/` · **Author:** glitch · 2026-06-14
## Goal
Render the gateway's **usage / credits notices** as a persistent, level-tinted
**chrome banner pinned at the top of the input zone** (directly above the status
bar), with the same lifecycle the Ink engine already has — sticky vs TTL,
mid-turn hold + turn-end reveal, and "flash-and-yield" for the usage bands.
Today the OpenTUI engine **receives** these notices but mis-renders them as
scrolling inline transcript cards with no lifecycle. This spec fixes that without
touching the gateway or the agent (the data already flows correctly).
## What already exists (verified)
### The wire (source of truth — do NOT change)
The gateway emits one event for every notice, snake_case payload:
```
notification.show payload { text, level, kind, ttl_ms, key, id } # tui_gateway/server.py:2878
notification.clear payload { key } # tui_gateway/server.py:2890
```
These come from `AgentNotice` (`agent/credits_tracker.py:177`). The credits
policy (`evaluate_credits_notices`, `agent/credits_tracker.py:245`) emits exactly
four notices — the full catalog this feature renders:
| `key` | `text` (already glyphed by policy) | `level` | `kind` | `ttl_ms` | lifecycle |
|-----------------------|-------------------------------------------------|-----------|----------|----------|----------------|
| `credits.usage` | `⚠/• Credits N% used · $X cap` (bands 50/75/90) | info/warn | `sticky` | — | flash-and-yield |
| `credits.grant_spent` | `• Grant spent · $X top-up left` | info | `sticky` | — | flash-and-yield |
| `credits.depleted` | `✕ Credit access paused · run /usage for balance` | error | `sticky` | — | sticky |
| `credits.restored` | `✓ Credit access restored` | success | `ttl` | `8000` | TTL self-expire |
**Load-bearing facts:**
- `text` is **already glyphed** (⚠ • ✕ ✓) by the Python policy — the renderer
**must not** prepend another glyph. It only tints by `level`.
- `level` includes **`success`** (green) — a level the current OpenTUI parser
silently drops to `info`.
- `kind` is the **lifecycle marker** (`sticky` | `ttl`), NOT a display label.
`id` == `key` (stable per kind, not unique per emission).
- Notices are **reconciled**: the policy emits `to_clear` (a `notification.clear`)
then `to_show`. A band change clears `credits.usage` then re-shows it.
### The Ink reference behavior (what we're matching)
`ui-tui/src/app/turnController.ts` + `appChrome.tsx`:
- `showNotice` (`:181`): if **busy**, hold in `pendingNotice` (latest-wins);
if idle, apply now.
- `applyNotice` (`:213`): set the visible notice; for `kind: 'ttl'` with
`ttl_ms > 0`, arm a self-expiry timer (clearing any prior timer first).
- `clearNotice(key)` (`:198`): drop the visible **and** pending notice only when
the key matches (a stale clear must not wipe a newer notice).
- `flushPendingNotice` (`:245`): at **turn end** (only the real end sites) apply
the held notice — its TTL clock starts here, when it first becomes visible.
- **Flash-and-yield** (`startMessage`, `:917`): at **turn start**, if the visible
notice's key is `credits.usage` or `credits.grant_spent`, clear it — "show
once, then get out of the way." `credits.depleted` and others stay sticky. The
Python `active` latch keeps the key so it won't re-fire next turn.
- Session reset clears all notice state so session A's notice can't bleed into B.
- Color by level: `error→error`, `warn→warn`, `success→statusGood`,
`info→accent` (`noticeColor`, `appChrome.tsx:192`).
### The OpenTUI side (what we change)
- `notification.show``parseNotification``pushNotification` → **inline card**
in the transcript (`store.ts:832`, `notificationCard.tsx`). All kinds, no
lifecycle. The Option B process-completion card (`kind: 'process.complete'`)
and `background.complete` (`kind: 'background task complete'`) also use this
path — **they must keep working unchanged.**
- `parseNotification` coerces `level` to `info|warn|error` only
(`backgroundActivity.ts:48`) — drops `success`.
- Store carries `lastNotification` (OSC seam), `bgTasks`; **no** `notice` slot.
- Theme has `accent`, `warn`, `error`, `ok`/`statusGood`, `muted`
(`logic/theme.ts`) — `success` maps to `statusGood`.
- Input zone layout (`view/App.tsx:140-211`): a top-bordered column —
`<StatusBar>` → composer `<Switch>``<AgentsTray>`. The new banner mounts at
`App.tsx:144`, **directly above `<StatusBar>`** (the topmost line of the chrome).
- Turn lifecycle hooks: `case 'message.start'` (`store.ts:779`, sets
`info.running = true`) and `case 'message.complete'` (`store.ts:811`, sets
`info.running = false`). `clearTranscript` (`store.ts:631`) is the reset site.
- `Date.now()` is used freely in the store (`:877`) — `setTimeout` for TTL is fine.
## The one design decision: routing
`kind` is the discriminator. **`notification.show` with `kind === 'sticky'` or
`kind === 'ttl'` → the new chrome-notice path; every other kind → the existing
inline-card path, untouched.** This mirrors Ink's `Notice.kind: 'sticky' | 'ttl'`
exactly, and the credits policy sets `kind` to one of those for all four notices,
while the process/background cards use label-strings (`process.complete`,
`background task complete`) that are neither — so they stay inline cards. No
gateway change, no key-prefix sniffing.
**Divergence from Ink (intentional):** Ink hides the notice while busy because the
FaceTicker shares its one status slot. OpenTUI's busy face (`StatusLine`) lives in
the transcript area, so the banner has a **dedicated row** and stays visible
through a turn (a depletion warning shouldn't vanish mid-turn). We still **hold
new notices** that arrive mid-turn (`pendingNotice`) and reveal them at turn end —
matching Ink's "don't pop a fresh banner mid-stream" intent.
## Implementation
### Phase 1 — parser + type (`logic/backgroundActivity.ts`)
1. Widen `ActivityNotification.level` to `'info' | 'warn' | 'error' | 'success'`.
2. `coerceLevel`: also accept `'success'` (still fall back to `'info'`).
3. Add `export function isChromeNotice(n: ActivityNotification): boolean`
`n.kind === 'sticky' || n.kind === 'ttl'`.
4. `parseNotification` already maps `ttl_ms → ttlMs` and preserves `key`/`id`
no shape change beyond the widened level.
**Tests** (`backgroundActivity.test.ts` or `notificationCard.test.tsx`):
`success` survives parse; `kind: 'ttl'` + `ttl_ms``ttlMs`; `isChromeNotice`
true for sticky/ttl, false for `process.complete`/`''`.
### Phase 2 — store lifecycle (`logic/store.ts`)
Add state + a private (non-reactive) timer handle in `createSessionStore`:
- `notice: ActivityNotification | null` (visible chrome notice) — new state field,
init `null`.
- `pendingNotice: ActivityNotification | null` — held mid-turn, init `null`.
- `let noticeTimer: ReturnType<typeof setTimeout> | undefined` (closure var).
Functions (port of `turnController`):
- `showNotice(n)`: `state.info.running ? setState('pendingNotice', n) : applyNotice(n)`
(latest-wins — assigning replaces any prior pending).
- `applyNotice(n)`: clear `noticeTimer`; `setState('notice', n)`; if
`n.kind === 'ttl' && n.ttlMs && n.ttlMs > 0`, arm `setTimeout(n.ttlMs)` that
clears `notice` only if `state.notice?.id === n.id` (defensive guard).
- `clearNotice(key)`: if `state.pendingNotice?.key === key` → null it; if
`state.notice?.key === key` → clear timer + null `notice`.
- `flushPendingNotice()`: if `state.pendingNotice``applyNotice` it, null pending.
- `clearNoticeState()`: null `notice` + `pendingNotice`, clear timer.
Wire into the event reducer:
- `notification.show` (`store.ts:832`): route —
`const n = parseNotification(...); if (!n) break; if (isChromeNotice(n)) showNotice(n); else pushNotification(n)`.
(Still record `lastNotification` for the OSC seam in **both** paths — extract
the `setState('lastNotification', {...n})` so a chrome notice also pings a
blurred terminal, matching the inline-card behavior.)
- `notification.clear` (`store.ts:837`): call **both** `clearNotificationCards(key)`
(cards) **and** `clearNotice(key)` (chrome) — a key only ever lives in one, so
calling both is safe and avoids guessing.
- `message.start` (`store.ts:779`): flash-and-yield — if
`state.notice?.key === 'credits.usage' || === 'credits.grant_spent'`
`clearNotice(state.notice.key)`. (Do this **before** flipping `running` true so
the read is clean.)
- `message.complete` (`store.ts:811`): call `flushPendingNotice()` (after the
`running = false` set, so a held notice reveals on the now-idle bar).
- `clearTranscript` (`store.ts:631`) and any session-switch reset:
`clearNoticeState()`.
Export `notice` via the store's state and `showNotice`/`clearNotice` if a test or
future slash command needs them.
**Tests** (`statusNotice.test.ts`, new):
- idle `showNotice``state.notice` set, no card pushed.
- routing: `notification.show` `kind:'sticky'``notice` set, **no** transcript
card; `kind:'process.complete'` → card pushed, `notice` still null.
- mid-turn hold: `message.start``showNotice``notice` stays null,
`pendingNotice` set → `message.complete``notice` revealed.
- `clearNotice` by key drops visible + pending; non-matching key is a no-op.
- TTL: `kind:'ttl', ttlMs:50` auto-clears (vitest fake timers).
- flash-and-yield: visible `credits.usage` cleared on `message.start`;
`credits.depleted` persists across a start/complete cycle.
- `clearTranscript` resets `notice` + `pendingNotice`.
- `success` notice keeps its level.
### Phase 3 — view (`view/noticeBanner.tsx` + `App.tsx`)
New `NoticeBanner` (sibling style to `notificationCard.tsx`):
- Props: `notice: ActivityNotification | null`, plus terminal width for truncation.
- `<Show when={notice}>` — renders nothing when null.
- One row, `flexShrink: 0`, `paddingLeft: 1`, `selectable={false}`.
- Text rendered **verbatim** (glyph already present), tinted by level:
`error→error`, `warn→warn`, `success→statusGood`, `info→accent`.
- Truncate to width with `truncRight` (`logic/truncate.ts`) so a long notice can
never push the composer or wrap.
Mount in `App.tsx:144`, the first child of the top-bordered input zone, directly
above `<StatusBar store={...} />`:
```tsx
<box border={['top']} ...>
<NoticeBanner notice={props.store.state.notice} /> {/* new */}
<StatusBar store={props.store} />
...
```
**Tests** (`noticeBanner.test.tsx`, frame): renders the text without adding a
glyph; warn→warn color, success→statusGood color; truncates at narrow width;
renders an empty frame when `notice` is null.
### Phase 4 — parity verification + docs
- `npm run check` green (prettier + eslint + vitest).
- Headless frame dump: a `credits.usage` warn banner above the status bar; a
`credits.depleted` error banner surviving a turn; a `credits.restored` success
banner that disappears after its TTL.
- tmux smoke per `docs/opentui-dev-handoff.md` (inject the three notices via the
test harness / a scripted gateway event; screenshot the chrome).
- Cross-check the four-notice catalog renders identically in tone to Ink's
`appChromeStatusRule` (color-by-level, no double glyph, truncation).
## Non-goals
- No gateway/agent changes — the wire and the policy are the source of truth.
- No new notice kinds — render exactly the four the policy emits.
- The inline-card path (process/background completions) is **unchanged**.
- No status-bar segment changes — the banner is its own row above the bar.
## Risk / footguns
- **Schema decode-at-boundary**: `notification.show` payload is a loose Record
read by `parseNotification`, not strict-decoded — a wrong-typed field won't blank
the bar (unlike `applyInfo`). Keep the loose reads.
- **createStore reference-aliasing**: store `notice` and `pendingNotice` distinct
objects; when applying pending, it's already its own object — don't alias it to
`lastNotification`. (See `[[solid-createstore-reference-aliasing]]`.)
- **Timer leak**: `clearNoticeState` must clear `noticeTimer`; ensure session
reset and store dispose clear it so a TTL callback can't fire into a dead store.
- **Routing regression**: assert in tests that `process.complete` /
`background task complete` still produce **cards**, not banners — the whole
feature hinges on the `kind` discriminator.

View File

@ -0,0 +1,260 @@
# Relay ↔ Connector Contract (v1, EXPERIMENTAL)
> **Status:** EXPERIMENTAL. This contract MAY CHANGE without a deprecation
> cycle until at least two real Class-1 platforms (Discord + Telegram) have
> validated it. Evolution during the experimental phase is **additive-only**,
> gated by `contract_version`. A breaking change updates both repos in lockstep.
This document is the formal interface between the **Hermes gateway** (Python,
`gateway/relay/`) and the **connector** (Node/TypeScript,
`NousResearch/gateway-gateway`). The connector implementer's first action is to
read this file.
The gateway runs a generic `RelayAdapter` that dials **out** to the connector,
receives a `CapabilityDescriptor` at handshake, then exchanges normalized
`MessageEvent`s (inbound) and actions (outbound) over a per-turn bidirectional
WebSocket. The gateway never learns which concrete platform is fronting it; the
connector owns all platform-specific socket/identity logic.
---
## 1. Handshake
1. Gateway opens the transport (`connect`).
2. Gateway calls `handshake()`; connector returns a `CapabilityDescriptor`
(section 2) describing the platform this adapter instance fronts.
3. Gateway configures the adapter from the descriptor (char limit, length unit,
draft/edit/thread/markdown capabilities) and registers an inbound handler.
4. Connector then streams inbound events and accepts outbound actions.
`contract_version` (currently `1`) is carried in the descriptor. The gateway
ignores unknown descriptor fields (forward-compat) and fills missing optional
fields from defaults.
---
## 2. CapabilityDescriptor (handshake payload)
JSON object. Source of truth: `gateway/relay/descriptor.py`.
| Field | Type | Required | Meaning |
| --- | --- | --- | --- |
| `contract_version` | int | yes | Contract version (additive-only within a version). |
| `platform` | string | yes | Platform name (e.g. `"discord"`, `"telegram"`). |
| `label` | string | yes | Human-readable label. |
| `max_message_length` | int | yes | Char limit; gateway exposes as `MAX_MESSAGE_LENGTH`. 0 → treat as 4096. |
| `supports_draft_streaming` | bool | yes | Native draft-streaming preview support. |
| `supports_edit` | bool | yes | Edit-based streaming possible; if false, consumer degrades to one-message-per-segment. |
| `supports_threads` | bool | yes | `create_handoff_thread` capability. |
| `markdown_dialect` | string | yes | `"plain"`, `"markdown_v2"`, `"discord"`, … (drives `supports_code_blocks`). |
| `len_unit` | string | yes | `"chars"` (builtin len) or `"utf16"` (Telegram UTF-16 code units). |
| `emoji` | string | no | Display emoji (default 🔌). |
| `platform_hint` | string | no | System-prompt platform hint. |
| `pii_safe` | bool | no | Redact PII in session descriptions. |
Most fields are a projection of the gateway's existing `PlatformEntry`; the
runtime-only fields (`len_unit`, `supports_*`, `markdown_dialect`) come from the
live platform adapter's capability methods.
---
## 3. Inbound: `MessageEvent` envelope
The connector normalizes each platform wire event into a `MessageEvent`
(`gateway/platforms/base.py`) and delivers it to the gateway. **Inbound is
delivered over a signed HTTP POST, not the outbound `/relay` WebSocket** (see
the transport note below). The gateway keys the session via `build_session_key()`
from the embedded `SessionSource` — so populating the right discriminators is
the single highest-correctness responsibility of the connector.
### Inbound transport (signed HTTP POST, not the outbound WS)
The gateway dials **out** to the connector's `/relay` WebSocket for the
handshake + outbound actions (§4) + its own `/stop` egress (§5). Inbound,
however, is delivered the other way: the connector **POSTs** the normalized
event to the gateway's inbound endpoint (`HttpGatewayDelivery` on the connector;
`gateway/relay/inbound_receiver.py` on the gateway). The reason is
multi-instance: the connector instance that owns a platform's socket (and thus
produces inbound events) is generally **not** the instance a given gateway
dialed its outbound WS into, so inbound must target a tenant **endpoint** (which
may load-balance across gateway instances) rather than ride one gateway's
outbound socket. Each delivery is HMAC-signed with the per-tenant **delivery
key** (§6.1); the gateway verifies the signature over the exact raw bytes before
accepting the event. Two POST targets:
- `POST {gatewayEndpoint}``{"type":"message", "event": <MessageEvent>}`
- `POST {gatewayEndpoint}/interrupt``{"type":"interrupt", "session_key", "reason"?}` (§5)
> An earlier draft of this contract delivered inbound over the WS `inbound`
> frame. That only works single-instance and predates the multi-instance
> socket-ownership + channel-auth model; the signed-HTTP path above is the
> shipped design.
### SessionSource fields (the wire surface)
Source of truth: `SessionSource.to_dict()` in `gateway/session.py`. These are
every key the gateway accepts on the wire. `platform`, `chat_id`, `chat_type`,
`user_id`, `user_name`, `thread_id`, `chat_name`, and `chat_topic` are always
present (may be `null`); the rest are included only when set.
| Field | Type | Always sent | Meaning |
| --- | --- | --- | --- |
| `platform` | string | yes | Platform name (matches the descriptor's `platform`). |
| `chat_id` | string | yes | Primary conversation id (channel/chat). Session-key discriminator. |
| `chat_type` | string | yes | `dm` / `group` / `channel` / `thread` / `forum`. |
| `chat_name` | string\|null | yes | Human-readable chat name. |
| `user_id` | string\|null | yes | Message author id. Session-key discriminator. |
| `user_name` | string\|null | yes | Author display name. |
| `thread_id` | string\|null | yes | Thread/forum-topic id when in a thread. Session-key discriminator. |
| `chat_topic` | string\|null | yes | Channel topic/description (Discord, Slack). |
| `user_id_alt` | string | no | Platform-specific stable alt id (Signal UUID, Feishu union_id). |
| `chat_id_alt` | string | no | Alternate chat id (e.g. Signal group internal id). |
| `guild_id` | string | no | Discord guild / Slack workspace / Matrix server scope. **REQUIRED for Discord server isolation.** Session-key discriminator. |
| `parent_chat_id` | string | no | Parent channel when `chat_id` refers to a thread. |
| `message_id` | string | no | Id of the triggering message (for pin/reply/react). |
> `is_bot` (author-is-a-bot/webhook classification) exists on the gateway-side
> dataclass but is **intentionally NOT on the wire** in v1 — it is not part of
> `to_dict()`. Do not add it to the connector's `SessionSource` until it is
> first added here and to `to_dict()` (additive bump).
### SessionSource discriminators per platform
| Platform | chat_id | chat_type | user_id | thread_id | guild_id |
| --- | --- | --- | --- | --- | --- |
| **Discord** | channel id | `dm`/`group`/`thread` | author id | thread channel id (threads) | **guild id** (REQUIRED for server isolation) |
| **Telegram** | chat id | `dm`/`group`/`forum` | from id | forum topic id (forums) | — |
**Get Discord's `guild_id` wrong and two servers collide into one session.**
This is the #1 High-severity risk. The gateway's `build_session_key()` is the
conformance oracle: for a given `SessionSource`, the connector's normalization
must produce the same key the Python adapter would. (The Phase-1 stub tests
assert known-input → known-key.)
### Bot identity vs tenant (single-bot consolidation, Appendix A)
The envelope carries the **originating bot identity** as a field **distinct from
tenant**. Tenant is resolved from the event's own discriminator (Discord
`guild_id`, Telegram `chat_id`, webhook path/subdomain) — **never** from which
token/socket/process delivered it. This keeps one shared bot able to front many
tenants (Phase 6) without overloading an existing field.
---
## 4. Outbound: action set
The gateway calls the transport with action dicts. Source of truth:
`gateway/relay/transport.py` + `gateway/relay/adapter.py`.
| `op` | Fields | Result |
| --- | --- | --- |
| `send` | `chat_id`, `content`, `reply_to?`, `metadata?` | `{success: bool, message_id?, error?}` |
| `edit` | `chat_id`, `message_id`, `content`, `metadata?` | `{success: bool, error?}` |
| `typing` | `chat_id` | `{success: bool}` |
| `follow_up` | `session_key`, `kind`, `content`, `metadata?` | `{success: bool, message_id?, error?}` |
`get_chat_info(chat_id)` is a separate proxied call returning at least
`{name, type}`. Media actions follow the same envelope shape (deferred to a
later contract revision; additive).
**`follow_up` (A2 capability action).** Some inbound payloads carry a credential
that acts on the **shared** bot identity (e.g. a Discord interaction follow-up
token). Per §6 the connector strips that at the edge and binds it in its
capability vault keyed by the session; it **never reaches the gateway**. To use
it, the gateway issues `follow_up` naming the **session it is already in**
(`session_key`) plus the capability `kind` (e.g. `discord.interaction_token`) —
**never a token**. The connector resolves the real value from its vault,
enforces the tenant match (tenant B can never wield tenant A's capability), and
egresses. `success: false` when the capability is absent/expired or the tenant
doesn't match — the gateway has nothing to retry with, by design (a leaked
gateway holds zero capability material). Source of truth:
`gateway/relay/transport.py` (`send_follow_up`) + `gateway/relay/adapter.py`.
---
## 5. Interrupt (`/stop`) routing
- **Gateway → connector:** `send_interrupt(session_key, reason?)` egresses a
mid-turn `/stop` over the outbound WS. The connector MUST forward it to the
gateway instance running that `session_key` (the routing invariant).
- **Connector → gateway:** an inbound interrupt for a `session_key` is delivered
as a **signed HTTP POST** to `{gatewayEndpoint}/interrupt` (§3 transport note),
and bridged by the adapter's `on_interrupt(session_key, chat_id)` into the
existing per-session interrupt mechanism, cancelling exactly that turn
(siblings untouched).
The gateway→connector `/stop` rides the outbound WS; the connector→gateway
interrupt rides the same signed-HTTP inbound path as a normalized event.
---
## 6. Trust boundary & signed-body handling (A2)
**The connector is the sole crypto/identity boundary. The gateway re-validates
nothing.**
Webhook signatures (Discord ed25519, Twilio HMAC, WeCom BizMsgCrypt) are
computed over exact raw bytes, and some payloads are *encrypted* with a shared
secret. The connector fronts a **shared** bot for many tenants and holds every
tenant's platform secrets, so it:
- **verifies / decrypts at the edge** (the only place the secrets live),
- **normalizes** the payload into a tenant-scoped `MessageEvent` (§3),
- **strips any shared-identity capability** out of the payload and binds it in
its capability vault, keyed by the session (see §4 `follow_up`),
- **forwards only the sanitized `MessageEvent`** — never the raw signed body.
The gateway therefore performs **no** platform signature/crypto verification on
the relay path; it trusts the normalized event. This is an enforced invariant on
the gateway side (`tests/gateway/relay/test_relay_sheds_crypto.py`: the relay
package imports/calls no platform-crypto).
**Why not "forward the signed body byte-for-byte so the gateway re-validates"?**
That earlier model is incoherent under an untrusted, disposable tenant gateway:
- Re-validating Twilio HMAC / WeCom crypto would require handing the gateway the
**shared signing secret** — which is itself the leak, and on a shared bot it's
a *cross-tenant* leak.
- WeCom payloads are encrypted with the shared secret; the connector must decrypt
at the edge just to route, so forwarding ciphertext would again require giving
the gateway the secret.
- A Discord interaction token lives **inside** the signed JSON body — you cannot
both preserve the bytes and strip the credential; they are the same bytes.
So byte-preservation is abandoned deliberately: the connector re-serializes the
sanitized event and the gateway trusts it. This also unifies the passthrough and
relay planes — both are "verify at the edge → emit a normalized event," differing
only in transport. See `docs/capability-trust-boundary.md` (connector repo:
`gateway-gateway`) for the full A2 rationale and the connector-side vault.
### 6.1 Channel authentication (the connector⇄gateway link itself)
A2 makes the connector the sole holder of platform secrets while the gateway may
be **customer-managed and internet-exposed**, so the connector⇄gateway channel
is itself authenticated. The gateway holds two enrollment-issued credentials
(`hermes gateway enroll` → connector `/relay/enroll`): a **per-gateway secret**
and a **per-tenant delivery key**. Both are HMAC-SHA256 schemes with a
multi-secret rotation verify list (gateway side: `gateway/relay/auth.py`;
connector side: `src/core/relayAuthToken.ts` + `src/core/deliverySigning.ts`).
| Leg | Credential | Mechanism |
|-----|-----------|-----------|
| Gateway → connector WS upgrade | per-gateway secret | An `Authorization` bearer header on the `/relay` upgrade. The token is `base64url(payload:exp:sig)` where `payload = gatewayId` and `sig = HMAC(payload:exp, secret)`. Connector verifies and rejects the upgrade (**close 4401**) on mismatch/absence/revocation. The authenticated tenant comes from the connector's store, never the `hello` frame. |
| Connector → gateway inbound POST | per-tenant delivery key | Two headers: `x-relay-timestamp` (unix seconds) and `x-relay-signature` (hex `HMAC(ts.rawBody, deliveryKey)`). Gateway verifies over the **exact raw bytes** within a ±300s replay window before accepting the event; rejects **401** otherwise. |
This is the **channel** authenticator — distinct from platform crypto, which the
relay path still sheds entirely (§6). The gateway holds zero platform secrets;
these two keys authenticate only the connector link. Full threat model +
enrollment/rotation/kill-switch design: `docs/connector-gateway-auth-design.md`
(connector repo).
---
## 7. Versioning policy
- `contract_version` is an int; bump **only** for additive changes during the
experimental phase (new optional fields, new `op`s).
- A breaking change (renamed/removed field, changed semantics) requires a
coordinated update of both repos and a version bump.
- The connector's first PR references the commit SHA of this file it implements
against.

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