fix(cli): map all Ctrl/Alt/Shift+key combos under modifyOtherKeys level 2 - #87511
Merged
kshitijk4poor merged 2 commits intoAug 16, 2026
Merged
Conversation
…el 2 Commit 4c34eeb stopped pushing the Kitty keyboard protocol (CSI >1u) because Ctrl+C arrived as ESC[99;5u instead of \x03, breaking SIGINT. But modifyOtherKeys level 2 (CSI >4;2m) was kept so Shift+Enter stays distinguishable from Enter. Under modifyOtherKeys=2, terminals re-encode EVERY Ctrl+key combo as ESC[27;5;<codepoint>~ instead of the raw control byte. prompt_toolkit 3.x only maps ESC[27;5;13~ (Ctrl+Enter = Ctrl+M); all other Ctrl+letter combos are unmapped and leak as literal text or get swallowed — breaking Ctrl+A, Ctrl+C, Ctrl+D, Ctrl+E, Ctrl+K, Ctrl+R, Ctrl+U, Ctrl+W, Ctrl+Z, etc. Shift+letter combos (ESC[27;2;<codepoint>~) have the same problem, causing the 'caps locked sessions' symptom where typed text appears corrupted or stuck. Fix: add install_modify_other_keys_aliases() to pt_input_extras.py that populates prompt_toolkit's ANSI_SEQUENCES dict with 294 mappings covering: - Ctrl+letter (a-z): ESC[27;5;<code>~ and ESC[<code>;5u -> Keys.ControlA..Z - Ctrl+digit (0-9): same formats -> Keys.Control0..9 - Ctrl+symbol ([ \ ] ^ _ @ Space): same formats -> matching Keys.Control* - Alt+letter (a-z, A-Z): both formats -> (Escape, <letter>) tuple - Shift+letter (a-z, A-Z): both formats -> uppercase character Uses setdefault semantics — never clobbers existing mappings from install_shift_enter_alias or install_ctrl_enter_alias. The Ink TUI (Node.js) already handles this via a regex parser; prompt_toolkit 3.x uses dict lookup only, so we populate the dict. Refs NousResearch#56684, NousResearch#87711.
…late test fixture
Apply findings from /simplify-code 3-agent review:
1. Extract _install_paired() inner helper — the Ctrl, Alt, and Shift
sections all repeated the same mok+csiu sequence generation pattern
(~30 lines of duplication). Now each section builds a dict and
delegates to _install_paired(modifier, mapping).
2. Replace 10 hardcoded Ctrl+digit lines with a loop matching the
Ctrl+letter pattern above it.
3. Fix misleading comment: claimed 'Ctrl+0 doesn't produce a control
byte' but chr(ord('0') & 0x1F) = 0x10 = ControlP. The code was
correct (maps directly to Keys.Control0..9); only the comment was
wrong.
4. Add comment explaining why Shift+letter maps both lowercase and
uppercase codepoints (some terminals send the already-shifted
codepoint with modifier=2).
5. Test fixture: snapshot/restore ANSI_SEQUENCES in teardown so 294
mappings don't leak into sibling test files (global mutable state).
kshitijk4poor
enabled auto-merge (rebase)
August 16, 2026 07:29
This was referenced Aug 16, 2026
kshitijk4poor
added a commit
that referenced
this pull request
Aug 16, 2026
…ded-key alias table Commit 4c34eeb fixed dead Ctrl+C by removing the Kitty protocol push (CSI >1u) from _EXTENDED_ENTER_KEYS_SEQ, keeping only modifyOtherKeys level 2. That regressed kitty-the-terminal completely: kitty removed xterm modifyOtherKeys support (kovidgoyal/kitty#4075) and only speaks its own protocol, so after the removal kitty users lost Shift+Enter and every other extended key — the CSI >4;2m we still pushed is a no-op there (kitty even logs a PARSE ERROR for it). The original reason for removing the push is obsolete: #87511 mapped CSI-u control sequences, so Ctrl+C as ESC[99;5u now parses to Keys.ControlC and fires the existing c-c binding. (The kernel-INTR concern in that commit was moot — prompt_toolkit's raw mode clears ISIG, so Ctrl+C is always handled by the binding, never the kernel.) Restore the dual push (CSI >1u + CSI >4;2m), exactly mirroring the Ink TUI, and complete the alias table for what the kitty disambiguate flag actually emits — #87511 left real gaps, some of which its PR body wrongly claimed were covered: - Esc key: ESC[27u (+ modifiers) — previously leaked '[27u' as text - Ctrl+Backspace -> backward-kill-word (#78285 was closed on the wrong claim that codepoint-127 mapping existed; it did not) - Shift+Space -> space (#86866's second symptom; the Ctrl+Space mapping never covered modifier 2) - Alt+Enter -> newline tuple; Shift+Tab -> BackTab; Ctrl+Tab -> Tab; Alt/Shift+Backspace - Multi-modifier letters (Shift+Alt 4, Ctrl+Shift 6, Ctrl+Alt 7, Ctrl+Alt+Shift 8) normalized onto their Ctrl/Escape-prefix targets, both unshifted (kitty) and shifted (mok emitters) codepoints - Kitty PUA functional keys: keypad -> non-keypad equivalents, F13-F24, and Ignore for lock/media/modifier-event keys so they are consumed instead of leaking (kitty emits these even in legacy mode) Also: clear the VT100 parser's prefix cache after installing (stale answers could misparse), and re-push extended keys after _recover_terminal_input_modes' reset — the recovery previously popped both modes mid-session and never re-enabled them, silently killing Shift+Enter until restart. Refs #87511, #87074, #56684, #56645, #78285, #86866, #87390.
13 tasks
13 tasks
abdulrahman305
added a commit
to qenex-ai/hermes-agent
that referenced
this pull request
Aug 17, 2026
…keeping --ignore-scripts (#115) * fix(agent): preserve stalled-provider escalation * fix(agent): cover provider wait teardown paths * fix(sessions): surface open sessions skipped by prune * fix(sessions): address prune skip review notes * fix(sessions): align prune filter derivation * fix(agent+discord): guard truncated-response continuation loops and cap Discord split delivery (#86581) * fix(agent): bound worker finalization when iteration budget exhausted (#87096) Adds a bounded fallback path in turn_finalizer.py that always records a terminal timed_out outcome via _record_task_failure (CAS receipt path) when the iteration budget is exhausted, regardless of whether the normal fallback paths (interrupted/failed/anomalous exit_reason) were eligible. Previously, a kanban worker whose budget was exhausted but whose turn was interrupted, failed, or exited with an anomalous reason would silently leave its task in an ambiguous lifecycle state — the dispatcher would eventually detect it as a crashed or protocol-violation worker, but the failure was not bounded and could take a full tick cycle to reconcile. The CAS invariant in _end_run (WHERE ended_at IS NULL) guarantees idempotence: if another path already closed the run, the call is a no-op. Extracted the inline kanban-budget-exhausted recording into a shared helper function (_record_kanban_budget_exhausted) used by both the existing iteration_limit_fallback path and the new bounded fallback path. Closes #87096 * fix: guard exit watchdog against mid-cleanup overlap * fix(desktop): hard-exit a lock-losing second instance before ready app.quit() does not stop a lock-losing instance from reaching whenReady: the before-quit teardown coordinator defers the quit (event.preventDefault + async backend shutdown), and ready fires in that window. The losing instance then runs the full startup whose reapOrphans() SIGTERMs the running instance's live backend (#87295). The lock-loser holds no state and no backend — requestSingleInstanceLock() has already delivered the argv to the primary by the time it returns false — so there is nothing to clean up. app.exit(0) terminates immediately, before ready, so a second launch routes into the running window and never touches backend machinery. * fix(desktop): keep startHermes inert without the single-instance lock startHermes() is the only entry point that can reap, spawn, claim, and therefore destroy a backend. Belt-and-suspenders on the exact killing line: even if some future path reaches it in a lock-losing process (a refactor, a dev harness, a race), the instance stays inert — no reap, no spawn, no claim — instead of SIGTERMing the running instance's backend (#87295). * fix(desktop): never reap a backend whose parent Electron is alive reapOrphans() treats any recorded backend with a matching process identity as orphan-reapable — including a backend owned by another live instance. The ownership file is shared across instances, so a second launch that reaches reap (even without the lock) SIGTERMs the running instance's backend. Claims now record the spawning Electron (parentPid + parentStartMarker, the same values already passed to the backend as HERMES_PARENT_PID / HERMES_PARENT_START_MARKER), and reapOrphans() skips any entry whose parent is still running. Even a second instance that wins a stale lock can never kill a live instance's backend. Legacy entries without parent data keep the old behaviour. Known tradeoff: a parent-liveness probe failure preserves the record, so a genuinely orphaned backend under a still-running parent is leaked until it dies naturally. That is preferable to killing a live instance's backend. Tests: parent-aware reap in backend-ownership.test.ts (live parent preserved, dead parent still reaped, probe failure preserved, parent identity round-trips through claim/parse). * fix(agent): include timezone and UTC offset in system prompt timestamp The "Conversation started:" line carried a bare date (%A, %B %d, %Y). Tools that accept instants -- nutrition, calendar and similar MCP servers -- reject naive datetimes and require an explicit UTC offset, so the model had to infer EST vs EDT from the date alone. Near a DST boundary that is a coin flip, and a wrong guess does not error: it silently writes the record onto the wrong day. Append the IANA zone (when configured), the zone abbreviation and the UTC offset, e.g.: Conversation started: Saturday, August 15, 2026 (America/New_York, EDT, UTC-04:00) get_timezone() returns None when no timezone is configured; in that case the line falls back to the abbreviation and offset of the server-local (still tz-aware) time, so behaviour is unchanged for users who never set one: Conversation started: Saturday, August 15, 2026 (EDT, UTC-04:00) Daily byte-stability is preserved -- the property the date-only format exists to protect (PR #20451). Zone name, abbreviation and offset are all constant for the whole day; they shift only at a DST transition, where a change is correct. The static-prefix reconstruction guard in _restore_plugin_sections matches on "\n\nConversation started:" and is unaffected by a suffix after the date. test_datetime_is_date_only_not_minute_precision used `re.search(r":\d{2}")` over the whole line as a proxy for "no time-of-day". A UTC offset also matches that pattern, so the check now applies to the date portion (everything before the zone parenthetical) and the invariant is tightened rather than relaxed: - test_datetime_includes_utc_offset asserts the offset is present - test_datetime_line_is_stable_across_rebuilds asserts two rebuilds in the same day produce a byte-identical line Fixes #87403 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(gateway): make managed Node suppress PATH fallback * fix(agent): canonicalize duplicate tool call arguments * fix(agent): harden canonical tool call deduplication * fix(gateway): let an explicit workspace move win for a running session session.workspace.move refused a running live session with 4009 (session busy), but the desktop's Move-to-project flow calls exactly this RPC — so the UI updated its local grouping while state.db kept the old cwd and the agent's tools kept running in the old workspace. Two sources of truth disagreed (#86626). An explicit move now wins: the stored row and the live session re-anchor together. In-flight tool calls keep the cwd they were launched with; the next tool call uses the new workspace. * fix(ui): ignore inherited esbuild binary overrides * fix(ui): scrub esbuild override during builds * fix(codex): clamp xAI max/ultra aliases to the model's reasoning ceiling (#87279) * fix(backup): bound locked database snapshot waits * fix(gemini): raise maxOutputTokens when thinking is enabled Gemini bills thought tokens against maxOutputTokens/max_tokens, so a global 4096 cap can be fully consumed by thinking on the first request, leaving zero content tokens and aborting after 4 continuations. When thinking is enabled, raise the effective output cap to the 65,535 ceiling on both the native and chat-completions paths. Refs #83915 * fix(gateway): don't crash on a foreign XDG_RUNTIME_DIR in user-systemd preflight (#86558) runuser/su/sudo -u from a root shell leaks XDG_RUNTIME_DIR=/run/user/0 into the child. _user_systemd_socket_ready() stat-ed sockets under it with a bare Path.exists(), which only suppresses ENOENT/ENOTDIR/EBADF/ELOOP — EACCES on the 0700 root-owned dir escaped as a raw PermissionError traceback instead of the documented UserSystemdUnavailableError remediation path. - _path_exists_safe(): Path.exists() that treats EACCES as absent; used at both the readiness and DBUS-detection call sites. - _ensure_user_systemd_env(): drop an XDG_RUNTIME_DIR that is unset or owned by another user in favour of our own /run/user/{uid}, so the restart actually succeeds after su/sudo -u instead of only failing cleanly. Regression tests cover the EACCES readiness probe, foreign-dir replacement, and that preflight raises UserSystemdUnavailableError (not PermissionError). * fix(agent): do not let hygiene idle timeouts block in-agent compression Session hygiene persists compression_failure_cooldown_until after a 30s no-progress watchdog so the pre-agent pass can skip. The in-conversation compressor read the same column and then refused to run even though its own budget is sufficient. Ignore hygiene idle-timeout errors on the in-agent path. Real aux-model faults such as rate limits still block. Fixes #86972 * fix(agent): clear in-memory cooldown when hygiene overwrites the shared row A later hygiene idle-timeout write can replace an aux-model cooldown on the shared column. Drop the in-memory timer on that refresh so the in-agent compressor is not still blocked after the DB row is hygiene. * fix(gateway): persist hygiene failure cooldown rung * fix(gateway): offload hygiene streak persistence * fix(skills): resolve skills dir before relative_to so junction installs work (#86971) * fix(telegram): honor group_allowed_chats in early auth under multiplex profiles (#87132) With gateway.multiplex_profiles enabled, the primary Telegram message handler is the closure returned by _make_default_profile_message_handler(), so its __self__ is absent. The early intake filter (_is_user_authorized_from_message) recovered the GatewayRunner via self._message_handler.__self__ and, finding none, fell back to env-only authorization — never evaluating the configured chat allowlist through GatewayRunner._is_user_authorized(). Every non-global sender was then default-denied in an explicitly allowlisted group. Prefer the platform-bound authorization callback registered via set_authorization_check(): it routes through the runner's full auth chain (platform + group allowlists, pairing store, allow-all) and survives the closure wrapping, whereas the bound-handler lookup does not. The bound handler remains the fallback for setups without a registered callback, and the pairing-passthrough guard for unknown DMs is preserved. Fixes #87132 * fix(desktop): close safe preview blockers before update * fix(desktop): identify unsafe update blockers * fix(gateway): keep persisted model routes consistent * fix(desktop): don't cancel the running turn on Esc while an overlay is open The composer's global Esc-to-cancel listener (useComposerEscCancel) fires whenever the turn is busy and the active composer matches — but overlays (Settings, Command Center, agents, cron, …) cover the chat while the composer stays mounted and 'active' beneath them, so pressing Esc on any of those pages interrupted the session the user wasn't even looking at. OverlayView's own escape-layer Esc-to-close fired too, but the stream was already dead. Stand Esc down with composerFocusBlockedBySurface() — the same signal the type-to-focus path uses (BLOCKING_OVERLAY includes OverlayView's [data-overlay-surface] marker). Esc on an overlay now closes the overlay via its escape layer instead of canceling the stream beneath it. Fixes #82618 * fix(caching): engage prompt caching for LiteLLM Claude on the OpenAI wire anthropic_prompt_cache_policy() only granted Anthropic cache_control markers to LiteLLM over the native Anthropic wire (api_mode == "anthropic_messages"). A LiteLLM deployment exposing the OpenAI-compatible surface instead (/v1/chat/completions, /v1/messages -> 404) matched no grant branch and fell through to (False, False): no cache_control injected, the system prompt sent as a plain string, and the provider serving zero cache hits -- the entire prompt re-billed at full price on every turn. Silent: no error, no warning, usage simply shows 100% uncached input forever. Add one branch after the is_anthropic_wire/is_claude case that grants caching to Claude-family models on a LiteLLM endpoint regardless of wire, with the native inner-block layout. Same failure class already documented in-function for Qwen/DashScope. Design: - Gated on the Claude family only (is_claude); a Gemini/GPT/Qwen route through the same proxy must not receive markers (they may reject the cache_control block format -- cf. the DeepSeek/OpenCode exclusion). - Matches on provider string OR base_url host, since provider naming varies per install (litellm, custom:litellm, or a bare custom alias pointed at a LiteLLM host). - prompt_caching.cache_ttl: false still wins (the _cache_disabled early return is untouched). - Generic strict OpenAI-wire custom providers (e.g. Fireworks) remain excluded -- verified by the existing over-reach regression test. Tests: adds TestLiteLLMOpenAIWire covering the grant (several model spellings x provider/host signals), no-over-reach (non-Claude on the same proxy get nothing; operator disable wins), and adjacent behavior (LiteLLM in Anthropic proxy mode still native layout). Full module: 43 passed. Closes #84506. Original diagnosis, patch design, and measurements by @ottosulin. * fix(caching): use the envelope layout for LiteLLM Claude on the OpenAI wire Follow-up to the salvaged LiteLLM cache grant. The grant itself is right; four things about how it was scoped were not. 1. Layout. The branch returned the native inner-block layout (use_native_layout=True) on api_mode == "chat_completions". That layout writes a TOP-LEVEL msg["cache_control"] on role:tool and empty-content messages and depends on the Anthropic adapter to relocate it into the block — but that adapter only runs for api_mode == "anthropic_messages" (agent/transports/anthropic.py registers there), and the chat_completions transport does no relocation. Measured on a 3-tool-turn transcript: 2 of the 4 available breakpoints landed on markers the provider never sees. Worse, when LiteLLM itself relocates a top-level marker for an OpenRouter-backed Claude route (OpenrouterConfig._move_cache_control_to_content), the marker lands on an empty assistant turn and produces a cache_control-marked empty text block — the HTTP 400 "text content blocks must contain" shape already guarded in agent/anthropic_adapter.py (#69512). Switched to the envelope layout, matching every other OpenAI-wire grant in this function: 4 of 4 breakpoints honored, zero empty blocks. 2. Host matching. `"litellm" in base_url_hostname(...)` is the substring false-positive class base_url_hostname's own docstring warns against; it granted Anthropic markers to notlitellm.example.com, foolitellmbar.example and friends. Replaced with a label-token match in a named helper, so "litellm" must be a whole dot- or hyphen-delimited token. All three of the original test hosts still match; a "litellm" path segment on an unrelated host still does not. 3. Transport gate. `not is_anthropic_wire` also swept in codex_responses, bedrock_converse and codex_app_server. Gated on api_mode == "chat_completions" explicitly. 4. Operator override. The grant is inferred from a provider/host name, but the custom-provider capability lookup was gated on is_anthropic_wire, so an explicit `prompt_caching: false` for the route+model was honored on /v1/messages and silently ignored on /v1/chat/completions. The lookup now also runs for a LiteLLM route, and its layout follows the transport rather than the declaration (an explicit `true` must not promote a chat_completions request to the native layout). Tests: 64 passed. Adds the wire-shape contract the original matrix was missing (asserts no breakpoint sits on the message envelope, rather than only checking the returned tuple), plus lookalike-host, other-transport, and both operator-override directions. All five guards mutation-checked — reverting each fix turns the corresponding test red. * fix(caching): match the litellm provider id token-wise too Self-review follow-up. The previous commit fixed substring matching on the HOST but left the provider-id side as a bare substring, so a user-named provider like `custom:notlitellm` or `mylitellmthing` still matched and was handed Anthropic markers — the same bug class, half-fixed. Both signals now match `litellm` as a whole delimited token via a shared helper. Real spellings (`litellm`, `custom:litellm`, `litellm-router`, and the already-lowercased `LiteLLM`) still match; lookalikes no longer do. Tests: 71 passed. Adds lookalike-provider and real-spelling guards; both new guards mutation-checked. Differential matrix over 2688 configs vs origin/main: 60 changes, every one a Claude model on a genuine LiteLLM route getting the envelope layout, zero pre-existing routes altered. * perf(caching): narrow the widened capability lookup to the LiteLLM grant Self-review follow-up, caught by benchmarking the previous commit. Widening the custom-provider capability-lookup gate to `is_anthropic_wire or _is_litellm_route(...)` made EVERY chat_completions route with a litellm-ish provider/host enter the lookup, including non-Claude models that the grant branch below can never match. Measured on a route with no config.yaml (the uncached worst case) that was ~7.5us -> ~1528us per evaluation. Narrowed the gate to the exact condition the LiteLLM branch grants on (chat_completions + Claude + litellm route), computed once into a local and reused by the branch itself so the predicate no longer runs twice. Measured with a realistic config.yaml present (mtime cache warm), vs origin/main: live-agent policy 20.6us -> 61.7us destination planning 219.3us -> 347.7us Sub-millisecond and scoped to the routes that actually opted in. The earlier 1.5ms figures were a tempdir artifact: load_config_readonly's mtime cache cannot engage when no config.yaml exists, which is never true of a real install. Non-LiteLLM and non-Claude routes are unaffected (openrouter Claude measured flat at ~7.9us). Tests: 82 passed across the policy and TTL-propagation modules. * test(caching): pin signal precedence and the openrouter-host opt-out Review follow-up. Three coverage gaps in the LiteLLM matrix: - The operator opt-out on a litellm-named provider pointed at an OpenRouter host. That route previously took the OpenRouter branch and ignored an explicit per-model `prompt_caching: false`; it is the only cell in the differential matrix where the salvage REMOVES caching, so pin it as intended rather than leaving it to be read as a regression. - Signal precedence: an explicitly litellm-named provider grants even on a lookalike host, because the provider id is an independent signal and only the host-derived signal is token-gated. Intentional, now documented. - A hyphen-delimited host label (`my-litellm-gw.internal.example.com`), which the token matcher handles but nothing exercised. Traded the redundant `claude-3-7-sonnet` parametrize cell for the new host case, so the matrix covers more shapes with the same cell count. Tests: 83 passed. All three production fixes re-mutation-checked against the final stack. * fix(web_server): discover root user plugins under profile-scoped processes When the backend is spawned profile-scoped (`--profile <name>` sets HERMES_HOME=<root>/profiles/<name>), _discover_dashboard_plugins() scanned only get_process_hermes_home()/plugins — the profile directory, which has no plugins/ content. Pooled per-profile backends therefore discovered zero user plugins, mounted no plugin API routes, and every plugin REST call fell through to the SPA catch-all 404. Also scan get_default_hermes_root()/plugins (which unwraps <root>/profiles/<name> to <root> and leaves a custom HERMES_HOME untouched when it is itself the root), matching how hermes_cli.plugins resolves install locations. The profile home is scanned first, so a profile-local plugin of the same name stays authoritative via the existing seen_names dedupe. Adds regression tests for root-plugin discovery under a profile-scoped process and for profile-over-root precedence. Fixes #87197 (plugin discovery half — the misleading /api/* catch-all half is addressed separately in #87270). * fix(telegram): keep /loop and synthetic sends in the active DM topic Fixes #87051 * fix(desktop): show failed status for timed-out subagents in fallback stream path Fixes #87200 * fix(desktop): match custom provider aliases in model catalog menu Fixes #87035 * chore(contributors): map emails for P2-sweep salvage wave * fix(desktop): avoid PowerShell parent marker boot gate * fix(desktop): give Windows start-marker PowerShell probe a 30s budget PowerShell 5.1 cold starts take 2.4-8s on affected Windows hosts, so the shared 3s execText timeout hard-failed the parent start-marker probe for any PID that still needs the PowerShell path (e.g. backend children). Make execText's timeout overridable and raise the marker probe to 30s. Fixes #87169 * perf(desktop): hydrate transcripts with a small tail page + on-demand older-page backfill Replace the fixed 500-message REST hydration (getLatestSessionMessages) with a 120-row newest-first tail page. When the page comes back full, a new per-session tail store records "possibly truncated + next offset"; "Show earlier" — once the DOM budget and the in-memory store window are both exhausted — fetches the next older page via the new getOlderSessionMessages helper (order latest + offset, matching the backend's back-from-newest paging semantics) and prepends it to the session store, deduped by durable row id and race-guarded against session switches. Legacy backends without pagination metadata fall back to the one-shot full transcript and retire the action. Tail-page refreshes (background sync, post-turn rehydrate, re-activate, cold-resume prefetch) graft the refreshed tail onto any backfilled prefix instead of clobbering it, preserving reference identity on no-ops. includeCompacted stays on every read — compaction-archived rows remain part of the durable display history. * feat(desktop): MCP fleet cost/usage overlay with schema token estimates and 30-day usage Each configured server row on the MCP Capabilities page now shows what it costs and whether it earns its keep: - ~per-call token estimate of the server's tool schemas, summed over ENABLED tools only (ceil(schema_chars/4) via the existing include/exclude filter) - 30-day usage count from getUsageAnalytics(30), cached per scope profile like the Toolsets tab's toolCallsCache, mapped to servers via the mcp__<server>__<tool> registry-name convention (tools/mcp_tool.py) - a subtle muted "unused" pill on enabled, probed-ok servers with nonzero schema cost and zero 30-day uses — never a dialog Backend: the /api/mcp/servers/{name}/test probe now fills an additive per-tool `schema_chars` (length of the SAME converted registry schema the agent registers). Older backends omit it → renderer shows counts only; older renderers ignore the extra key. Display-only: nothing changes what schemas are sent to models, no config knobs. i18n keys (costTokens/usage30d/unusedPill) added to types/en/zh/zh-hant/ja (ar inherits en via defineLocale overrides). Pure math lives in lib/mcp-cost.ts with unit tests; Python wire shape pinned in tests/hermes_cli/test_web_server_profile_unification.py. * fix(tui): map lineage edit ordinals past compression prefix Desktop/TUI count full displayed lineage after compression, but prompt.submit validated truncate ordinals against tip-only history. Translate via display_history_prefix and recover stale 4018s on Desktop. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(desktop): hoist GatewayMock type into #82462 edit recovery suite CI typecheck failed because GatewayMock lived only in the previous describe. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(desktop): hermes:// deep link to install MCP servers with explicit confirmation Adds hermes://mcp/install?name=NAME&config=B64 (base64url or standard base64 JSON), mirroring Cursor's mcp/install deep link, so vendors and docs can offer an "Add to Hermes" button. - Electron: the existing generic hermes:// handler already forwards {kind, name, params}; only its comment is updated (no new handler). - Renderer: use-desktop-integrations routes kind=mcp/name=install into a pending-install store; a new confirmation dialog shows the server name and the FULL pretty-printed config (attacker-controllable input), with a prominent caution for stdio command entries. Nothing is written until the user confirms; existing names require a rename or cancel. On confirm the server is merged over a fresh fetch of the current map via saveMcpServers, then navigation lands on /skills?tab=mcp&server=… so useDeepLinkHighlight focuses the new row. - Validation: name ^[A-Za-z0-9._-]{1,64}$; config must decode to an object with a string http(s) `url` or a string `command` (never both); payloads over 32KB rejected; failures surface as a toast. - Pure parser in src/lib/mcp-deeplink.ts with unit tests (url shape, command shape, bad base64, non-object, javascript: URL, oversized). - i18n keys in types + en/zh/zh-hant/ja/ar. - Docs: "Add to Hermes link" section in the MCP config reference. * fmt(js): `npm run fix` on merge (#87599) Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * feat(desktop): background MCP health checks with re-auth nudges MCP server problems (expired OAuth tokens especially) were only discovered when the user visited the MCP page and a probe ran. Now a renderer-side background checker (store/mcp-health.ts) sweeps the active profile's enabled HTTP/SSE MCP servers on gateway connect and every 30 minutes, and fires an in-app notification with a "Sign in" action ("<name> MCP needs re-authentication") that navigates to the MCP page with ?server=<name> so useDeepLinkHighlight focuses the server and its Authenticate button. Navigation only — OAuth flows are never auto-launched. stdio servers are deliberately excluded: probing a stdio server SPAWNS a local process, so a background timer must never touch them. Only url-shaped servers (where OAuth expiry lives) are swept, sequentially. The tab's probeCache/serverFingerprint/probeKey/NEEDS_AUTH_RE moved to a shared lib/mcp-probe-cache.ts (behavior identical) so the page and the checker share one probe cache and its 5-minute TTL — neither surface re-probes what the other just learned. Notifications fire only on a TRANSITION into needs-auth/error (pure state machine, unit-tested), hard-capped at one per server per app session, keyed per profile. Profile switches drop pending timers and re-arm for the new profile; sweeps never run while the gateway is disconnected. No new config knobs. i18n keys added across en/zh/zh-hant/ja/ar + types. * fmt(js): `npm run fix` on merge (#87607) Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * fix(desktop): scope messaging to active remote profile Complete the sidebar profile-scope contract across remote Electron routing, older-backend fallbacks, standalone messaging refreshes, and pagination. Reject stale profile responses and keep the explicit all-profiles view unified. Co-authored-by: 墨綠BG <s5460703@gmail.com> Co-authored-by: liuhao1024 <sunsky.lau@gmail.com> * fix(desktop): retain messaging totals per profile Key resolved platform totals by Desktop profile and source so profile switches neither inherit another profile's count nor discard a count that was already resolved. Keep the full reset for connection configuration changes. Co-authored-by: frendo <frendo.wu@gmail.com> * fix(desktop): ignore stale messaging page responses Sequence per-profile platform pagination so an older overlapping response cannot replace a newer, larger page. * docs(desktop): document profile scope helpers Add JSDoc to the exported helpers introduced by the profile-scoped sidebar change. * fix(desktop): reject stale profile refreshes * fix(desktop): harden profile-scoped refreshes * fix(desktop): resolve sessions from sidebar caches Consult messaging and cron caches before the by-id fallback so opening a sidebar row neither depends on a redundant network lookup nor duplicates it into regular recents. Co-authored-by: protas-box <protas.box@icloud.com> * fix(tools-config): stop reconfigure flow clobbering image_gen.use_gateway on managed FAL rows The Nous Subscription image_gen row carries imagegen_backend="fal", so _reconfigure_provider's post-model-picker step ran img_cfg["use_gateway"] = False unconditionally at two sites, immediately after the managed branch had written use_gateway=True. A user who picked Nous Subscription and then re-entered `hermes tools` to change the model was silently flipped onto their personal FAL_KEY. Same bug class as fe63353cb, which fixed the plugin-provider selector but missed these two legacy-backend sites in the reconfigure flow. Both now write bool(managed_feature), matching the existing correct site in _configure_provider. Adds regression tests driven through the real TOOL_CATEGORIES managed row; sabotage-verified (tests fail with the old behavior restored). * feat(desktop): make the multi-gateway Connections registry discoverable The multi-connection registry (Settings -> Connections) shipped with no entry point outside the settings nav, and the product-owner report was blunt: 'I didn't see any obvious way to hook up multiple gateways.' - Profile rail: a plug pill pinned beside Manage ('Connect another Hermes gateway...') deep-links to /settings?tab=connections. Always visible, including for single-profile first-run users. - Command palette: Settings -> Connections is now a searchable entry (keywords: add gateway, remote, ssh, cloud, instances, registry). - i18n: profiles.connectGateway added to en/types/zh; other locales fall back through defineLocale. - Tests: profile-rail-connect.test.tsx covers the deep link and the single-profile visibility guarantee. * docs: full multi-gateway setup guide for Hermes Desktop Expand user-guide/multi-connection-desktop.md into a complete setup walkthrough: where to find the pane (settings nav, profile-rail plug, command palette), the exact add-connection editor fields (Name, Gateway URL, Authentication: Session token/OAuth, SSH host), Primary / This device pills, Test semantics, agent roster + profile-rail switching and per-profile session/cron/messaging scoping, token storage via Electron safeStorage with the keyring-less Linux plain- text opt-in, and troubleshooting. All quoted labels match the desktop i18n strings. Cross-link the rail entry point from desktop.md. * fix(desktop): never resolve a missing named gateway scope to the primary activeGateway() fell back to the primary gateway when the active key named a registry-agent scope (conn:<id>::<profile>) whose secondaries entry had been evicted — e.g. closeSecondaryGateways() during a soft gateway switch — so sends and session ops silently executed against the WRONG machine. A named scope now resolves to its own socket or null, and every eviction path (closeSecondaryGateways, pruneSecondaryGateways) explicitly restores the primary as active when it evicts the active scope, keeping the 'activeKey always resolves' invariant with the atoms following. * fix(desktop): sync connection atoms and share the switch mutex for agent activation ensureGatewayForAgent (the SDK ensureAgent door) skipped the two invariants the profile path provides: - $connection / $activeGatewayProfile were only updated when a socket was freshly dialed (setConnection inside openSecondary), so activating an ALREADY-OPEN registry agent left both describing the previous backend — /api/fs, /api/media and image.attach routed to the wrong machine (same class as #46651) and newSessionInProfile targeted the stale profile. - Activations bypassed the gatewaySwitch mutex, so a rapid agent/profile interleave could complete out of order with the earlier setActive() landing last. Add profile.ts ensureGatewayAgent: the (connectionId, profile) analogue of ensureGatewayProfile that shares the same gatewaySwitch mutex, moves $activeGatewayProfile on every activation, and resyncs $connection from getConnectionFor (best-effort, like the profile path). The SDK ensureAgent now routes through it; local/null connectionId falls through to the profile path unchanged. * feat(desktop): expose busy turn flags on plugin SDK Plugins can now read host.state.busy and host.state.awaitingResponse for the focused chat. These follow the same session slice the chat pane uses, so a draft falls back to the global flags and a background turn does not leak. * fix(desktop): make plugin SDK turn flags follow the focused chat Follow-up to the salvaged #87558 commit: the PR's docs promised the flags follow "the focused chat", but PRIMARY_SESSION_VIEW is the primary workspace tab only — a focused session TILE would read the wrong chat. Wire host.state.busy / host.state.awaitingResponse through the focused slice ($focusedStoredSessionId / $focusedSessionState), same semantics as the statusbar busy pulse, with the primary view (and its draft fallback) while the workspace holds focus. Adds a tile-focus vitest case and corrects the docs wording. * feat(desktop): paste-anything MCP server import Add a compact Import popover to the MCP Capabilities page that accepts anything a user might copy from an MCP server README and infers the server config: - mcp.json snippets (mcpServers-wrapped, bare name->config maps, single unnamed server objects, Cursor/Claude `type` normalized to `transport`) - bare npx/bunx/uvx/node/docker command lines (name inferred from the package basename, e.g. server-filesystem -> filesystem) - `claude mcp add NAME [--transport http|sse] [-e K=V] [-H ...] [--] CMD ARGS...` and `claude mcp add NAME URL` - bare http(s) URLs (name inferred from the hostname) - Cursor deeplinks (cursor://anysphere.cursor-deeplink/mcp/install with a base64-encoded JSON config payload) The parser is a pure module (src/lib/mcp-import.ts) with unit tests for every format plus garbage input. The popover previews the inferred name + config and, on confirm, merges the entries into the editor draft exactly like addServer's starter entry: unique keys, dirty (unsaved) draft, first new block focused. Placeholder env values (YOUR_KEY, TOKEN_HERE, ...) are kept verbatim for the user to edit in the editor before saving. i18n keys added under settings.mcp for en, zh, zh-hant, ja (ar falls back through defineLocale). * feat(desktop): running is not busy Gate composer submit and plugin host busy on the target session slice, not a leftover foreground busyRef. Staff can keep typing while a worker session is running. Includes the follow-up test that submit uses the target session busy flag. * fix(desktop): lint and map contributor email for running-is-not-busy Drop the redundant Boolean() on selected in $primaryBusy and add the professorpalmer9@gmail.com mapping so attribution CI can resolve the PR. * feat(desktop-sdk): expose focused-session state atoms to plugins Disk plugins read app state exclusively through host.state, which only exposed the primary workspace tab ($activeSessionId). In the multi-tile layout, clicking a tile never touches that atom — and tile focus is a pure renderer concern, invisible to both gateway RPC and the event stream — so a plugin cannot follow the session the user is actually looking at. The core statusbar solves this same problem by reading the focused- session atoms (use-statusbar-items.tsx). Widen the generic plugin surface with the same signals, per the contribution rubric: - host.state.focusedSessionId — runtime id of the focused session (interacted tile, else the primary), the key for session.* RPC - host.state.focusedStoredSessionId — durable id for navigation and session-list matching - host.state.focusedUsage — live streamed UsageStats projection (context_used/max/percent, tokens, cost_usd), no RPC needed Additive only; no existing behavior changes. tsc --noEmit clean. Verified end-to-end with a disk plugin that now tracks the focused session across tiles. * test(desktop-sdk): contract-test the focused-session host.state atoms Locks the plugin-facing contract: the focused atoms exist as readonly nanostores, mirror the primary session while no tile is focused, project the focused session's usage, and — the behavior this PR exists for — follow the interacted tile while the primary-only $activeSessionId stays put. * fix(desktop-sdk): type focusedUsage as Partial<UsageStats>, fix expect arity ClientSessionState.usage is Partial<UsageStats> (app/types.ts) — the backend streams whichever fields changed — so the computed produces ReadableAtom<Partial<UsageStats> | null>. Annotate the entry honestly instead of claiming full UsageStats, and document the fallback rule for plugin authors. Also collapse the three-argument expect() calls in the contract test (vitest takes one message arg). Addresses triage review on PR #80461. * fix(desktop-sdk): address adversarial review — type honesty, real tile coverage, docs Independent second-pass review found three gaps: - focusedUsage is null | UsageStats, not Partial — ClientSessionState.usage is the full type (app/types.ts) and its only write site seeds the four required fields before merging (gateway-event.ts). The earlier Partial annotation traced the wrong type (SessionRuntimeInfo, an RPC payload). Comment now names the genuinely optional fields instead. - The tile-focus contract test never seeded $sessionTiles/$sessionStates, so it proved focusedStoredSessionId follows a tile but could not distinguish focusedSessionId/focusedUsage working from broken. Seed a bound runtime with distinct usage and assert both readout atoms move. - The two public host.state references (website docs + bundled skill reference) enumerated the old six atoms; plugin authors would never discover the new ones. Both lists updated. tsc/eslint/vitest green (4/4). * chore: map contributor email for focused-session atoms salvage * fix(desktop): exclude process-less descriptors from backend pool LRU cap Remote/cloud registry descriptors (entry.process === null) shared the POOL_MAX_BACKENDS cap with real spawned local backends, so a roster refresh across N registered remote connections could LRU-evict a live local backend idle past the keepalive window. Cap accounting and cap-driven eviction now count only entries with a live child process; descriptors remain subject to the idle reaper. * fix(desktop): tear down renderer secondaries when a registry connection is removed Removing a connection stopped its pooled backends and ssh tunnels but never told the renderer: for remote/cloud sources there is no local process to die, so the removed connection's WebSocket stayed open and kept streaming ghost events into the UI until page reload. If the socket did drop, openSecondary -> getConnectionFor threw 'No connection with id' and scheduleReconnect retried forever (backoff caps at 15s, entry never evicted). - main now broadcasts 'hermes:connections:changed' on removal (and on material edits); preload exposes connections.onChanged. - use-gateway-boot subscribes and calls the new disposeSecondariesForConnection(), which disposes + evicts every secondary scoped to the connection id (redialing on edits). - reconnectSecondary fail-stops: when the Electron main reports the connection no longer exists, the entry is disposed and evicted instead of retrying forever; ordinary transport errors keep the existing backoff behavior. * fix(desktop): recycle live backends and sockets when a connection edit changes its target saveRegistryConnection only rewrote the registry file: editing a connection's URL/token/host left pooled backend descriptors under 'conn:<id>::*' and open renderer sockets pointing at the OLD endpoint — the UI showed the new target while traffic kept flowing to the old one until idle-reap. When a save MATERIALLY changes an existing connection (endpoint / auth / ssh routing fields, via the new connectionDialFieldsChanged helper), main now stops that connection's pooled backends and tunnels (stopRegistryConnectionBackends, same teardown as removal) and broadcasts 'hermes:connections:changed' with reason 'updated' so renderers dispose and re-dial their secondaries at the new target. Label-only renames do not recycle. * fix(cli): restore Kitty keyboard protocol push and complete the extended-key alias table Commit 4c34eeb416 fixed dead Ctrl+C by removing the Kitty protocol push (CSI >1u) from _EXTENDED_ENTER_KEYS_SEQ, keeping only modifyOtherKeys level 2. That regressed kitty-the-terminal completely: kitty removed xterm modifyOtherKeys support (kovidgoyal/kitty#4075) and only speaks its own protocol, so after the removal kitty users lost Shift+Enter and every other extended key — the CSI >4;2m we still pushed is a no-op there (kitty even logs a PARSE ERROR for it). The original reason for removing the push is obsolete: #87511 mapped CSI-u control sequences, so Ctrl+C as ESC[99;5u now parses to Keys.ControlC and fires the existing c-c binding. (The kernel-INTR concern in that commit was moot — prompt_toolkit's raw mode clears ISIG, so Ctrl+C is always handled by the binding, never the kernel.) Restore the dual push (CSI >1u + CSI >4;2m), exactly mirroring the Ink TUI, and complete the alias table for what the kitty disambiguate flag actually emits — #87511 left real gaps, some of which its PR body wrongly claimed were covered: - Esc key: ESC[27u (+ modifiers) — previously leaked '[27u' as text - Ctrl+Backspace -> backward-kill-word (#78285 was closed on the wrong claim that codepoint-127 mapping existed; it did not) - Shift+Space -> space (#86866's second symptom; the Ctrl+Space mapping never covered modifier 2) - Alt+Enter -> newline tuple; Shift+Tab -> BackTab; Ctrl+Tab -> Tab; Alt/Shift+Backspace - Multi-modifier letters (Shift+Alt 4, Ctrl+Shift 6, Ctrl+Alt 7, Ctrl+Alt+Shift 8) normalized onto their Ctrl/Escape-prefix targets, both unshifted (kitty) and shifted (mok emitters) codepoints - Kitty PUA functional keys: keypad -> non-keypad equivalents, F13-F24, and Ignore for lock/media/modifier-event keys so they are consumed instead of leaking (kitty emits these even in legacy mode) Also: clear the VT100 parser's prefix cache after installing (stale answers could misparse), and re-push extended keys after _recover_terminal_input_modes' reset — the recovery previously popped both modes mid-session and never re-enabled them, silently killing Shift+Enter until restart. Refs #87511, #87074, #56684, #56645, #78285, #86866, #87390. * fix(cron): process .pth files for Windows uv-venv script jobs (#86567) _windows_cron_python_invocation bypasses the uv venv launcher (to avoid flashing a console window) and re-attaches the venv via PYTHONPATH — but PYTHONPATH entries are plain sys.path additions and never get .pth processing, so editable installs (pip install -e) were invisible to cron script jobs (ModuleNotFoundError). Bootstrap the script with site.addsitedir() on the venv site-packages, then exec it as __main__ via runpy.run_path, preserving the script directory on sys.path (python script.py semantics). Falls back to a plain invocation when the venv layout is unresolvable. * fix(cron): log and document the .pth bootstrap fallback (#86816 review) - WARN when the venv site-packages layout is unresolvable and the script falls back to plain PYTHONPATH execution, so 'editable installs invisible' failures are diagnosable. - Docstring: note that runpy does not set __package__/__spec__ the way a direct python script.py invocation does. * fix(update): surface config mutations applied silently during version-bump-only updates * fix: honor JSON-array string forms for skills.disabled and agent.disabled_toolsets `hermes config set` and JSON-mode editor saves store lists as quoted strings (e.g. '["skill-a","skill-b"]' or "['memory']"). Both disable filters treated such a string as a single name, so curated disable lists silently filtered nothing with zero diagnostics. Add parse_config_string_list() in agent.skill_utils and use it in _normalize_string_set (skills.disabled / platform_disabled) and at every agent.disabled_toolsets read site: tools_config resolve + reconcile, CLI, gateway agent construction (both sites), cron scheduler, and prompt_size. A scalar string still names a single entry (#13026); malformed JSON falls back to the single-name behavior instead of raising. Fixes #86661 * fix(desktop-update): put --daemonized ahead of ORIGINAL_ARGS in posix.sh re-exec Appending --daemonized after ORIGINAL_ARGS put it past the `--` relaunch-args separator on Linux, so it was absorbed into RELAUNCH_ARGS instead of being parsed as a flag. HANDOFF_DAEMONIZED never got set, so the one-shot self-detach block re-fired on every re-exec -- an unbounded self-exec loop (thousands of iterations/sec, 100%+ CPU, argv growing until execve fails with E2BIG) whenever relaunch args were present, which is the normal invocation shape on Linux. Fixes #86957 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(cli): deliver the Bedrock API key through a named provider The Bedrock API-key flow stored the bearer token in OPENAI_API_KEY and set a bare `provider: custom`. Since #28660 that variable is only honoured for openai.com hosts, so for bedrock-mantle.*.api.aws the token was dropped and requests went out with api_key="no-key-required", a 401 on every call. Write a named `providers.bedrock-mantle` entry with key_env: AWS_BEARER_TOKEN_BEDROCK instead. The named-provider branch in runtime_provider.py resolves key_env; the bare-custom branch cannot. Fixes authentication only. Per-model mantle route selection is separate. * fix(gateway): improve Windows detach diagnostics * refactor(gateway): share breakaway marker constant * fix(tui): settle session close against active turns * fix(tui): stop queued dispatch after session close * fix(cron): reject NUL-bearing script paths before any Path call (#76762 class) _run_job_script wrapped only expanduser() in its ingestion try/except. On Linux an unexpandable NUL-bearing value raises inside that call, so it landed on the clean fail-with-report path; on Windows expanduser() never expands '~user' (and so never raises), and the NUL surfaces later as an uncaught ValueError from resolve()/exists() — crashing the scheduler. Align with cron.lifecycle_guard._expand_candidate_path, which already documents this as the whole-class fix (the per-syscall catching produced #76762, #77703, #77780, #78256): reject '\\x00' eagerly at the ingestion boundary so both platforms fail identically. * test(cron): pin the eager NUL rejection contract for _run_job_script (#86829) * fix(cron): coerce script_path to str in the NUL guard so it can never crash (#86829, review #86832) "\x00" in script_path raises TypeError when a caller passes a non-str (e.g. a pathlib.Path, which is not iterable) — the guard itself would crash the scheduler. All current call sites pass plain str, but the guard must be crash-proof: str() first, then check. Adds a regression test running a real script through _run_job_script with a Path argument, which fails with TypeError on the pre-fix guard. * docs(agents): record multiplex profile-scoped env fail-closed rule (#86905) Lesson from the feishu DM multiplex investigation: under multiplex, os.environ holds the default profile's values, so any profile-level env config (credentials AND authorization) must be read scope-aware, and a scoped miss with a scope installed must fail closed instead of borrowing from os.environ. The _get_scoped_secret wrapper is copy-pasted across ~15 platform adapters — new adapters and edits to existing ones must keep the fail-closed semantics. * fix(auth): resolve provider auto-detection keys through the profile scope (#86917) resolve_provider's auto path read provider API keys with bare os.getenv — under multiplex a secondary profile's keys live only in its secret scope, so auto-detection found nothing and every secondary profile with model.provider: auto failed with 'No LLM provider configured' at agent init (reproduced on a live 7-profile gateway). Route both env-key reads (the OPENAI/OPENROUTER tier and the PROVIDER_REGISTRY loop) through _scoped_key_env, the scope-aware helper auxiliary_client already uses: secret scope wins under multiplex, UnscopedSecretError falls back to os.environ (default-profile/CLI paths unchanged). Same bug class as #86905. Verified in a gateway-accurate simulation (hermes_home_override + profile scope): resolve_provider('auto') now returns the secondary profile's own provider (deepseek) instead of erroring. * test(auth): cover profile-scoped key resolution in resolve_provider (#86917) Three regression tests: scoped DEEPSEEK_API_KEY is visible to auto detection under multiplex (the #86917 failure); unscoped paths keep the os.environ read; explicit config provider still wins. * fix(auth): only fall back to os.getenv on ImportError in resolve_provider (#86918 review) The previous except Exception silently fell back to os.getenv if the _scoped_key_env import ever failed — under multiplex that is exactly the fail-open this PR removes (secondary profiles would regress to 'No LLM provider configured' with zero trace). Catch only ImportError, log a WARNING naming the consequence, and let any other failure propagate. Also replaces the lambda fallback with a named nested function. * test(gitlock): pin the git-process guard in sweep tests (deflake slice 8) The stale-lock removal tests asserted the sweep result while leaving _git_proc_running() live: on CI the parallel per-file runner almost always has a real git subprocess in flight, pgrep -x git hits, and clear_stale_git_locks correctly refuses to sweep — failing the tests for reasons unrelated to the code under test (surfaced on PR #86918, which doesn't touch gitlock at all). Monkeypatch the guard to False in the sweep tests and add an explicit test pinning the guard's block-while-git-running behavior. * fix(gateway): complete /loop ticks after streamed already_sent turns Streamed replies return None so the adapter does not send twice. The /loop hook then saw empty text and never ran, so awaiting_response stayed true and later ticks never fired. Stash the delivered text on the event and use it for the post-turn hooks. /goal uses the same path. Tests: tests/gateway/test_loop_command.py * fix(cli): chat -c fails loudly on stderr and gains --create-if-missing `hermes chat -c "<title>" -q "<text>"` silently no-oped when no session matched the title under quiet/programmatic use: the not-found message was written to stdout (the channel quiet callers parse as the final response), so a background send to a not-yet-existing named session vanished with no error. Surfaces via Hermes-Bot-Mode bot-to-bot handoffs (#86794). - not-found message now goes to stderr (exit 1 unchanged), so programmatic callers always see it even with -Q/--quiet - new --create-if-missing: with `-c <title>` and no matching session, create a fresh session carrying the title and proceed — the deterministic "send to this named thread, making it if needed" primitive plugins asked for - extract the -c resolution block into _resolve_continue_arg for testability Tests: flag parsing, titled-session creation, stderr routing, source guard. * fix(cli): address review feedback on chat -c fail-loudly PR Response to AI review (Enough1122) on #86812: 1. `_create_titled_session`: log the underlying exception before returning None so programmatic callers aren't left with an undebuggable "could not be created" — failures (DB lock, I/O, import) now land in errors.log via logger.exception. 2. Drop the source-reading `TestSourceGuard` — it violated the repo's "never read source code in tests" rule and was a change-detector (passed even if behavior regressed). The stderr routing is already covered by the real-path `test_missing_session_fails_on_stderr`. 3. Bare `-c` + `--create-if-missing` now prints a stderr note explaining the flag needs a session name, instead of silently ignoring it — makes the no-op self-evident to programmatic callers. Tests: 6 targeted + 8 adjacent, all passing. * chore: map contributor email for @yflmq001 * fix(acp): probe CLI for --acp support before spawning subprocess CopilotACPClient unconditionally passes [self._acp_command] + self._acp_args (default ['--acp', '--stdio']) to subprocess.Popen. When the resolved CLI doesn't accept --acp (e.g. Claude Code v2.1.233, where 'claude --acp --stdio' exits 1 with 'error: unknown option') the subprocess dies in ~250ms with the error on stderr, but the parent ACP loop has no fast-fail for this shape and waits the full child_timeout_seconds (default 600s, observed 109s+ before user interruption) for stdout that never arrives. Add _acp_supported() that probes the CLI's --help output for the --acp flag in ~50ms, then call it at the top of _run_prompt before any spawn happens. When the probe fails, raise a RuntimeError that names the unsupported flag, lists the expected fix (install @github/copilot late 2025+, or set HERMES_COPILOT_ACP_*), and returns control to the caller in ~280ms instead of hanging the delegate_task parent for hundreds of seconds. Measured locally against Claude Code v2.1.233: - Before: delegate_task acp_command=claude hangs 109s+ then returns tokens={input:0, output:0}. - After: delegate_task acp_command=claude raises RuntimeError in 280ms with a clear actionable message. This does NOT change behavior for supported CLIs (the new @github/copilot ships with --acp) — the probe returns True and the spawn proceeds unchanged. Refs the bundled claude-review-delegate skill which already documents this class of transport-mismatch pitfall for users who call 'claude -p' directly; this fix closes the same gap for the delegate_task MCP path. * fix(acp): make --acp probe tri-state, cached, and mock-safe Salvage hardening on top of #87308 (thanks @Dudeman456): - Tri-state verdict: inconclusive probes (binary missing, --help failed/timed out) return None and fall through to the normal spawn path, preserving the established 'Could not start Copilot ACP command' error instead of masking it. This also fixes the two test_copilot_acp_client HOME-env regressions that went red on the PR: their mocked-Popen path was intercepted by the new unmocked subprocess.run probe. - Cache definitive verdicts per binary path so CLIs that DO support --acp pay the ~50ms --help cost once per process, not per prompt. - Skip the probe entirely when custom ACP args don't include --acp. - Fix the help-text regex: the old pattern never matched '[--acp]' (leading '[' is neither start-of-string nor whitespace) and \b after 'p' matched '--acpfoo'. - Hermeticity: stub subprocess.run in the two HOME-env tests; add 6 probe-specific tests (fast-fail, fall-through, caching, skip). * fix(cli): bound the Windows process-scan probes so a slow WMI scan cannot wedge hermes update (#87134) subprocess.run(capture_output=True, timeout=N) is not hang-safe on Windows: after the timeout fires, run()'s cleanup kills the direct child and then joins the pipe reader threads with an UNBOUNDED communicate(). A descendant (conhost.exe under wmic/powershell) holding duplicated pipe handles keeps the pipes from EOF and the join never returns. _scan_gateway_pids() runs its wmic / Get-CimInstance Win32_Process scans exactly that way, and on machines where the full process scan genuinely exceeds its 10/15s budget (cold WMI on first boot, ARM VMs, heavy Update/AV activity) hermes update wedged forever inside _pause_windows_gateways_for_update() before printing a single line — observed live on a fresh Windows 11 ARM64 VM with a faulthandler stack pinning the main thread in subprocess._communicate and only a conhost.exe child surviving. The single-flight update lock then blocks retries until the wedged process is killed by hand. This is the same deadlock class bounded_git_probe already fixed for git probes (#68609 / #66037). Generalize that proven pattern into a shared bounded_probe_run() — explicit communicate(timeout), kill_process_tree on failure, bounded 1s drain, then abandon the daemonic readers — and migrate the whole call-site class onto it: - hermes_cli/gateway.py _scan_gateway_pids (the site that hung; reached from hermes update, cron, gateway restart/status, dashboard) - hermes_cli/dashboard_procs.py wmic scan (same shape, reached on update) - hermes_cli/claw.py tasklist + PowerShell probes (same shape; its try/except cannot catch a hang because a hang raises nothing) - bounded_git_probe now delegates to bounded_probe_run (identical contract, one copy of the cleanup logic) Unlike bounded_git_probe, bounded_probe_run returns the CompletedProcess (or None) rather than collapsing to stdout, because the gateway scan branches on returncode to trip its wmic -> powershell fallback. Tests: tests/hermes_cli/test_bounded_probe_run.py covers success, nonzero-exit passthrough, spawn failure, bounded timeout (fails against the old unbounded semantics — verified by sabotage), errors= decoding, DEVNULL stdin, POSIX process-group placement, and the bounded_git_probe delegation contract. Existing test_git_probe_tree_kill.py passes unchanged against the delegated implementation. Closes #87134 * test(cli): retarget the wmic-encoding regression test at bounded_probe_run The Windows-only test asserted encoding/errors kwargs on a mocked subprocess.run, but the scan now routes through bounded_probe_run (#87134), so subprocess.run is never invoked. Assert the probe call's contract instead (errors='ignore', finite timeout), verify the parsed PIDs, and add a fail-open case for probe failure. The test no longer needs a Windows host once the probe is mocked, so the windows_only gate is dropped. * fix(agent): attribute background-review usage and add cost controls Persist fork token usage under session_model_usage task=background_review, emit a per-fork completion log line, and expose enabled/max_iterations/ prompt_file so operators can see and bound the automatic review cost. Address review feedback: load auxiliary.background_review once per spawn, classify completion logs by summarize action prefixes, treat explicit api_call_count=None as the documented default of 1, and WARNING on the fail-open enabled-gate path. * fix(desktop): route registry 'local' entry to the genuinely-local runtime ensureRegistryBackend delegated kind==='local' to ensureBackend(), which follows the v1 connection.json routing table — under a v1 REMOTE global mode (the migration keeps the mandatory 'local' entry AND makes that remote the registry primary) the roster's 'This device' rows enumerated and dialed the REMOTE primary: every profile appeared twice (forcing -slug handles) and clicking a local agent talked to the remote box. resolveRegistryLocalRoute() (pure, colocated with the registry helpers) now decides the local entry's path: delegate to the legacy route only when v1 is itself local (single-source behavior byte-identical); otherwise spawn/reuse a forced-local pool child via spawnPoolBackend's new forceLocal option, pooled under the composite conn:local::<profile> key so it cannot collide with the v1 remote descriptor cached at the bare profile key. * fix(desktop): key fan-out event consumption by (connectionId, profile) Secondary-gateway events were tagged with connectionId (store/gateway fan-out) but no consumer read it: working/attention tracking, the pruneSecondaryGateways keep-set, and the profile-scoped event gates (skin.changed / change-watcher broadcasts / approval-mode reconcile) all keyed by session id + bare profile name. Every registered source exposes a 'default' profile (the roster force-unshifts it), so two connected gateways collided — gateway B's 'default' activity was attributed to gateway A's 'default', keeping the wrong socket alive and applying the wrong source's config/skin/cron changes. Thread connectionId through consumption using the existing composite backendScopeKey helper: - session-states records each registry-tagged event's (connectionId, profile) scope per runtime session; liveSessionScopes() projects the busy/needs-input ones as composite keys for the gateway keep-set. - recomputeKeptGateways (use-gateway-boot) seeds the keep-set with those scopes; pruneSecondaryGateways matches registry-scoped entries ONLY on their composite key, while local entries keep matching bare profile names (single-source path unchanged). - gateway-event's 'from the active profile' gates now compare the event's composite scope against the active gateway's connection via the new activeGatewayConnectionId(); untagged local/primary events behave byte-identically. Display-only surfaces that already use roster handles are untouched. * style(desktop): order @hermes/shared import before nanostores (perfectionist/sort-imports) * fmt(js): `npm run fix` on merge (#87839) Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * fmt(js): `npm run fix` on merge (#87844) Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * fix(agent): trim background_review to the enabled switch Follow-up to #87400: drop the max_iterations and prompt_file knobs from auxiliary.background_review. The aux model routing (provider/model/ base_url/...) predates #87400 and stays; the enabled switch and the usage telemetry stay. The fork's iteration budget returns to the historical hardcoded 16. * fix(update): reload _subprocess_compat and dashboard_procs after git pull hermes update runs in the PRE-pull Python process. After git pull updates source files on disk, modules already in sys.modules still hold the OLD code. The existing _reload_config_modules() reloaded only config modules, but the post-update dashboard cleanup path (_finish_dashboard_update_cleanup -> _scan_dashboard_processes) imports hermes_cli._subprocess_compat lazily; a new symbol added there (e.g. bounded_probe_run) is invisible to the cached module object, causing ImportError during the cleanup step. Extend the reload list to include hermes_cli._subprocess_compat and hermes_cli.dashboard_procs so the cleanup uses freshly-pulled code. * fix(update): reload process-scan modules at the dashboard-cleanup entry point Widen PR #87757 to cover the ZIP path: _update_via_zip() also calls _finish_dashboard_update_cleanup() but never runs _reload_config_modules, so the Windows git-broken fallback would still crash with the same ImportError (cannot import name 'bounded_probe_run' from the stale cached hermes_cli._subprocess_compat). - new _reload_process_scan_modules() called inside _finish_dashboard_update_cleanup itself, so every current and future call site is covered; reloads dependency-first (_subprocess_compat, then dashboard_procs) - reload failures log at warning (a miss surfaces seconds later as an ImportError in the same process) - regression tests: reload-before-kill ordering, node-failure skip, stale-module symbol restoration (the exact #87134 boundary state), nonfatal reload failure, and the #87757 reload-list contract * chore: release v0.20.2 (2026.8.16) * fix(tui): modified Enter and bare LF insert a newline in the composer across IDE and macOS terminals (#87854) * fix(tui): send atomic CSI u for modified Enter in IDE terminals VS Code/Cursor/Windsurf terminals bound Shift/Ctrl/Cmd+Enter to the legacy \\r\n sequence, which Ink's parse-keypress split into a backslash keypress plus a plain Return — inserting a stray backslash and submitting instead of adding a newline. Emit Kitty CSI u sequences that encode the modifier atomically, and migrate keybindings users already have on disk. Co-authored-by: yatesjalex <yatesjalex@users.noreply.github.com> * fix(tui): treat a bare LF as a newline in macOS composer terminals Terminals that can't send a distinct Shift+Enter collapse a modified Enter / Ctrl+J down to a bare LF. shouldPreserveCtrlJNewline() already handles the env-detectable cases (SSH, Windows Terminal, Ghostty, WSL), but plain macOS terminals (Terminal.app, iTerm2 defaults) do the same and aren't env-detectable, leaving no keyboard-driven newline there. Fold the return-key decision into shouldInsertNewlineOnReturn() and accept a bare LF as a multiline fallback on macOS too, keeping CR as submit everywhere. Co-authored-by: LeonSGP43 <LeonSGP43@users.noreply.github.com> --------- Co-authored-by: yatesjalex <yatesjalex@users.noreply.github.com> Co-authored-by: LeonSGP43 <LeonSGP43@users.noreply.github.com> * fix(state): classify structural DB corruption as its own persistence cause 'database disk image is malformed' contains the word 'disk', so classify_persistence_error bucketed SQLITE_CORRUPT / SQLITE_NOTADB failures as 'disk' and the turn-completion explainer told users to free disk space for a structurally damaged state.db (the #77386-family misdiagnosis, reproduced in the v0.20.0 malformed-DB incident report). - hermes_state: new 'corrupt' bucket in PERSISTENCE_ERROR_CAUSES, matched via _DB_CORRUPTION_MARKERS BEFORE the lo…
vashkartik
added a commit
to vashkartik/hermes-agent
that referenced
this pull request
Aug 17, 2026
* fix(desktop): identify unsafe update blockers
* fix(gateway): keep persisted model routes consistent
* fix(desktop): don't cancel the running turn on Esc while an overlay is open
The composer's global Esc-to-cancel listener (useComposerEscCancel) fires
whenever the turn is busy and the active composer matches — but overlays
(Settings, Command Center, agents, cron, …) cover the chat while the
composer stays mounted and 'active' beneath them, so pressing Esc on any
of those pages interrupted the session the user wasn't even looking at.
OverlayView's own escape-layer Esc-to-close fired too, but the stream was
already dead.
Stand Esc down with composerFocusBlockedBySurface() — the same signal the
type-to-focus path uses (BLOCKING_OVERLAY includes OverlayView's
[data-overlay-surface] marker). Esc on an overlay now closes the overlay
via its escape layer instead of canceling the stream beneath it.
Fixes #82618
* fix(caching): engage prompt caching for LiteLLM Claude on the OpenAI wire
anthropic_prompt_cache_policy() only granted Anthropic cache_control
markers to LiteLLM over the native Anthropic wire
(api_mode == "anthropic_messages"). A LiteLLM deployment exposing the
OpenAI-compatible surface instead (/v1/chat/completions, /v1/messages
-> 404) matched no grant branch and fell through to (False, False): no
cache_control injected, the system prompt sent as a plain string, and
the provider serving zero cache hits -- the entire prompt re-billed at
full price on every turn. Silent: no error, no warning, usage simply
shows 100% uncached input forever.
Add one branch after the is_anthropic_wire/is_claude case that grants
caching to Claude-family models on a LiteLLM endpoint regardless of
wire, with the native inner-block layout. Same failure class already
documented in-function for Qwen/DashScope.
Design:
- Gated on the Claude family only (is_claude); a Gemini/GPT/Qwen route
through the same proxy must not receive markers (they may reject the
cache_control block format -- cf. the DeepSeek/OpenCode exclusion).
- Matches on provider string OR base_url host, since provider naming
varies per install (litellm, custom:litellm, or a bare custom alias
pointed at a LiteLLM host).
- prompt_caching.cache_ttl: false still wins (the _cache_disabled early
return is untouched).
- Generic strict OpenAI-wire custom providers (e.g. Fireworks) remain
excluded -- verified by the existing over-reach regression test.
Tests: adds TestLiteLLMOpenAIWire covering the grant (several model
spellings x provider/host signals), no-over-reach (non-Claude on the
same proxy get nothing; operator disable wins), and adjacent behavior
(LiteLLM in Anthropic proxy mode still native layout). Full module:
43 passed.
Closes #84506. Original diagnosis, patch design, and measurements by
@ottosulin.
* fix(caching): use the envelope layout for LiteLLM Claude on the OpenAI wire
Follow-up to the salvaged LiteLLM cache grant. The grant itself is right;
four things about how it was scoped were not.
1. Layout. The branch returned the native inner-block layout
(use_native_layout=True) on api_mode == "chat_completions". That layout
writes a TOP-LEVEL msg["cache_control"] on role:tool and empty-content
messages and depends on the Anthropic adapter to relocate it into the
block — but that adapter only runs for api_mode == "anthropic_messages"
(agent/transports/anthropic.py registers there), and the
chat_completions transport does no relocation. Measured on a 3-tool-turn
transcript: 2 of the 4 available breakpoints landed on markers the
provider never sees. Worse, when LiteLLM itself relocates a top-level
marker for an OpenRouter-backed Claude route
(OpenrouterConfig._move_cache_control_to_content), the marker lands on
an empty assistant turn and produces a cache_control-marked empty text
block — the HTTP 400 "text content blocks must contain" shape already
guarded in agent/anthropic_adapter.py (#69512). Switched to the envelope
layout, matching every other OpenAI-wire grant in this function:
4 of 4 breakpoints honored, zero empty blocks.
2. Host matching. `"litellm" in base_url_hostname(...)` is the substring
false-positive class base_url_hostname's own docstring warns against; it
granted Anthropic markers to notlitellm.example.com,
foolitellmbar.example and friends. Replaced with a label-token match in
a named helper, so "litellm" must be a whole dot- or hyphen-delimited
token. All three of the original test hosts still match; a "litellm"
path segment on an unrelated host still does not.
3. Transport gate. `not is_anthropic_wire` also swept in codex_responses,
bedrock_converse and codex_app_server. Gated on
api_mode == "chat_completions" explicitly.
4. Operator override. The grant is inferred from a provider/host name, but
the custom-provider capability lookup was gated on is_anthropic_wire, so
an explicit `prompt_caching: false` for the route+model was honored on
/v1/messages and silently ignored on /v1/chat/completions. The lookup
now also runs for a LiteLLM route, and its layout follows the transport
rather than the declaration (an explicit `true` must not promote a
chat_completions request to the native layout).
Tests: 64 passed. Adds the wire-shape contract the original matrix was
missing (asserts no breakpoint sits on the message envelope, rather than
only checking the returned tuple), plus lookalike-host, other-transport,
and both operator-override directions. All five guards mutation-checked —
reverting each fix turns the corresponding test red.
* fix(caching): match the litellm provider id token-wise too
Self-review follow-up. The previous commit fixed substring matching on the
HOST but left the provider-id side as a bare substring, so a user-named
provider like `custom:notlitellm` or `mylitellmthing` still matched and was
handed Anthropic markers — the same bug class, half-fixed.
Both signals now match `litellm` as a whole delimited token via a shared
helper. Real spellings (`litellm`, `custom:litellm`, `litellm-router`, and
the already-lowercased `LiteLLM`) still match; lookalikes no longer do.
Tests: 71 passed. Adds lookalike-provider and real-spelling guards; both
new guards mutation-checked. Differential matrix over 2688 configs vs
origin/main: 60 changes, every one a Claude model on a genuine LiteLLM
route getting the envelope layout, zero pre-existing routes altered.
* perf(caching): narrow the widened capability lookup to the LiteLLM grant
Self-review follow-up, caught by benchmarking the previous commit.
Widening the custom-provider capability-lookup gate to `is_anthropic_wire or
_is_litellm_route(...)` made EVERY chat_completions route with a litellm-ish
provider/host enter the lookup, including non-Claude models that the grant
branch below can never match. Measured on a route with no config.yaml
(the uncached worst case) that was ~7.5us -> ~1528us per evaluation.
Narrowed the gate to the exact condition the LiteLLM branch grants on
(chat_completions + Claude + litellm route), computed once into a local and
reused by the branch itself so the predicate no longer runs twice.
Measured with a realistic config.yaml present (mtime cache warm), vs
origin/main:
live-agent policy 20.6us -> 61.7us
destination planning 219.3us -> 347.7us
Sub-millisecond and scoped to the routes that actually opted in. The
earlier 1.5ms figures were a tempdir artifact: load_config_readonly's
mtime cache cannot engage when no config.yaml exists, which is never true
of a real install. Non-LiteLLM and non-Claude routes are unaffected
(openrouter Claude measured flat at ~7.9us).
Tests: 82 passed across the policy and TTL-propagation modules.
* test(caching): pin signal precedence and the openrouter-host opt-out
Review follow-up. Three coverage gaps in the LiteLLM matrix:
- The operator opt-out on a litellm-named provider pointed at an OpenRouter
host. That route previously took the OpenRouter branch and ignored an
explicit per-model `prompt_caching: false`; it is the only cell in the
differential matrix where the salvage REMOVES caching, so pin it as
intended rather than leaving it to be read as a regression.
- Signal precedence: an explicitly litellm-named provider grants even on a
lookalike host, because the provider id is an independent signal and only
the host-derived signal is token-gated. Intentional, now documented.
- A hyphen-delimited host label (`my-litellm-gw.internal.example.com`),
which the token matcher handles but nothing exercised.
Traded the redundant `claude-3-7-sonnet` parametrize cell for the new host
case, so the matrix covers more shapes with the same cell count.
Tests: 83 passed. All three production fixes re-mutation-checked against
the final stack.
* fix(web_server): discover root user plugins under profile-scoped processes
When the backend is spawned profile-scoped (`--profile <name>` sets
HERMES_HOME=<root>/profiles/<name>), _discover_dashboard_plugins()
scanned only get_process_hermes_home()/plugins — the profile directory,
which has no plugins/ content. Pooled per-profile backends therefore
discovered zero user plugins, mounted no plugin API routes, and every
plugin REST call fell through to the SPA catch-all 404.
Also scan get_default_hermes_root()/plugins (which unwraps
<root>/profiles/<name> to <root> and leaves a custom HERMES_HOME
untouched when it is itself the root), matching how hermes_cli.plugins
resolves install locations. The profile home is scanned first, so a
profile-local plugin of the same name stays authoritative via the
existing seen_names dedupe.
Adds regression tests for root-plugin discovery under a profile-scoped
process and for profile-over-root precedence.
Fixes #87197 (plugin discovery half — the misleading /api/* catch-all
half is addressed separately in #87270).
* fix(telegram): keep /loop and synthetic sends in the active DM topic
Fixes #87051
* fix(desktop): show failed status for timed-out subagents in fallback stream path
Fixes #87200
* fix(desktop): match custom provider aliases in model catalog menu
Fixes #87035
* chore(contributors): map emails for P2-sweep salvage wave
* fix(desktop): avoid PowerShell parent marker boot gate
* fix(desktop): give Windows start-marker PowerShell probe a 30s budget
PowerShell 5.1 cold starts take 2.4-8s on affected Windows hosts, so the
shared 3s execText timeout hard-failed the parent start-marker probe for
any PID that still needs the PowerShell path (e.g. backend children).
Make execText's timeout overridable and raise the marker probe to 30s.
Fixes #87169
* perf(desktop): hydrate transcripts with a small tail page + on-demand older-page backfill
Replace the fixed 500-message REST hydration (getLatestSessionMessages)
with a 120-row newest-first tail page. When the page comes back full, a
new per-session tail store records "possibly truncated + next offset";
"Show earlier" — once the DOM budget and the in-memory store window are
both exhausted — fetches the next older page via the new
getOlderSessionMessages helper (order latest + offset, matching the
backend's back-from-newest paging semantics) and prepends it to the
session store, deduped by durable row id and race-guarded against
session switches. Legacy backends without pagination metadata fall back
to the one-shot full transcript and retire the action.
Tail-page refreshes (background sync, post-turn rehydrate, re-activate,
cold-resume prefetch) graft the refreshed tail onto any backfilled
prefix instead of clobbering it, preserving reference identity on
no-ops. includeCompacted stays on every read — compaction-archived rows
remain part of the durable display history.
* feat(desktop): MCP fleet cost/usage overlay with schema token estimates and 30-day usage
Each configured server row on the MCP Capabilities page now shows what it
costs and whether it earns its keep:
- ~per-call token estimate of the server's tool schemas, summed over ENABLED
tools only (ceil(schema_chars/4) via the existing include/exclude filter)
- 30-day usage count from getUsageAnalytics(30), cached per scope profile
like the Toolsets tab's toolCallsCache, mapped to servers via the
mcp__<server>__<tool> registry-name convention (tools/mcp_tool.py)
- a subtle muted "unused" pill on enabled, probed-ok servers with nonzero
schema cost and zero 30-day uses — never a dialog
Backend: the /api/mcp/servers/{name}/test probe now fills an additive
per-tool `schema_chars` (length of the SAME converted registry schema the
agent registers). Older backends omit it → renderer shows counts only;
older renderers ignore the extra key. Display-only: nothing changes what
schemas are sent to models, no config knobs.
i18n keys (costTokens/usage30d/unusedPill) added to types/en/zh/zh-hant/ja
(ar inherits en via defineLocale overrides). Pure math lives in
lib/mcp-cost.ts with unit tests; Python wire shape pinned in
tests/hermes_cli/test_web_server_profile_unification.py.
* fix(tui): map lineage edit ordinals past compression prefix
Desktop/TUI count full displayed lineage after compression, but
prompt.submit validated truncate ordinals against tip-only history.
Translate via display_history_prefix and recover stale 4018s on Desktop.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(desktop): hoist GatewayMock type into #82462 edit recovery suite
CI typecheck failed because GatewayMock lived only in the previous describe.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(desktop): hermes:// deep link to install MCP servers with explicit confirmation
Adds hermes://mcp/install?name=NAME&config=B64 (base64url or standard
base64 JSON), mirroring Cursor's mcp/install deep link, so vendors and
docs can offer an "Add to Hermes" button.
- Electron: the existing generic hermes:// handler already forwards
{kind, name, params}; only its comment is updated (no new handler).
- Renderer: use-desktop-integrations routes kind=mcp/name=install into
a pending-install store; a new confirmation dialog shows the server
name and the FULL pretty-printed config (attacker-controllable input),
with a prominent caution for stdio command entries. Nothing is written
until the user confirms; existing names require a rename or cancel.
On confirm the server is merged over a fresh fetch of the current map
via saveMcpServers, then navigation lands on /skills?tab=mcp&server=…
so useDeepLinkHighlight focuses the new row.
- Validation: name ^[A-Za-z0-9._-]{1,64}$; config must decode to an
object with a string http(s) `url` or a string `command` (never both);
payloads over 32KB rejected; failures surface as a toast.
- Pure parser in src/lib/mcp-deeplink.ts with unit tests (url shape,
command shape, bad base64, non-object, javascript: URL, oversized).
- i18n keys in types + en/zh/zh-hant/ja/ar.
- Docs: "Add to Hermes link" section in the MCP config reference.
* fmt(js): `npm run fix` on merge (#87599)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* feat(desktop): background MCP health checks with re-auth nudges
MCP server problems (expired OAuth tokens especially) were only
discovered when the user visited the MCP page and a probe ran. Now a
renderer-side background checker (store/mcp-health.ts) sweeps the
active profile's enabled HTTP/SSE MCP servers on gateway connect and
every 30 minutes, and fires an in-app notification with a "Sign in"
action ("<name> MCP needs re-authentication") that navigates to the
MCP page with ?server=<name> so useDeepLinkHighlight focuses the
server and its Authenticate button. Navigation only — OAuth flows are
never auto-launched.
stdio servers are deliberately excluded: probing a stdio server SPAWNS
a local process, so a background timer must never touch them. Only
url-shaped servers (where OAuth expiry lives) are swept, sequentially.
The tab's probeCache/serverFingerprint/probeKey/NEEDS_AUTH_RE moved to
a shared lib/mcp-probe-cache.ts (behavior identical) so the page and
the checker share one probe cache and its 5-minute TTL — neither
surface re-probes what the other just learned.
Notifications fire only on a TRANSITION into needs-auth/error (pure
state machine, unit-tested), hard-capped at one per server per app
session, keyed per profile. Profile switches drop pending timers and
re-arm for the new profile; sweeps never run while the gateway is
disconnected. No new config knobs. i18n keys added across
en/zh/zh-hant/ja/ar + types.
* fmt(js): `npm run fix` on merge (#87607)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* fix(desktop): scope messaging to active remote profile
Complete the sidebar profile-scope contract across remote Electron routing, older-backend fallbacks, standalone messaging refreshes, and pagination. Reject stale profile responses and keep the explicit all-profiles view unified.
Co-authored-by: 墨綠BG <s5460703@gmail.com>
Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
* fix(desktop): retain messaging totals per profile
Key resolved platform totals by Desktop profile and source so profile switches neither inherit another profile's count nor discard a count that was already resolved. Keep the full reset for connection configuration changes.
Co-authored-by: frendo <frendo.wu@gmail.com>
* fix(desktop): ignore stale messaging page responses
Sequence per-profile platform pagination so an older overlapping response cannot replace a newer, larger page.
* docs(desktop): document profile scope helpers
Add JSDoc to the exported helpers introduced by the profile-scoped sidebar change.
* fix(desktop): reject stale profile refreshes
* fix(desktop): harden profile-scoped refreshes
* fix(desktop): resolve sessions from sidebar caches
Consult messaging and cron caches before the by-id fallback so opening a sidebar row neither depends on a redundant network lookup nor duplicates it into regular recents.
Co-authored-by: protas-box <protas.box@icloud.com>
* fix(tools-config): stop reconfigure flow clobbering image_gen.use_gateway on managed FAL rows
The Nous Subscription image_gen row carries imagegen_backend="fal", so
_reconfigure_provider's post-model-picker step ran
img_cfg["use_gateway"] = False
unconditionally at two sites, immediately after the managed branch had
written use_gateway=True. A user who picked Nous Subscription and then
re-entered `hermes tools` to change the model was silently flipped onto
their personal FAL_KEY.
Same bug class as fe63353cb, which fixed the plugin-provider selector
but missed these two legacy-backend sites in the reconfigure flow. Both
now write bool(managed_feature), matching the existing correct site in
_configure_provider.
Adds regression tests driven through the real TOOL_CATEGORIES managed
row; sabotage-verified (tests fail with the old behavior restored).
* feat(desktop): make the multi-gateway Connections registry discoverable
The multi-connection registry (Settings -> Connections) shipped with no
entry point outside the settings nav, and the product-owner report was
blunt: 'I didn't see any obvious way to hook up multiple gateways.'
- Profile rail: a plug pill pinned beside Manage ('Connect another
Hermes gateway...') deep-links to /settings?tab=connections. Always
visible, including for single-profile first-run users.
- Command palette: Settings -> Connections is now a searchable entry
(keywords: add gateway, remote, ssh, cloud, instances, registry).
- i18n: profiles.connectGateway added to en/types/zh; other locales
fall back through defineLocale.
- Tests: profile-rail-connect.test.tsx covers the deep link and the
single-profile visibility guarantee.
* docs: full multi-gateway setup guide for Hermes Desktop
Expand user-guide/multi-connection-desktop.md into a complete setup
walkthrough: where to find the pane (settings nav, profile-rail plug,
command palette), the exact add-connection editor fields (Name,
Gateway URL, Authentication: Session token/OAuth, SSH host), Primary /
This device pills, Test semantics, agent roster + profile-rail
switching and per-profile session/cron/messaging scoping, token
storage via Electron safeStorage with the keyring-less Linux plain-
text opt-in, and troubleshooting. All quoted labels match the desktop
i18n strings. Cross-link the rail entry point from desktop.md.
* fix(desktop): never resolve a missing named gateway scope to the primary
activeGateway() fell back to the primary gateway when the active key named
a registry-agent scope (conn:<id>::<profile>) whose secondaries entry had
been evicted — e.g. closeSecondaryGateways() during a soft gateway switch —
so sends and session ops silently executed against the WRONG machine.
A named scope now resolves to its own socket or null, and every eviction
path (closeSecondaryGateways, pruneSecondaryGateways) explicitly restores
the primary as active when it evicts the active scope, keeping the
'activeKey always resolves' invariant with the atoms following.
* fix(desktop): sync connection atoms and share the switch mutex for agent activation
ensureGatewayForAgent (the SDK ensureAgent door) skipped the two invariants
the profile path provides:
- $connection / $activeGatewayProfile were only updated when a socket was
freshly dialed (setConnection inside openSecondary), so activating an
ALREADY-OPEN registry agent left both describing the previous backend —
/api/fs, /api/media and image.attach routed to the wrong machine (same
class as #46651) and newSessionInProfile targeted the stale profile.
- Activations bypassed the gatewaySwitch mutex, so a rapid agent/profile
interleave could complete out of order with the earlier setActive()
landing last.
Add profile.ts ensureGatewayAgent: the (connectionId, profile) analogue of
ensureGatewayProfile that shares the same gatewaySwitch mutex, moves
$activeGatewayProfile on every activation, and resyncs $connection from
getConnectionFor (best-effort, like the profile path). The SDK ensureAgent
now routes through it; local/null connectionId falls through to the
profile path unchanged.
* feat(desktop): expose busy turn flags on plugin SDK
Plugins can now read host.state.busy and host.state.awaitingResponse
for the focused chat. These follow the same session slice the chat pane
uses, so a draft falls back to the global flags and a background turn
does not leak.
* fix(desktop): make plugin SDK turn flags follow the focused chat
Follow-up to the salvaged #87558 commit: the PR's docs promised the flags
follow "the focused chat", but PRIMARY_SESSION_VIEW is the primary
workspace tab only — a focused session TILE would read the wrong chat.
Wire host.state.busy / host.state.awaitingResponse through the focused
slice ($focusedStoredSessionId / $focusedSessionState), same semantics
as the statusbar busy pulse, with the primary view (and its draft
fallback) while the workspace holds focus.
Adds a tile-focus vitest case and corrects the docs wording.
* feat(desktop): paste-anything MCP server import
Add a compact Import popover to the MCP Capabilities page that accepts
anything a user might copy from an MCP server README and infers the
server config:
- mcp.json snippets (mcpServers-wrapped, bare name->config maps, single
unnamed server objects, Cursor/Claude `type` normalized to `transport`)
- bare npx/bunx/uvx/node/docker command lines (name inferred from the
package basename, e.g. server-filesystem -> filesystem)
- `claude mcp add NAME [--transport http|sse] [-e K=V] [-H ...] [--] CMD
ARGS...` and `claude mcp add NAME URL`
- bare http(s) URLs (name inferred from the hostname)
- Cursor deeplinks (cursor://anysphere.cursor-deeplink/mcp/install with
a base64-encoded JSON config payload)
The parser is a pure module (src/lib/mcp-import.ts) with unit tests for
every format plus garbage input. The popover previews the inferred
name + config and, on confirm, merges the entries into the editor draft
exactly like addServer's starter entry: unique keys, dirty (unsaved)
draft, first new block focused. Placeholder env values (YOUR_KEY,
TOKEN_HERE, ...) are kept verbatim for the user to edit in the editor
before saving.
i18n keys added under settings.mcp for en, zh, zh-hant, ja (ar falls
back through defineLocale).
* feat(desktop): running is not busy
Gate composer submit and plugin host busy on the target session slice, not a leftover foreground busyRef. Staff can keep typing while a worker session is running.
Includes the follow-up test that submit uses the target session busy flag.
* fix(desktop): lint and map contributor email for running-is-not-busy
Drop the redundant Boolean() on selected in $primaryBusy and add the
professorpalmer9@gmail.com mapping so attribution CI can resolve the PR.
* feat(desktop-sdk): expose focused-session state atoms to plugins
Disk plugins read app state exclusively through host.state, which only
exposed the primary workspace tab ($activeSessionId). In the multi-tile
layout, clicking a tile never touches that atom — and tile focus is a
pure renderer concern, invisible to both gateway RPC and the event
stream — so a plugin cannot follow the session the user is actually
looking at.
The core statusbar solves this same problem by reading the focused-
session atoms (use-statusbar-items.tsx). Widen the generic plugin
surface with the same signals, per the contribution rubric:
- host.state.focusedSessionId — runtime id of the focused session
(interacted tile, else the primary), the key for session.* RPC
- host.state.focusedStoredSessionId — durable id for navigation and
session-list matching
- host.state.focusedUsage — live streamed UsageStats projection
(context_used/max/percent, tokens, cost_usd), no RPC needed
Additive only; no existing behavior changes. tsc --noEmit clean.
Verified end-to-end with a disk plugin that now tracks the focused
session across tiles.
* test(desktop-sdk): contract-test the focused-session host.state atoms
Locks the plugin-facing contract: the focused atoms exist as readonly
nanostores, mirror the primary session while no tile is focused, project
the focused session's usage, and — the behavior this PR exists for —
follow the interacted tile while the primary-only $activeSessionId
stays put.
* fix(desktop-sdk): type focusedUsage as Partial<UsageStats>, fix expect arity
ClientSessionState.usage is Partial<UsageStats> (app/types.ts) — the
backend streams whichever fields changed — so the computed produces
ReadableAtom<Partial<UsageStats> | null>. Annotate the entry honestly
instead of claiming full UsageStats, and document the fallback rule for
plugin authors. Also collapse the three-argument expect() calls in the
contract test (vitest takes one message arg). Addresses triage review on
PR #80461.
* fix(desktop-sdk): address adversarial review — type honesty, real tile coverage, docs
Independent second-pass review found three gaps:
- focusedUsage is null | UsageStats, not Partial — ClientSessionState.usage
is the full type (app/types.ts) and its only write site seeds the four
required fields before merging (gateway-event.ts). The earlier Partial
annotation traced the wrong type (SessionRuntimeInfo, an RPC payload).
Comment now names the genuinely optional fields instead.
- The tile-focus contract test never seeded $sessionTiles/$sessionStates,
so it proved focusedStoredSessionId follows a tile but could not
distinguish focusedSessionId/focusedUsage working from broken. Seed a
bound runtime with distinct usage and assert both readout atoms move.
- The two public host.state references (website docs + bundled skill
reference) enumerated the old six atoms; plugin authors would never
discover the new ones. Both lists updated.
tsc/eslint/vitest green (4/4).
* chore: map contributor email for focused-session atoms salvage
* fix(desktop): exclude process-less descriptors from backend pool LRU cap
Remote/cloud registry descriptors (entry.process === null) shared the
POOL_MAX_BACKENDS cap with real spawned local backends, so a roster
refresh across N registered remote connections could LRU-evict a live
local backend idle past the keepalive window. Cap accounting and
cap-driven eviction now count only entries with a live child process;
descriptors remain subject to the idle reaper.
* fix(desktop): tear down renderer secondaries when a registry connection is removed
Removing a connection stopped its pooled backends and ssh tunnels but
never told the renderer: for remote/cloud sources there is no local
process to die, so the removed connection's WebSocket stayed open and
kept streaming ghost events into the UI until page reload. If the
socket did drop, openSecondary -> getConnectionFor threw 'No connection
with id' and scheduleReconnect retried forever (backoff caps at 15s,
entry never evicted).
- main now broadcasts 'hermes:connections:changed' on removal (and on
material edits); preload exposes connections.onChanged.
- use-gateway-boot subscribes and calls the new
disposeSecondariesForConnection(), which disposes + evicts every
secondary scoped to the connection id (redialing on edits).
- reconnectSecondary fail-stops: when the Electron main reports the
connection no longer exists, the entry is disposed and evicted
instead of retrying forever; ordinary transport errors keep the
existing backoff behavior.
* fix(desktop): recycle live backends and sockets when a connection edit changes its target
saveRegistryConnection only rewrote the registry file: editing a
connection's URL/token/host left pooled backend descriptors under
'conn:<id>::*' and open renderer sockets pointing at the OLD endpoint —
the UI showed the new target while traffic kept flowing to the old one
until idle-reap.
When a save MATERIALLY changes an existing connection (endpoint / auth /
ssh routing fields, via the new connectionDialFieldsChanged helper),
main now stops that connection's pooled backends and tunnels
(stopRegistryConnectionBackends, same teardown as removal) and
broadcasts 'hermes:connections:changed' with reason 'updated' so
renderers dispose and re-dial their secondaries at the new target.
Label-only renames do not recycle.
* fix(cli): restore Kitty keyboard protocol push and complete the extended-key alias table
Commit 4c34eeb416 fixed dead Ctrl+C by removing the Kitty protocol push
(CSI >1u) from _EXTENDED_ENTER_KEYS_SEQ, keeping only modifyOtherKeys
level 2. That regressed kitty-the-terminal completely: kitty removed
xterm modifyOtherKeys support (kovidgoyal/kitty#4075) and only speaks
its own protocol, so after the removal kitty users lost Shift+Enter and
every other extended key — the CSI >4;2m we still pushed is a no-op
there (kitty even logs a PARSE ERROR for it).
The original reason for removing the push is obsolete: #87511 mapped
CSI-u control sequences, so Ctrl+C as ESC[99;5u now parses to
Keys.ControlC and fires the existing c-c binding. (The kernel-INTR
concern in that commit was moot — prompt_toolkit's raw mode clears
ISIG, so Ctrl+C is always handled by the binding, never the kernel.)
Restore the dual push (CSI >1u + CSI >4;2m), exactly mirroring the Ink
TUI, and complete the alias table for what the kitty disambiguate flag
actually emits — #87511 left real gaps, some of which its PR body
wrongly claimed were covered:
- Esc key: ESC[27u (+ modifiers) — previously leaked '[27u' as text
- Ctrl+Backspace -> backward-kill-word (#78285 was closed on the wrong
claim that codepoint-127 mapping existed; it did not)
- Shift+Space -> space (#86866's second symptom; the Ctrl+Space
mapping never covered modifier 2)
- Alt+Enter -> newline tuple; Shift+Tab -> BackTab; Ctrl+Tab -> Tab;
Alt/Shift+Backspace
- Multi-modifier letters (Shift+Alt 4, Ctrl+Shift 6, Ctrl+Alt 7,
Ctrl+Alt+Shift 8) normalized onto their Ctrl/Escape-prefix targets,
both unshifted (kitty) and shifted (mok emitters) codepoints
- Kitty PUA functional keys: keypad -> non-keypad equivalents,
F13-F24, and Ignore for lock/media/modifier-event keys so they are
consumed instead of leaking (kitty emits these even in legacy mode)
Also: clear the VT100 parser's prefix cache after installing (stale
answers could misparse), and re-push extended keys after
_recover_terminal_input_modes' reset — the recovery previously popped
both modes mid-session and never re-enabled them, silently killing
Shift+Enter until restart.
Refs #87511, #87074, #56684, #56645, #78285, #86866, #87390.
* fix(cron): process .pth files for Windows uv-venv script jobs (#86567)
_windows_cron_python_invocation bypasses the uv venv launcher (to avoid
flashing a console window) and re-attaches the venv via PYTHONPATH — but
PYTHONPATH entries are plain sys.path additions and never get .pth
processing, so editable installs (pip install -e) were invisible to cron
script jobs (ModuleNotFoundError).
Bootstrap the script with site.addsitedir() on the venv site-packages,
then exec it as __main__ via runpy.run_path, preserving the script
directory on sys.path (python script.py semantics). Falls back to a
plain invocation when the venv layout is unresolvable.
* fix(cron): log and document the .pth bootstrap fallback (#86816 review)
- WARN when the venv site-packages layout is unresolvable and the script
falls back to plain PYTHONPATH execution, so 'editable installs
invisible' failures are diagnosable.
- Docstring: note that runpy does not set __package__/__spec__ the way a
direct python script.py invocation does.
* fix(update): surface config mutations applied silently during version-bump-only updates
* fix: honor JSON-array string forms for skills.disabled and agent.disabled_toolsets
`hermes config set` and JSON-mode editor saves store lists as quoted
strings (e.g. '["skill-a","skill-b"]' or "['memory']"). Both disable
filters treated such a string as a single name, so curated disable
lists silently filtered nothing with zero diagnostics.
Add parse_config_string_list() in agent.skill_utils and use it in
_normalize_string_set (skills.disabled / platform_disabled) and at
every agent.disabled_toolsets read site: tools_config resolve +
reconcile, CLI, gateway agent construction (both sites), cron
scheduler, and prompt_size. A scalar string still names a single
entry (#13026); malformed JSON falls back to the single-name
behavior instead of raising.
Fixes #86661
* fix(desktop-update): put --daemonized ahead of ORIGINAL_ARGS in posix.sh re-exec
Appending --daemonized after ORIGINAL_ARGS put it past the `--`
relaunch-args separator on Linux, so it was absorbed into
RELAUNCH_ARGS instead of being parsed as a flag. HANDOFF_DAEMONIZED
never got set, so the one-shot self-detach block re-fired on every
re-exec -- an unbounded self-exec loop (thousands of iterations/sec,
100%+ CPU, argv growing until execve fails with E2BIG) whenever
relaunch args were present, which is the normal invocation shape on
Linux.
Fixes #86957
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(cli): deliver the Bedrock API key through a named provider
The Bedrock API-key flow stored the bearer token in OPENAI_API_KEY and set
a bare `provider: custom`. Since #28660 that variable is only honoured for
openai.com hosts, so for bedrock-mantle.*.api.aws the token was dropped and
requests went out with api_key="no-key-required", a 401 on every call.
Write a named `providers.bedrock-mantle` entry with
key_env: AWS_BEARER_TOKEN_BEDROCK instead. The named-provider branch in
runtime_provider.py resolves key_env; the bare-custom branch cannot.
Fixes authentication only. Per-model mantle route selection is separate.
* fix(gateway): improve Windows detach diagnostics
* refactor(gateway): share breakaway marker constant
* fix(tui): settle session close against active turns
* fix(tui): stop queued dispatch after session close
* fix(cron): reject NUL-bearing script paths before any Path call (#76762 class)
_run_job_script wrapped only expanduser() in its ingestion try/except.
On Linux an unexpandable NUL-bearing value raises inside that call, so it
landed on the clean fail-with-report path; on Windows expanduser() never
expands '~user' (and so never raises), and the NUL surfaces later as an
uncaught ValueError from resolve()/exists() — crashing the scheduler.
Align with cron.lifecycle_guard._expand_candidate_path, which already
documents this as the whole-class fix (the per-syscall catching produced
#76762, #77703, #77780, #78256): reject '\\x00' eagerly at the ingestion
boundary so both platforms fail identically.
* test(cron): pin the eager NUL rejection contract for _run_job_script (#86829)
* fix(cron): coerce script_path to str in the NUL guard so it can never crash (#86829, review #86832)
"\x00" in script_path raises TypeError when a caller passes a non-str
(e.g. a pathlib.Path, which is not iterable) — the guard itself would
crash the scheduler. All current call sites pass plain str, but the
guard must be crash-proof: str() first, then check. Adds a regression
test running a real script through _run_job_script with a Path argument,
which fails with TypeError on the pre-fix guard.
* docs(agents): record multiplex profile-scoped env fail-closed rule (#86905)
Lesson from the feishu DM multiplex investigation: under multiplex,
os.environ holds the default profile's values, so any profile-level env
config (credentials AND authorization) must be read scope-aware, and a
scoped miss with a scope installed must fail closed instead of borrowing
from os.environ. The _get_scoped_secret wrapper is copy-pasted across
~15 platform adapters — new adapters and edits to existing ones must
keep the fail-closed semantics.
* fix(auth): resolve provider auto-detection keys through the profile scope (#86917)
resolve_provider's auto path read provider API keys with bare
os.getenv — under multiplex a secondary profile's keys live only in its
secret scope, so auto-detection found nothing and every secondary
profile with model.provider: auto failed with 'No LLM provider
configured' at agent init (reproduced on a live 7-profile gateway).
Route both env-key reads (the OPENAI/OPENROUTER tier and the
PROVIDER_REGISTRY loop) through _scoped_key_env, the scope-aware helper
auxiliary_client already uses: secret scope wins under multiplex,
UnscopedSecretError falls back to os.environ (default-profile/CLI
paths unchanged). Same bug class as #86905.
Verified in a gateway-accurate simulation (hermes_home_override +
profile scope): resolve_provider('auto') now returns the secondary
profile's own provider (deepseek) instead of erroring.
* test(auth): cover profile-scoped key resolution in resolve_provider (#86917)
Three regression tests: scoped DEEPSEEK_API_KEY is visible to auto
detection under multiplex (the #86917 failure); unscoped paths keep the
os.environ read; explicit config provider still wins.
* fix(auth): only fall back to os.getenv on ImportError in resolve_provider (#86918 review)
The previous except Exception silently fell back to os.getenv if the
_scoped_key_env import ever failed — under multiplex that is exactly the
fail-open this PR removes (secondary profiles would regress to 'No LLM
provider configured' with zero trace). Catch only ImportError, log a
WARNING naming the consequence, and let any other failure propagate.
Also replaces the lambda fallback with a named nested function.
* test(gitlock): pin the git-process guard in sweep tests (deflake slice 8)
The stale-lock removal tests asserted the sweep result while leaving
_git_proc_running() live: on CI the parallel per-file runner almost
always has a real git subprocess in flight, pgrep -x git hits, and
clear_stale_git_locks correctly refuses to sweep — failing the tests
for reasons unrelated to the code under test (surfaced on PR #86918,
which doesn't touch gitlock at all).
Monkeypatch the guard to False in the sweep tests and add an explicit
test pinning the guard's block-while-git-running behavior.
* fix(gateway): complete /loop ticks after streamed already_sent turns
Streamed replies return None so the adapter does not send twice.
The /loop hook then saw empty text and never ran, so
awaiting_response stayed true and later ticks never fired.
Stash the delivered text on the event and use it for the post-turn
hooks. /goal uses the same path.
Tests: tests/gateway/test_loop_command.py
* fix(cli): chat -c fails loudly on stderr and gains --create-if-missing
`hermes chat -c "<title>" -q "<text>"` silently no-oped when no session
matched the title under quiet/programmatic use: the not-found message was
written to stdout (the channel quiet callers parse as the final response),
so a background send to a not-yet-existing named session vanished with no
error. Surfaces via Hermes-Bot-Mode bot-to-bot handoffs (#86794).
- not-found message now goes to stderr (exit 1 unchanged), so programmatic
callers always see it even with -Q/--quiet
- new --create-if-missing: with `-c <title>` and no matching session, create
a fresh session carrying the title and proceed — the deterministic
"send to this named thread, making it if needed" primitive plugins asked for
- extract the -c resolution block into _resolve_continue_arg for testability
Tests: flag parsing, titled-session creation, stderr routing, source guard.
* fix(cli): address review feedback on chat -c fail-loudly PR
Response to AI review (Enough1122) on #86812:
1. `_create_titled_session`: log the underlying exception before returning
None so programmatic callers aren't left with an undebuggable "could
not be created" — failures (DB lock, I/O, import) now land in errors.log
via logger.exception.
2. Drop the source-reading `TestSourceGuard` — it violated the repo's
"never read source code in tests" rule and was a change-detector
(passed even if behavior regressed). The stderr routing is already
covered by the real-path `test_missing_session_fails_on_stderr`.
3. Bare `-c` + `--create-if-missing` now prints a stderr note explaining
the flag needs a session name, instead of silently ignoring it — makes
the no-op self-evident to programmatic callers.
Tests: 6 targeted + 8 adjacent, all passing.
* chore: map contributor email for @yflmq001
* fix(acp): probe CLI for --acp support before spawning subprocess
CopilotACPClient unconditionally passes [self._acp_command] +
self._acp_args (default ['--acp', '--stdio']) to subprocess.Popen.
When the resolved CLI doesn't accept --acp (e.g. Claude Code
v2.1.233, where 'claude --acp --stdio' exits 1 with
'error: unknown option') the subprocess dies in ~250ms with the
error on stderr, but the parent ACP loop has no fast-fail for this
shape and waits the full child_timeout_seconds (default 600s,
observed 109s+ before user interruption) for stdout that never
arrives.
Add _acp_supported() that probes the CLI's --help output for the
--acp flag in ~50ms, then call it at the top of _run_prompt before
any spawn happens. When the probe fails, raise a RuntimeError that
names the unsupported flag, lists the expected fix (install
@github/copilot late 2025+, or set HERMES_COPILOT_ACP_*), and
returns control to the caller in ~280ms instead of hanging the
delegate_task parent for hundreds of seconds.
Measured locally against Claude Code v2.1.233:
- Before: delegate_task acp_command=claude hangs 109s+ then
returns tokens={input:0, output:0}.
- After: delegate_task acp_command=claude raises RuntimeError
in 280ms with a clear actionable message.
This does NOT change behavior for supported CLIs (the new
@github/copilot ships with --acp) — the probe returns True and
the spawn proceeds unchanged.
Refs the bundled claude-review-delegate skill which already
documents this class of transport-mismatch pitfall for users
who call 'claude -p' directly; this fix closes the same gap for
the delegate_task MCP path.
* fix(acp): make --acp probe tri-state, cached, and mock-safe
Salvage hardening on top of #87308 (thanks @Dudeman456):
- Tri-state verdict: inconclusive probes (binary missing, --help
failed/timed out) return None and fall through to the normal spawn
path, preserving the established 'Could not start Copilot ACP
command' error instead of masking it. This also fixes the two
test_copilot_acp_client HOME-env regressions that went red on the
PR: their mocked-Popen path was intercepted by the new unmocked
subprocess.run probe.
- Cache definitive verdicts per binary path so CLIs that DO support
--acp pay the ~50ms --help cost once per process, not per prompt.
- Skip the probe entirely when custom ACP args don't include --acp.
- Fix the help-text regex: the old pattern never matched '[--acp]'
(leading '[' is neither start-of-string nor whitespace) and \b
after 'p' matched '--acpfoo'.
- Hermeticity: stub subprocess.run in the two HOME-env tests; add 6
probe-specific tests (fast-fail, fall-through, caching, skip).
* fix(cli): bound the Windows process-scan probes so a slow WMI scan cannot wedge hermes update (#87134)
subprocess.run(capture_output=True, timeout=N) is not hang-safe on
Windows: after the timeout fires, run()'s cleanup kills the direct child
and then joins the pipe reader threads with an UNBOUNDED communicate().
A descendant (conhost.exe under wmic/powershell) holding duplicated pipe
handles keeps the pipes from EOF and the join never returns.
_scan_gateway_pids() runs its wmic / Get-CimInstance Win32_Process scans
exactly that way, and on machines where the full process scan genuinely
exceeds its 10/15s budget (cold WMI on first boot, ARM VMs, heavy
Update/AV activity) hermes update wedged forever inside
_pause_windows_gateways_for_update() before printing a single line —
observed live on a fresh Windows 11 ARM64 VM with a faulthandler stack
pinning the main thread in subprocess._communicate and only a conhost.exe
child surviving. The single-flight update lock then blocks retries until
the wedged process is killed by hand.
This is the same deadlock class bounded_git_probe already fixed for git
probes (#68609 / #66037). Generalize that proven pattern into a shared
bounded_probe_run() — explicit communicate(timeout), kill_process_tree on
failure, bounded 1s drain, then abandon the daemonic readers — and
migrate the whole call-site class onto it:
- hermes_cli/gateway.py _scan_gateway_pids (the site that hung; reached
from hermes update, cron, gateway restart/status, dashboard)
- hermes_cli/dashboard_procs.py wmic scan (same shape, reached on update)
- hermes_cli/claw.py tasklist + PowerShell probes (same shape; its
try/except cannot catch a hang because a hang raises nothing)
- bounded_git_probe now delegates to bounded_probe_run (identical
contract, one copy of the cleanup logic)
Unlike bounded_git_probe, bounded_probe_run returns the CompletedProcess
(or None) rather than collapsing to stdout, because the gateway scan
branches on returncode to trip its wmic -> powershell fallback.
Tests: tests/hermes_cli/test_bounded_probe_run.py covers success,
nonzero-exit passthrough, spawn failure, bounded timeout (fails against
the old unbounded semantics — verified by sabotage), errors= decoding,
DEVNULL stdin, POSIX process-group placement, and the bounded_git_probe
delegation contract. Existing test_git_probe_tree_kill.py passes
unchanged against the delegated implementation.
Closes #87134
* test(cli): retarget the wmic-encoding regression test at bounded_probe_run
The Windows-only test asserted encoding/errors kwargs on a mocked
subprocess.run, but the scan now routes through bounded_probe_run
(#87134), so subprocess.run is never invoked. Assert the probe call's
contract instead (errors='ignore', finite timeout), verify the parsed
PIDs, and add a fail-open case for probe failure. The test no longer
needs a Windows host once the probe is mocked, so the windows_only
gate is dropped.
* fix(agent): attribute background-review usage and add cost controls
Persist fork token usage under session_model_usage task=background_review,
emit a per-fork completion log line, and expose enabled/max_iterations/
prompt_file so operators can see and bound the automatic review cost.
Address review feedback: load auxiliary.background_review once per spawn,
classify completion logs by summarize action prefixes, treat explicit
api_call_count=None as the documented default of 1, and WARNING on the
fail-open enabled-gate path.
* fix(desktop): route registry 'local' entry to the genuinely-local runtime
ensureRegistryBackend delegated kind==='local' to ensureBackend(), which
follows the v1 connection.json routing table — under a v1 REMOTE global
mode (the migration keeps the mandatory 'local' entry AND makes that
remote the registry primary) the roster's 'This device' rows enumerated
and dialed the REMOTE primary: every profile appeared twice (forcing
-slug handles) and clicking a local agent talked to the remote box.
resolveRegistryLocalRoute() (pure, colocated with the registry helpers)
now decides the local entry's path: delegate to the legacy route only
when v1 is itself local (single-source behavior byte-identical);
otherwise spawn/reuse a forced-local pool child via spawnPoolBackend's
new forceLocal option, pooled under the composite conn:local::<profile>
key so it cannot collide with the v1 remote descriptor cached at the
bare profile key.
* fix(desktop): key fan-out event consumption by (connectionId, profile)
Secondary-gateway events were tagged with connectionId (store/gateway
fan-out) but no consumer read it: working/attention tracking, the
pruneSecondaryGateways keep-set, and the profile-scoped event gates
(skin.changed / change-watcher broadcasts / approval-mode reconcile)
all keyed by session id + bare profile name. Every registered source
exposes a 'default' profile (the roster force-unshifts it), so two
connected gateways collided — gateway B's 'default' activity was
attributed to gateway A's 'default', keeping the wrong socket alive
and applying the wrong source's config/skin/cron changes.
Thread connectionId through consumption using the existing composite
backendScopeKey helper:
- session-states records each registry-tagged event's (connectionId,
profile) scope per runtime session; liveSessionScopes() projects the
busy/needs-input ones as composite keys for the gateway keep-set.
- recomputeKeptGateways (use-gateway-boot) seeds the keep-set with
those scopes; pruneSecondaryGateways matches registry-scoped entries
ONLY on their composite key, while local entries keep matching bare
profile names (single-source path unchanged).
- gateway-event's 'from the active profile' gates now compare the
event's composite scope against the active gateway's connection via
the new activeGatewayConnectionId(); untagged local/primary events
behave byte-identically.
Display-only surfaces that already use roster handles are untouched.
* style(desktop): order @hermes/shared import before nanostores (perfectionist/sort-imports)
* fmt(js): `npm run fix` on merge (#87839)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* fmt(js): `npm run fix` on merge (#87844)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* fix(agent): trim background_review to the enabled switch
Follow-up to #87400: drop the max_iterations and prompt_file knobs from
auxiliary.background_review. The aux model routing (provider/model/
base_url/...) predates #87400 and stays; the enabled switch and the
usage telemetry stay. The fork's iteration budget returns to the
historical hardcoded 16.
* fix(update): reload _subprocess_compat and dashboard_procs after git pull
hermes update runs in the PRE-pull Python process. After git pull updates
source files on disk, modules already in sys.modules still hold the OLD
code. The existing _reload_config_modules() reloaded only config modules,
but the post-update dashboard cleanup path (_finish_dashboard_update_cleanup
-> _scan_dashboard_processes) imports hermes_cli._subprocess_compat lazily;
a new symbol added there (e.g. bounded_probe_run) is invisible to the
cached module object, causing ImportError during the cleanup step.
Extend the reload list to include hermes_cli._subprocess_compat and
hermes_cli.dashboard_procs so the cleanup uses freshly-pulled code.
* fix(update): reload process-scan modules at the dashboard-cleanup entry point
Widen PR #87757 to cover the ZIP path: _update_via_zip() also calls
_finish_dashboard_update_cleanup() but never runs _reload_config_modules,
so the Windows git-broken fallback would still crash with the same
ImportError (cannot import name 'bounded_probe_run' from the stale cached
hermes_cli._subprocess_compat).
- new _reload_process_scan_modules() called inside
_finish_dashboard_update_cleanup itself, so every current and future
call site is covered; reloads dependency-first
(_subprocess_compat, then dashboard_procs)
- reload failures log at warning (a miss surfaces seconds later as an
ImportError in the same process)
- regression tests: reload-before-kill ordering, node-failure skip,
stale-module symbol restoration (the exact #87134 boundary state),
nonfatal reload failure, and the #87757 reload-list contract
* chore: release v0.20.2 (2026.8.16)
* fix(tui): modified Enter and bare LF insert a newline in the composer across IDE and macOS terminals (#87854)
* fix(tui): send atomic CSI u for modified Enter in IDE terminals
VS Code/Cursor/Windsurf terminals bound Shift/Ctrl/Cmd+Enter to the
legacy \\r\n sequence, which Ink's parse-keypress split into a
backslash keypress plus a plain Return — inserting a stray backslash and
submitting instead of adding a newline. Emit Kitty CSI u sequences that
encode the modifier atomically, and migrate keybindings users already
have on disk.
Co-authored-by: yatesjalex <yatesjalex@users.noreply.github.com>
* fix(tui): treat a bare LF as a newline in macOS composer terminals
Terminals that can't send a distinct Shift+Enter collapse a modified
Enter / Ctrl+J down to a bare LF. shouldPreserveCtrlJNewline() already
handles the env-detectable cases (SSH, Windows Terminal, Ghostty, WSL),
but plain macOS terminals (Terminal.app, iTerm2 defaults) do the same and
aren't env-detectable, leaving no keyboard-driven newline there. Fold the
return-key decision into shouldInsertNewlineOnReturn() and accept a bare
LF as a multiline fallback on macOS too, keeping CR as submit everywhere.
Co-authored-by: LeonSGP43 <LeonSGP43@users.noreply.github.com>
---------
Co-authored-by: yatesjalex <yatesjalex@users.noreply.github.com>
Co-authored-by: LeonSGP43 <LeonSGP43@users.noreply.github.com>
* fix(state): classify structural DB corruption as its own persistence cause
'database disk image is malformed' contains the word 'disk', so
classify_persistence_error bucketed SQLITE_CORRUPT / SQLITE_NOTADB
failures as 'disk' and the turn-completion explainer told users to
free disk space for a structurally damaged state.db (the #77386-family
misdiagnosis, reproduced in the v0.20.0 malformed-DB incident report).
- hermes_state: new 'corrupt' bucket in PERSISTENCE_ERROR_CAUSES,
matched via _DB_CORRUPTION_MARKERS BEFORE the locked/disk buckets
- run_agent: explainer text for 'corrupt' points at hermes doctor and
explicitly says freeing space will not help
- cron explainer-variant suppression picks the new variant up
automatically (it iterates PERSISTENCE_ERROR_CAUSES)
* fix(openviking): strip PYTHONPATH from autostarted server child env (#78153)
(cherry picked from commit 7afd99155667cde480c0ab4ee31e242dab849d40)
* fix(openviking): read .env BOM-tolerantly when rewriting credentials
f1ea4a56c ("cover the remaining setup-time .env reads with utf-8-sig",
following 75afc47ba for mem0/hindsight) swept this class; openviking's
_write_env_vars was missed and still reads with strict utf-8.
It copies every existing line through on each update, so the read decides
whether a credential update lands:
BOM'd .env -> the first key never matches, so the old line survives and
the new value is appended as a duplicate. .env loaders keep
the first occurrence, so the update silently does nothing.
cp1252 .env -> UnicodeDecodeError aborts setup outright.
Read exactly like the canonical hermes_cli/config.py save_env_value
(utf-8-sig + errors="replace"). A plain UTF-8 file rewrites byte-identically.
Scope: hermes_cli/memory_setup.py has the same read but is already the
subject of #30281 / #60587, so it is left alone here.
(cherry picked from commit 175c6852c2c255b3219575b5de0b1b70f1f0efcb)
* fix(openviking): preserve non-UTF-8 env bytes on update
* docs(openviking): correct environment handling explanations
Clarify that the Desktop backend can add Hermes venv packages to PYTHONPATH and that current .env loaders use the last duplicate value.
* Revert "fix(agent): preserve local reasoning timeout opt-out"
This reverts commit 26b2b475935d5f5f369142fe1648cf5c95e7b056.
* Revert "fix(agent): harden canonical tool call deduplication"
This reverts commit 8fc4189edd23dde055232cc07ea14d1d525e44ee.
* fix(update): restart hermes-serve systemd units alongside gateways
hermes update discovered and restarted hermes-gateway* systemd units but
never looked for hermes-serve* — the Desktop app's backend — so it kept
running stale pre-update code until the user restarted it by hand (#83438).
Extend the systemd unit discovery/restart loop to also match hermes-serve*
units. They don't wire SIGUSR1 to a graceful drain (only gateway/run.py
does), so restart eligibility for the graceful path is now gated on unit
name via a small, directly-tested helper; hermes-serve units fall straight
to the existing blunt systemctl restart path, matching the workaround the
issue already documents.
* fix(update): tighten hermes-serve unit gate, dedupe fleet/cleanup restarts
Review on #83595 flagged two service-lifecycle gaps in the hermes-serve
restart support:
- The unit-name gate accepted anything starting with "hermes-serve",
which also matched the unrelated hermes-server.service. Require the
exact base unit or the hyphenated profile family instead.
- The fleet-restart loop and _finish_dashboard_update_cleanup() could
both restart the same hermes-serve unit — the loop restarts it
directly, then cleanup's PID scan finds the fresh process and
restarts its owning unit again. Thread the fleet loop's restarted
unit names through to _kill_stale_dashboard_processes() so it skips
units already handled.
* fix(update): tighten gateway-side unit gates to exact/hyphenated shape
Mirror the strict unit-name shape from the hermes-serve gate (review on
PR #83595) on the gateway side too: the discovery gate and the SIGUSR1
eligibility helper now accept only `hermes-gateway.service` or the
`hermes-gateway-<profile>` family, so a near-prefix unit like
`hermes-gatewayd.service` can neither enter the restart path nor be sent
a SIGUSR1 it does not handle.
* fix(desktop): ignore stale remote connection attempts
* chore: map contributor email for xkam7ar
* fix(apps): dial primary sleep/wake reconnect at window backend not active profile
* fix(desktop): scope pluginSocket's connection to the active profile
pluginSocket (hermes.ts) is documented as "the live twin of pluginRest,
scoped the same way", but it calls window.hermesDesktop.getConnection()
with no profile argument, while pluginRest passes the active profile via
profileScoped(). getConnection's IPC handler (ensureBackend in
electron/main.ts) falls back to the primary profile whenever the profile
argument is empty, so an unscoped call always resolves to the primary
profile's backend regardless of which profile is actually active.
For a plugin used from a non-primary profile (e.g. kanban), this means REST
calls go to the correct pooled backend while the plugin's WebSocket silently
connects to the wrong one — a multi-profile user sees one profile's data
with another profile's live events.
Fix (adapted to the post-#87600 registry-agent store shape during salvage):
resolve the plugin socket's connection through the same (connectionId,
profile) source of truth ensureGatewayProfile/ensureGatewayAgent maintain
for $connection — store/gateway's setActive now pushes the active scope's
registry connection id into the hermes module (setApiRequestConnection,
the no-store-import twin of setApiRequestProfile), and pluginSocket
resolves via getConnectionFor for registry-agent scopes and
getConnection(profile) for the local pool. The plugin socket therefore
follows registry-agent activations too, not just profile switches.
voice-playback.ts's resolveSpeakStreamUrl had the same gap originally, but
main has since fixed it independently (via the getApiRequestProfile()
getter rather than direct store access) — dropped from this PR as
redundant, keeping only the still-open pluginSocket gap.
Co-authored-by: Hermes Agent <hermes@nousresearch.com>
* fmt(js): `npm run fix` on merge (#87880)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* fix(image_gen): disable default-on upscaling everywhere — opt-in only
The Aug 8 default-on upscaling policy (66ea4e686) chained the Clarity
Upscaler after every sub-2MP generation. Clarity is an SD1.5 creative
tile-diffusion enhancer (creativity 0.35, "masterpiece" prompt prefix) —
it redraws content, which degraded output on 100% of generations for
models like GPT Image 2 and Ideogram whose value is precise text
rendering, CJK, and photorealistic detail.
Policy now: no model upscales by default, on FAL or Krea. The `upscale`
tool param remains as a per-call opt-in (`upscale: true`); explicit
requests still chain Clarity (FAL) / Krea Enhance as before.
- FAL catalog: all 17 default-on entries flipped to upscale=False
- Krea plugin: medium + medium-turbo per-model defaults flipped off
- Tool schema: upscale param described as opt-in with a fidelity warning
- Tests updated: catalog invariant now pins all-off; default-on cases
now assert no upscaler call
- Docs (en + zh) updated to the opt-in policy
* fix: make every tool interruptible — sequential executor abandons on user interrupt
The sequential tool path only noticed a user interrupt after the running
tool returned: with the deadline disabled it ran the tool inline (fully
blocking), and with a deadline it waited in 5s slices without ever
checking agent._interrupt_requested. Any tool without cooperative
is_interrupted() polling (image_generate, tts, transcription, skills
sync, ...) held the whole turn hostage — the reported symptom was a
redirect queued ~40s behind a FAL image generation + upscale pass.
Executor backstop (class fix, covers ALL tools):
- _run_sequential_tool_execution_middleware always dispatches on the
daemon worker (timeout None no longer means inline blocking) and polls
the interrupt flag every 1s.
- On interrupt: 3s cooperative grace (mirrors the concurrent path), then
synthesize a cancelled tool result (_ToolCancelledResult), emit the
terminal post_tool_call with status=cancelled, and abandon the worker.
- _ToolCancelledResult suppresses downstream post-hook double emission
exactly like _ToolTimeoutResult, so an abandoned worker finishing late
cannot report success for a cancelled call.
- clarify (interactive, _NEVER_PARALLEL_TOOLS) keeps the inline path —
it owns its own human wait.
Cooperative layer in the reported offender:
- image_generation_tool: blind handler.get() (generation + Clarity
upscale) replaced with _wait_fal_result(), which polls is_interrupted()
in 0.5s slices and raises ImageGenerationInterrupted immediately.
- _upscale_image propagates the interrupt instead of swallowing it into
the "upscale failed, use original" fallback.
Message alternation is preserved: the cancelled result is a normal tool
result for the call_id. Sabotage-verified: with the old wait loop
restored, the new tests fail (tool blocks full runtime); with the fix
they pass in ~4s.
* feat(computer-use): support Cua Driver 0.20 runtime contracts
* fix(computer-use): reconcile existing cua-driver installs
* fix(computer-use): enforce existing-profile grant, unblock the opt-in
Live-testing the Cua Driver 0.20 convergence on Windows 11 (session 2,
cua-driver 0.20.0) surfaced three defects in the existing-profile browser
path and in install status.
1. The config grant was silently nullified by an approval bypass.
`--yolo` / `-z` map onto a private unrestricted daemon, which answers every
browser_prepare. Because the host delegated the entire existing-profile
decision to the driver, that bypass also nullified
`computer_use.grant_existing_profile: false`: a plain `hermes -z` attached
to the user's real Chrome profile and read live page content over CDP, with
the driver reporting it as "the approved existing Chromium profile". It was
never approved.
An approval bypass is consent to skip prompts, not consent to read an
existing profile's pages, cookies, and storage. CuaTypedBrowserRoute.prepare
now enforces the key itself, regardless of permission mode. bounded stays
exempt - its reviewed capability manifest is the authorization boundary.
The authorization inputs are resolved in the backend from config and the
backend's immutable mode, never from model-supplied kwargs.
2. The grant, once set, still could not b…
vashkartik
added a commit
to vashkartik/hermes-agent
that referenced
this pull request
Aug 17, 2026
* fix(desktop): don't cancel the running turn on Esc while an overlay is open
The composer's global Esc-to-cancel listener (useComposerEscCancel) fires
whenever the turn is busy and the active composer matches — but overlays
(Settings, Command Center, agents, cron, …) cover the chat while the
composer stays mounted and 'active' beneath them, so pressing Esc on any
of those pages interrupted the session the user wasn't even looking at.
OverlayView's own escape-layer Esc-to-close fired too, but the stream was
already dead.
Stand Esc down with composerFocusBlockedBySurface() — the same signal the
type-to-focus path uses (BLOCKING_OVERLAY includes OverlayView's
[data-overlay-surface] marker). Esc on an overlay now closes the overlay
via its escape layer instead of canceling the stream beneath it.
Fixes #82618
* fix(caching): engage prompt caching for LiteLLM Claude on the OpenAI wire
anthropic_prompt_cache_policy() only granted Anthropic cache_control
markers to LiteLLM over the native Anthropic wire
(api_mode == "anthropic_messages"). A LiteLLM deployment exposing the
OpenAI-compatible surface instead (/v1/chat/completions, /v1/messages
-> 404) matched no grant branch and fell through to (False, False): no
cache_control injected, the system prompt sent as a plain string, and
the provider serving zero cache hits -- the entire prompt re-billed at
full price on every turn. Silent: no error, no warning, usage simply
shows 100% uncached input forever.
Add one branch after the is_anthropic_wire/is_claude case that grants
caching to Claude-family models on a LiteLLM endpoint regardless of
wire, with the native inner-block layout. Same failure class already
documented in-function for Qwen/DashScope.
Design:
- Gated on the Claude family only (is_claude); a Gemini/GPT/Qwen route
through the same proxy must not receive markers (they may reject the
cache_control block format -- cf. the DeepSeek/OpenCode exclusion).
- Matches on provider string OR base_url host, since provider naming
varies per install (litellm, custom:litellm, or a bare custom alias
pointed at a LiteLLM host).
- prompt_caching.cache_ttl: false still wins (the _cache_disabled early
return is untouched).
- Generic strict OpenAI-wire custom providers (e.g. Fireworks) remain
excluded -- verified by the existing over-reach regression test.
Tests: adds TestLiteLLMOpenAIWire covering the grant (several model
spellings x provider/host signals), no-over-reach (non-Claude on the
same proxy get nothing; operator disable wins), and adjacent behavior
(LiteLLM in Anthropic proxy mode still native layout). Full module:
43 passed.
Closes #84506. Original diagnosis, patch design, and measurements by
@ottosulin.
* fix(caching): use the envelope layout for LiteLLM Claude on the OpenAI wire
Follow-up to the salvaged LiteLLM cache grant. The grant itself is right;
four things about how it was scoped were not.
1. Layout. The branch returned the native inner-block layout
(use_native_layout=True) on api_mode == "chat_completions". That layout
writes a TOP-LEVEL msg["cache_control"] on role:tool and empty-content
messages and depends on the Anthropic adapter to relocate it into the
block — but that adapter only runs for api_mode == "anthropic_messages"
(agent/transports/anthropic.py registers there), and the
chat_completions transport does no relocation. Measured on a 3-tool-turn
transcript: 2 of the 4 available breakpoints landed on markers the
provider never sees. Worse, when LiteLLM itself relocates a top-level
marker for an OpenRouter-backed Claude route
(OpenrouterConfig._move_cache_control_to_content), the marker lands on
an empty assistant turn and produces a cache_control-marked empty text
block — the HTTP 400 "text content blocks must contain" shape already
guarded in agent/anthropic_adapter.py (#69512). Switched to the envelope
layout, matching every other OpenAI-wire grant in this function:
4 of 4 breakpoints honored, zero empty blocks.
2. Host matching. `"litellm" in base_url_hostname(...)` is the substring
false-positive class base_url_hostname's own docstring warns against; it
granted Anthropic markers to notlitellm.example.com,
foolitellmbar.example and friends. Replaced with a label-token match in
a named helper, so "litellm" must be a whole dot- or hyphen-delimited
token. All three of the original test hosts still match; a "litellm"
path segment on an unrelated host still does not.
3. Transport gate. `not is_anthropic_wire` also swept in codex_responses,
bedrock_converse and codex_app_server. Gated on
api_mode == "chat_completions" explicitly.
4. Operator override. The grant is inferred from a provider/host name, but
the custom-provider capability lookup was gated on is_anthropic_wire, so
an explicit `prompt_caching: false` for the route+model was honored on
/v1/messages and silently ignored on /v1/chat/completions. The lookup
now also runs for a LiteLLM route, and its layout follows the transport
rather than the declaration (an explicit `true` must not promote a
chat_completions request to the native layout).
Tests: 64 passed. Adds the wire-shape contract the original matrix was
missing (asserts no breakpoint sits on the message envelope, rather than
only checking the returned tuple), plus lookalike-host, other-transport,
and both operator-override directions. All five guards mutation-checked —
reverting each fix turns the corresponding test red.
* fix(caching): match the litellm provider id token-wise too
Self-review follow-up. The previous commit fixed substring matching on the
HOST but left the provider-id side as a bare substring, so a user-named
provider like `custom:notlitellm` or `mylitellmthing` still matched and was
handed Anthropic markers — the same bug class, half-fixed.
Both signals now match `litellm` as a whole delimited token via a shared
helper. Real spellings (`litellm`, `custom:litellm`, `litellm-router`, and
the already-lowercased `LiteLLM`) still match; lookalikes no longer do.
Tests: 71 passed. Adds lookalike-provider and real-spelling guards; both
new guards mutation-checked. Differential matrix over 2688 configs vs
origin/main: 60 changes, every one a Claude model on a genuine LiteLLM
route getting the envelope layout, zero pre-existing routes altered.
* perf(caching): narrow the widened capability lookup to the LiteLLM grant
Self-review follow-up, caught by benchmarking the previous commit.
Widening the custom-provider capability-lookup gate to `is_anthropic_wire or
_is_litellm_route(...)` made EVERY chat_completions route with a litellm-ish
provider/host enter the lookup, including non-Claude models that the grant
branch below can never match. Measured on a route with no config.yaml
(the uncached worst case) that was ~7.5us -> ~1528us per evaluation.
Narrowed the gate to the exact condition the LiteLLM branch grants on
(chat_completions + Claude + litellm route), computed once into a local and
reused by the branch itself so the predicate no longer runs twice.
Measured with a realistic config.yaml present (mtime cache warm), vs
origin/main:
live-agent policy 20.6us -> 61.7us
destination planning 219.3us -> 347.7us
Sub-millisecond and scoped to the routes that actually opted in. The
earlier 1.5ms figures were a tempdir artifact: load_config_readonly's
mtime cache cannot engage when no config.yaml exists, which is never true
of a real install. Non-LiteLLM and non-Claude routes are unaffected
(openrouter Claude measured flat at ~7.9us).
Tests: 82 passed across the policy and TTL-propagation modules.
* test(caching): pin signal precedence and the openrouter-host opt-out
Review follow-up. Three coverage gaps in the LiteLLM matrix:
- The operator opt-out on a litellm-named provider pointed at an OpenRouter
host. That route previously took the OpenRouter branch and ignored an
explicit per-model `prompt_caching: false`; it is the only cell in the
differential matrix where the salvage REMOVES caching, so pin it as
intended rather than leaving it to be read as a regression.
- Signal precedence: an explicitly litellm-named provider grants even on a
lookalike host, because the provider id is an independent signal and only
the host-derived signal is token-gated. Intentional, now documented.
- A hyphen-delimited host label (`my-litellm-gw.internal.example.com`),
which the token matcher handles but nothing exercised.
Traded the redundant `claude-3-7-sonnet` parametrize cell for the new host
case, so the matrix covers more shapes with the same cell count.
Tests: 83 passed. All three production fixes re-mutation-checked against
the final stack.
* fix(web_server): discover root user plugins under profile-scoped processes
When the backend is spawned profile-scoped (`--profile <name>` sets
HERMES_HOME=<root>/profiles/<name>), _discover_dashboard_plugins()
scanned only get_process_hermes_home()/plugins — the profile directory,
which has no plugins/ content. Pooled per-profile backends therefore
discovered zero user plugins, mounted no plugin API routes, and every
plugin REST call fell through to the SPA catch-all 404.
Also scan get_default_hermes_root()/plugins (which unwraps
<root>/profiles/<name> to <root> and leaves a custom HERMES_HOME
untouched when it is itself the root), matching how hermes_cli.plugins
resolves install locations. The profile home is scanned first, so a
profile-local plugin of the same name stays authoritative via the
existing seen_names dedupe.
Adds regression tests for root-plugin discovery under a profile-scoped
process and for profile-over-root precedence.
Fixes #87197 (plugin discovery half — the misleading /api/* catch-all
half is addressed separately in #87270).
* fix(telegram): keep /loop and synthetic sends in the active DM topic
Fixes #87051
* fix(desktop): show failed status for timed-out subagents in fallback stream path
Fixes #87200
* fix(desktop): match custom provider aliases in model catalog menu
Fixes #87035
* chore(contributors): map emails for P2-sweep salvage wave
* fix(desktop): avoid PowerShell parent marker boot gate
* fix(desktop): give Windows start-marker PowerShell probe a 30s budget
PowerShell 5.1 cold starts take 2.4-8s on affected Windows hosts, so the
shared 3s execText timeout hard-failed the parent start-marker probe for
any PID that still needs the PowerShell path (e.g. backend children).
Make execText's timeout overridable and raise the marker probe to 30s.
Fixes #87169
* perf(desktop): hydrate transcripts with a small tail page + on-demand older-page backfill
Replace the fixed 500-message REST hydration (getLatestSessionMessages)
with a 120-row newest-first tail page. When the page comes back full, a
new per-session tail store records "possibly truncated + next offset";
"Show earlier" — once the DOM budget and the in-memory store window are
both exhausted — fetches the next older page via the new
getOlderSessionMessages helper (order latest + offset, matching the
backend's back-from-newest paging semantics) and prepends it to the
session store, deduped by durable row id and race-guarded against
session switches. Legacy backends without pagination metadata fall back
to the one-shot full transcript and retire the action.
Tail-page refreshes (background sync, post-turn rehydrate, re-activate,
cold-resume prefetch) graft the refreshed tail onto any backfilled
prefix instead of clobbering it, preserving reference identity on
no-ops. includeCompacted stays on every read — compaction-archived rows
remain part of the durable display history.
* feat(desktop): MCP fleet cost/usage overlay with schema token estimates and 30-day usage
Each configured server row on the MCP Capabilities page now shows what it
costs and whether it earns its keep:
- ~per-call token estimate of the server's tool schemas, summed over ENABLED
tools only (ceil(schema_chars/4) via the existing include/exclude filter)
- 30-day usage count from getUsageAnalytics(30), cached per scope profile
like the Toolsets tab's toolCallsCache, mapped to servers via the
mcp__<server>__<tool> registry-name convention (tools/mcp_tool.py)
- a subtle muted "unused" pill on enabled, probed-ok servers with nonzero
schema cost and zero 30-day uses — never a dialog
Backend: the /api/mcp/servers/{name}/test probe now fills an additive
per-tool `schema_chars` (length of the SAME converted registry schema the
agent registers). Older backends omit it → renderer shows counts only;
older renderers ignore the extra key. Display-only: nothing changes what
schemas are sent to models, no config knobs.
i18n keys (costTokens/usage30d/unusedPill) added to types/en/zh/zh-hant/ja
(ar inherits en via defineLocale overrides). Pure math lives in
lib/mcp-cost.ts with unit tests; Python wire shape pinned in
tests/hermes_cli/test_web_server_profile_unification.py.
* fix(tui): map lineage edit ordinals past compression prefix
Desktop/TUI count full displayed lineage after compression, but
prompt.submit validated truncate ordinals against tip-only history.
Translate via display_history_prefix and recover stale 4018s on Desktop.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(desktop): hoist GatewayMock type into #82462 edit recovery suite
CI typecheck failed because GatewayMock lived only in the previous describe.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(desktop): hermes:// deep link to install MCP servers with explicit confirmation
Adds hermes://mcp/install?name=NAME&config=B64 (base64url or standard
base64 JSON), mirroring Cursor's mcp/install deep link, so vendors and
docs can offer an "Add to Hermes" button.
- Electron: the existing generic hermes:// handler already forwards
{kind, name, params}; only its comment is updated (no new handler).
- Renderer: use-desktop-integrations routes kind=mcp/name=install into
a pending-install store; a new confirmation dialog shows the server
name and the FULL pretty-printed config (attacker-controllable input),
with a prominent caution for stdio command entries. Nothing is written
until the user confirms; existing names require a rename or cancel.
On confirm the server is merged over a fresh fetch of the current map
via saveMcpServers, then navigation lands on /skills?tab=mcp&server=…
so useDeepLinkHighlight focuses the new row.
- Validation: name ^[A-Za-z0-9._-]{1,64}$; config must decode to an
object with a string http(s) `url` or a string `command` (never both);
payloads over 32KB rejected; failures surface as a toast.
- Pure parser in src/lib/mcp-deeplink.ts with unit tests (url shape,
command shape, bad base64, non-object, javascript: URL, oversized).
- i18n keys in types + en/zh/zh-hant/ja/ar.
- Docs: "Add to Hermes link" section in the MCP config reference.
* fmt(js): `npm run fix` on merge (#87599)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* feat(desktop): background MCP health checks with re-auth nudges
MCP server problems (expired OAuth tokens especially) were only
discovered when the user visited the MCP page and a probe ran. Now a
renderer-side background checker (store/mcp-health.ts) sweeps the
active profile's enabled HTTP/SSE MCP servers on gateway connect and
every 30 minutes, and fires an in-app notification with a "Sign in"
action ("<name> MCP needs re-authentication") that navigates to the
MCP page with ?server=<name> so useDeepLinkHighlight focuses the
server and its Authenticate button. Navigation only — OAuth flows are
never auto-launched.
stdio servers are deliberately excluded: probing a stdio server SPAWNS
a local process, so a background timer must never touch them. Only
url-shaped servers (where OAuth expiry lives) are swept, sequentially.
The tab's probeCache/serverFingerprint/probeKey/NEEDS_AUTH_RE moved to
a shared lib/mcp-probe-cache.ts (behavior identical) so the page and
the checker share one probe cache and its 5-minute TTL — neither
surface re-probes what the other just learned.
Notifications fire only on a TRANSITION into needs-auth/error (pure
state machine, unit-tested), hard-capped at one per server per app
session, keyed per profile. Profile switches drop pending timers and
re-arm for the new profile; sweeps never run while the gateway is
disconnected. No new config knobs. i18n keys added across
en/zh/zh-hant/ja/ar + types.
* fmt(js): `npm run fix` on merge (#87607)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* fix(desktop): scope messaging to active remote profile
Complete the sidebar profile-scope contract across remote Electron routing, older-backend fallbacks, standalone messaging refreshes, and pagination. Reject stale profile responses and keep the explicit all-profiles view unified.
Co-authored-by: 墨綠BG <s5460703@gmail.com>
Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
* fix(desktop): retain messaging totals per profile
Key resolved platform totals by Desktop profile and source so profile switches neither inherit another profile's count nor discard a count that was already resolved. Keep the full reset for connection configuration changes.
Co-authored-by: frendo <frendo.wu@gmail.com>
* fix(desktop): ignore stale messaging page responses
Sequence per-profile platform pagination so an older overlapping response cannot replace a newer, larger page.
* docs(desktop): document profile scope helpers
Add JSDoc to the exported helpers introduced by the profile-scoped sidebar change.
* fix(desktop): reject stale profile refreshes
* fix(desktop): harden profile-scoped refreshes
* fix(desktop): resolve sessions from sidebar caches
Consult messaging and cron caches before the by-id fallback so opening a sidebar row neither depends on a redundant network lookup nor duplicates it into regular recents.
Co-authored-by: protas-box <protas.box@icloud.com>
* fix(tools-config): stop reconfigure flow clobbering image_gen.use_gateway on managed FAL rows
The Nous Subscription image_gen row carries imagegen_backend="fal", so
_reconfigure_provider's post-model-picker step ran
img_cfg["use_gateway"] = False
unconditionally at two sites, immediately after the managed branch had
written use_gateway=True. A user who picked Nous Subscription and then
re-entered `hermes tools` to change the model was silently flipped onto
their personal FAL_KEY.
Same bug class as fe63353cb, which fixed the plugin-provider selector
but missed these two legacy-backend sites in the reconfigure flow. Both
now write bool(managed_feature), matching the existing correct site in
_configure_provider.
Adds regression tests driven through the real TOOL_CATEGORIES managed
row; sabotage-verified (tests fail with the old behavior restored).
* feat(desktop): make the multi-gateway Connections registry discoverable
The multi-connection registry (Settings -> Connections) shipped with no
entry point outside the settings nav, and the product-owner report was
blunt: 'I didn't see any obvious way to hook up multiple gateways.'
- Profile rail: a plug pill pinned beside Manage ('Connect another
Hermes gateway...') deep-links to /settings?tab=connections. Always
visible, including for single-profile first-run users.
- Command palette: Settings -> Connections is now a searchable entry
(keywords: add gateway, remote, ssh, cloud, instances, registry).
- i18n: profiles.connectGateway added to en/types/zh; other locales
fall back through defineLocale.
- Tests: profile-rail-connect.test.tsx covers the deep link and the
single-profile visibility guarantee.
* docs: full multi-gateway setup guide for Hermes Desktop
Expand user-guide/multi-connection-desktop.md into a complete setup
walkthrough: where to find the pane (settings nav, profile-rail plug,
command palette), the exact add-connection editor fields (Name,
Gateway URL, Authentication: Session token/OAuth, SSH host), Primary /
This device pills, Test semantics, agent roster + profile-rail
switching and per-profile session/cron/messaging scoping, token
storage via Electron safeStorage with the keyring-less Linux plain-
text opt-in, and troubleshooting. All quoted labels match the desktop
i18n strings. Cross-link the rail entry point from desktop.md.
* fix(desktop): never resolve a missing named gateway scope to the primary
activeGateway() fell back to the primary gateway when the active key named
a registry-agent scope (conn:<id>::<profile>) whose secondaries entry had
been evicted — e.g. closeSecondaryGateways() during a soft gateway switch —
so sends and session ops silently executed against the WRONG machine.
A named scope now resolves to its own socket or null, and every eviction
path (closeSecondaryGateways, pruneSecondaryGateways) explicitly restores
the primary as active when it evicts the active scope, keeping the
'activeKey always resolves' invariant with the atoms following.
* fix(desktop): sync connection atoms and share the switch mutex for agent activation
ensureGatewayForAgent (the SDK ensureAgent door) skipped the two invariants
the profile path provides:
- $connection / $activeGatewayProfile were only updated when a socket was
freshly dialed (setConnection inside openSecondary), so activating an
ALREADY-OPEN registry agent left both describing the previous backend —
/api/fs, /api/media and image.attach routed to the wrong machine (same
class as #46651) and newSessionInProfile targeted the stale profile.
- Activations bypassed the gatewaySwitch mutex, so a rapid agent/profile
interleave could complete out of order with the earlier setActive()
landing last.
Add profile.ts ensureGatewayAgent: the (connectionId, profile) analogue of
ensureGatewayProfile that shares the same gatewaySwitch mutex, moves
$activeGatewayProfile on every activation, and resyncs $connection from
getConnectionFor (best-effort, like the profile path). The SDK ensureAgent
now routes through it; local/null connectionId falls through to the
profile path unchanged.
* feat(desktop): expose busy turn flags on plugin SDK
Plugins can now read host.state.busy and host.state.awaitingResponse
for the focused chat. These follow the same session slice the chat pane
uses, so a draft falls back to the global flags and a background turn
does not leak.
* fix(desktop): make plugin SDK turn flags follow the focused chat
Follow-up to the salvaged #87558 commit: the PR's docs promised the flags
follow "the focused chat", but PRIMARY_SESSION_VIEW is the primary
workspace tab only — a focused session TILE would read the wrong chat.
Wire host.state.busy / host.state.awaitingResponse through the focused
slice ($focusedStoredSessionId / $focusedSessionState), same semantics
as the statusbar busy pulse, with the primary view (and its draft
fallback) while the workspace holds focus.
Adds a tile-focus vitest case and corrects the docs wording.
* feat(desktop): paste-anything MCP server import
Add a compact Import popover to the MCP Capabilities page that accepts
anything a user might copy from an MCP server README and infers the
server config:
- mcp.json snippets (mcpServers-wrapped, bare name->config maps, single
unnamed server objects, Cursor/Claude `type` normalized to `transport`)
- bare npx/bunx/uvx/node/docker command lines (name inferred from the
package basename, e.g. server-filesystem -> filesystem)
- `claude mcp add NAME [--transport http|sse] [-e K=V] [-H ...] [--] CMD
ARGS...` and `claude mcp add NAME URL`
- bare http(s) URLs (name inferred from the hostname)
- Cursor deeplinks (cursor://anysphere.cursor-deeplink/mcp/install with
a base64-encoded JSON config payload)
The parser is a pure module (src/lib/mcp-import.ts) with unit tests for
every format plus garbage input. The popover previews the inferred
name + config and, on confirm, merges the entries into the editor draft
exactly like addServer's starter entry: unique keys, dirty (unsaved)
draft, first new block focused. Placeholder env values (YOUR_KEY,
TOKEN_HERE, ...) are kept verbatim for the user to edit in the editor
before saving.
i18n keys added under settings.mcp for en, zh, zh-hant, ja (ar falls
back through defineLocale).
* feat(desktop): running is not busy
Gate composer submit and plugin host busy on the target session slice, not a leftover foreground busyRef. Staff can keep typing while a worker session is running.
Includes the follow-up test that submit uses the target session busy flag.
* fix(desktop): lint and map contributor email for running-is-not-busy
Drop the redundant Boolean() on selected in $primaryBusy and add the
professorpalmer9@gmail.com mapping so attribution CI can resolve the PR.
* feat(desktop-sdk): expose focused-session state atoms to plugins
Disk plugins read app state exclusively through host.state, which only
exposed the primary workspace tab ($activeSessionId). In the multi-tile
layout, clicking a tile never touches that atom — and tile focus is a
pure renderer concern, invisible to both gateway RPC and the event
stream — so a plugin cannot follow the session the user is actually
looking at.
The core statusbar solves this same problem by reading the focused-
session atoms (use-statusbar-items.tsx). Widen the generic plugin
surface with the same signals, per the contribution rubric:
- host.state.focusedSessionId — runtime id of the focused session
(interacted tile, else the primary), the key for session.* RPC
- host.state.focusedStoredSessionId — durable id for navigation and
session-list matching
- host.state.focusedUsage — live streamed UsageStats projection
(context_used/max/percent, tokens, cost_usd), no RPC needed
Additive only; no existing behavior changes. tsc --noEmit clean.
Verified end-to-end with a disk plugin that now tracks the focused
session across tiles.
* test(desktop-sdk): contract-test the focused-session host.state atoms
Locks the plugin-facing contract: the focused atoms exist as readonly
nanostores, mirror the primary session while no tile is focused, project
the focused session's usage, and — the behavior this PR exists for —
follow the interacted tile while the primary-only $activeSessionId
stays put.
* fix(desktop-sdk): type focusedUsage as Partial<UsageStats>, fix expect arity
ClientSessionState.usage is Partial<UsageStats> (app/types.ts) — the
backend streams whichever fields changed — so the computed produces
ReadableAtom<Partial<UsageStats> | null>. Annotate the entry honestly
instead of claiming full UsageStats, and document the fallback rule for
plugin authors. Also collapse the three-argument expect() calls in the
contract test (vitest takes one message arg). Addresses triage review on
PR #80461.
* fix(desktop-sdk): address adversarial review — type honesty, real tile coverage, docs
Independent second-pass review found three gaps:
- focusedUsage is null | UsageStats, not Partial — ClientSessionState.usage
is the full type (app/types.ts) and its only write site seeds the four
required fields before merging (gateway-event.ts). The earlier Partial
annotation traced the wrong type (SessionRuntimeInfo, an RPC payload).
Comment now names the genuinely optional fields instead.
- The tile-focus contract test never seeded $sessionTiles/$sessionStates,
so it proved focusedStoredSessionId follows a tile but could not
distinguish focusedSessionId/focusedUsage working from broken. Seed a
bound runtime with distinct usage and assert both readout atoms move.
- The two public host.state references (website docs + bundled skill
reference) enumerated the old six atoms; plugin authors would never
discover the new ones. Both lists updated.
tsc/eslint/vitest green (4/4).
* chore: map contributor email for focused-session atoms salvage
* fix(desktop): exclude process-less descriptors from backend pool LRU cap
Remote/cloud registry descriptors (entry.process === null) shared the
POOL_MAX_BACKENDS cap with real spawned local backends, so a roster
refresh across N registered remote connections could LRU-evict a live
local backend idle past the keepalive window. Cap accounting and
cap-driven eviction now count only entries with a live child process;
descriptors remain subject to the idle reaper.
* fix(desktop): tear down renderer secondaries when a registry connection is removed
Removing a connection stopped its pooled backends and ssh tunnels but
never told the renderer: for remote/cloud sources there is no local
process to die, so the removed connection's WebSocket stayed open and
kept streaming ghost events into the UI until page reload. If the
socket did drop, openSecondary -> getConnectionFor threw 'No connection
with id' and scheduleReconnect retried forever (backoff caps at 15s,
entry never evicted).
- main now broadcasts 'hermes:connections:changed' on removal (and on
material edits); preload exposes connections.onChanged.
- use-gateway-boot subscribes and calls the new
disposeSecondariesForConnection(), which disposes + evicts every
secondary scoped to the connection id (redialing on edits).
- reconnectSecondary fail-stops: when the Electron main reports the
connection no longer exists, the entry is disposed and evicted
instead of retrying forever; ordinary transport errors keep the
existing backoff behavior.
* fix(desktop): recycle live backends and sockets when a connection edit changes its target
saveRegistryConnection only rewrote the registry file: editing a
connection's URL/token/host left pooled backend descriptors under
'conn:<id>::*' and open renderer sockets pointing at the OLD endpoint —
the UI showed the new target while traffic kept flowing to the old one
until idle-reap.
When a save MATERIALLY changes an existing connection (endpoint / auth /
ssh routing fields, via the new connectionDialFieldsChanged helper),
main now stops that connection's pooled backends and tunnels
(stopRegistryConnectionBackends, same teardown as removal) and
broadcasts 'hermes:connections:changed' with reason 'updated' so
renderers dispose and re-dial their secondaries at the new target.
Label-only renames do not recycle.
* fix(cli): restore Kitty keyboard protocol push and complete the extended-key alias table
Commit 4c34eeb416 fixed dead Ctrl+C by removing the Kitty protocol push
(CSI >1u) from _EXTENDED_ENTER_KEYS_SEQ, keeping only modifyOtherKeys
level 2. That regressed kitty-the-terminal completely: kitty removed
xterm modifyOtherKeys support (kovidgoyal/kitty#4075) and only speaks
its own protocol, so after the removal kitty users lost Shift+Enter and
every other extended key — the CSI >4;2m we still pushed is a no-op
there (kitty even logs a PARSE ERROR for it).
The original reason for removing the push is obsolete: #87511 mapped
CSI-u control sequences, so Ctrl+C as ESC[99;5u now parses to
Keys.ControlC and fires the existing c-c binding. (The kernel-INTR
concern in that commit was moot — prompt_toolkit's raw mode clears
ISIG, so Ctrl+C is always handled by the binding, never the kernel.)
Restore the dual push (CSI >1u + CSI >4;2m), exactly mirroring the Ink
TUI, and complete the alias table for what the kitty disambiguate flag
actually emits — #87511 left real gaps, some of which its PR body
wrongly claimed were covered:
- Esc key: ESC[27u (+ modifiers) — previously leaked '[27u' as text
- Ctrl+Backspace -> backward-kill-word (#78285 was closed on the wrong
claim that codepoint-127 mapping existed; it did not)
- Shift+Space -> space (#86866's second symptom; the Ctrl+Space
mapping never covered modifier 2)
- Alt+Enter -> newline tuple; Shift+Tab -> BackTab; Ctrl+Tab -> Tab;
Alt/Shift+Backspace
- Multi-modifier letters (Shift+Alt 4, Ctrl+Shift 6, Ctrl+Alt 7,
Ctrl+Alt+Shift 8) normalized onto their Ctrl/Escape-prefix targets,
both unshifted (kitty) and shifted (mok emitters) codepoints
- Kitty PUA functional keys: keypad -> non-keypad equivalents,
F13-F24, and Ignore for lock/media/modifier-event keys so they are
consumed instead of leaking (kitty emits these even in legacy mode)
Also: clear the VT100 parser's prefix cache after installing (stale
answers could misparse), and re-push extended keys after
_recover_terminal_input_modes' reset — the recovery previously popped
both modes mid-session and never re-enabled them, silently killing
Shift+Enter until restart.
Refs #87511, #87074, #56684, #56645, #78285, #86866, #87390.
* fix(cron): process .pth files for Windows uv-venv script jobs (#86567)
_windows_cron_python_invocation bypasses the uv venv launcher (to avoid
flashing a console window) and re-attaches the venv via PYTHONPATH — but
PYTHONPATH entries are plain sys.path additions and never get .pth
processing, so editable installs (pip install -e) were invisible to cron
script jobs (ModuleNotFoundError).
Bootstrap the script with site.addsitedir() on the venv site-packages,
then exec it as __main__ via runpy.run_path, preserving the script
directory on sys.path (python script.py semantics). Falls back to a
plain invocation when the venv layout is unresolvable.
* fix(cron): log and document the .pth bootstrap fallback (#86816 review)
- WARN when the venv site-packages layout is unresolvable and the script
falls back to plain PYTHONPATH execution, so 'editable installs
invisible' failures are diagnosable.
- Docstring: note that runpy does not set __package__/__spec__ the way a
direct python script.py invocation does.
* fix(update): surface config mutations applied silently during version-bump-only updates
* fix: honor JSON-array string forms for skills.disabled and agent.disabled_toolsets
`hermes config set` and JSON-mode editor saves store lists as quoted
strings (e.g. '["skill-a","skill-b"]' or "['memory']"). Both disable
filters treated such a string as a single name, so curated disable
lists silently filtered nothing with zero diagnostics.
Add parse_config_string_list() in agent.skill_utils and use it in
_normalize_string_set (skills.disabled / platform_disabled) and at
every agent.disabled_toolsets read site: tools_config resolve +
reconcile, CLI, gateway agent construction (both sites), cron
scheduler, and prompt_size. A scalar string still names a single
entry (#13026); malformed JSON falls back to the single-name
behavior instead of raising.
Fixes #86661
* fix(desktop-update): put --daemonized ahead of ORIGINAL_ARGS in posix.sh re-exec
Appending --daemonized after ORIGINAL_ARGS put it past the `--`
relaunch-args separator on Linux, so it was absorbed into
RELAUNCH_ARGS instead of being parsed as a flag. HANDOFF_DAEMONIZED
never got set, so the one-shot self-detach block re-fired on every
re-exec -- an unbounded self-exec loop (thousands of iterations/sec,
100%+ CPU, argv growing until execve fails with E2BIG) whenever
relaunch args were present, which is the normal invocation shape on
Linux.
Fixes #86957
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(cli): deliver the Bedrock API key through a named provider
The Bedrock API-key flow stored the bearer token in OPENAI_API_KEY and set
a bare `provider: custom`. Since #28660 that variable is only honoured for
openai.com hosts, so for bedrock-mantle.*.api.aws the token was dropped and
requests went out with api_key="no-key-required", a 401 on every call.
Write a named `providers.bedrock-mantle` entry with
key_env: AWS_BEARER_TOKEN_BEDROCK instead. The named-provider branch in
runtime_provider.py resolves key_env; the bare-custom branch cannot.
Fixes authentication only. Per-model mantle route selection is separate.
* fix(gateway): improve Windows detach diagnostics
* refactor(gateway): share breakaway marker constant
* fix(tui): settle session close against active turns
* fix(tui): stop queued dispatch after session close
* fix(cron): reject NUL-bearing script paths before any Path call (#76762 class)
_run_job_script wrapped only expanduser() in its ingestion try/except.
On Linux an unexpandable NUL-bearing value raises inside that call, so it
landed on the clean fail-with-report path; on Windows expanduser() never
expands '~user' (and so never raises), and the NUL surfaces later as an
uncaught ValueError from resolve()/exists() — crashing the scheduler.
Align with cron.lifecycle_guard._expand_candidate_path, which already
documents this as the whole-class fix (the per-syscall catching produced
#76762, #77703, #77780, #78256): reject '\\x00' eagerly at the ingestion
boundary so both platforms fail identically.
* test(cron): pin the eager NUL rejection contract for _run_job_script (#86829)
* fix(cron): coerce script_path to str in the NUL guard so it can never crash (#86829, review #86832)
"\x00" in script_path raises TypeError when a caller passes a non-str
(e.g. a pathlib.Path, which is not iterable) — the guard itself would
crash the scheduler. All current call sites pass plain str, but the
guard must be crash-proof: str() first, then check. Adds a regression
test running a real script through _run_job_script with a Path argument,
which fails with TypeError on the pre-fix guard.
* docs(agents): record multiplex profile-scoped env fail-closed rule (#86905)
Lesson from the feishu DM multiplex investigation: under multiplex,
os.environ holds the default profile's values, so any profile-level env
config (credentials AND authorization) must be read scope-aware, and a
scoped miss with a scope installed must fail closed instead of borrowing
from os.environ. The _get_scoped_secret wrapper is copy-pasted across
~15 platform adapters — new adapters and edits to existing ones must
keep the fail-closed semantics.
* fix(auth): resolve provider auto-detection keys through the profile scope (#86917)
resolve_provider's auto path read provider API keys with bare
os.getenv — under multiplex a secondary profile's keys live only in its
secret scope, so auto-detection found nothing and every secondary
profile with model.provider: auto failed with 'No LLM provider
configured' at agent init (reproduced on a live 7-profile gateway).
Route both env-key reads (the OPENAI/OPENROUTER tier and the
PROVIDER_REGISTRY loop) through _scoped_key_env, the scope-aware helper
auxiliary_client already uses: secret scope wins under multiplex,
UnscopedSecretError falls back to os.environ (default-profile/CLI
paths unchanged). Same bug class as #86905.
Verified in a gateway-accurate simulation (hermes_home_override +
profile scope): resolve_provider('auto') now returns the secondary
profile's own provider (deepseek) instead of erroring.
* test(auth): cover profile-scoped key resolution in resolve_provider (#86917)
Three regression tests: scoped DEEPSEEK_API_KEY is visible to auto
detection under multiplex (the #86917 failure); unscoped paths keep the
os.environ read; explicit config provider still wins.
* fix(auth): only fall back to os.getenv on ImportError in resolve_provider (#86918 review)
The previous except Exception silently fell back to os.getenv if the
_scoped_key_env import ever failed — under multiplex that is exactly the
fail-open this PR removes (secondary profiles would regress to 'No LLM
provider configured' with zero trace). Catch only ImportError, log a
WARNING naming the consequence, and let any other failure propagate.
Also replaces the lambda fallback with a named nested function.
* test(gitlock): pin the git-process guard in sweep tests (deflake slice 8)
The stale-lock removal tests asserted the sweep result while leaving
_git_proc_running() live: on CI the parallel per-file runner almost
always has a real git subprocess in flight, pgrep -x git hits, and
clear_stale_git_locks correctly refuses to sweep — failing the tests
for reasons unrelated to the code under test (surfaced on PR #86918,
which doesn't touch gitlock at all).
Monkeypatch the guard to False in the sweep tests and add an explicit
test pinning the guard's block-while-git-running behavior.
* fix(gateway): complete /loop ticks after streamed already_sent turns
Streamed replies return None so the adapter does not send twice.
The /loop hook then saw empty text and never ran, so
awaiting_response stayed true and later ticks never fired.
Stash the delivered text on the event and use it for the post-turn
hooks. /goal uses the same path.
Tests: tests/gateway/test_loop_command.py
* fix(cli): chat -c fails loudly on stderr and gains --create-if-missing
`hermes chat -c "<title>" -q "<text>"` silently no-oped when no session
matched the title under quiet/programmatic use: the not-found message was
written to stdout (the channel quiet callers parse as the final response),
so a background send to a not-yet-existing named session vanished with no
error. Surfaces via Hermes-Bot-Mode bot-to-bot handoffs (#86794).
- not-found message now goes to stderr (exit 1 unchanged), so programmatic
callers always see it even with -Q/--quiet
- new --create-if-missing: with `-c <title>` and no matching session, create
a fresh session carrying the title and proceed — the deterministic
"send to this named thread, making it if needed" primitive plugins asked for
- extract the -c resolution block into _resolve_continue_arg for testability
Tests: flag parsing, titled-session creation, stderr routing, source guard.
* fix(cli): address review feedback on chat -c fail-loudly PR
Response to AI review (Enough1122) on #86812:
1. `_create_titled_session`: log the underlying exception before returning
None so programmatic callers aren't left with an undebuggable "could
not be created" — failures (DB lock, I/O, import) now land in errors.log
via logger.exception.
2. Drop the source-reading `TestSourceGuard` — it violated the repo's
"never read source code in tests" rule and was a change-detector
(passed even if behavior regressed). The stderr routing is already
covered by the real-path `test_missing_session_fails_on_stderr`.
3. Bare `-c` + `--create-if-missing` now prints a stderr note explaining
the flag needs a session name, instead of silently ignoring it — makes
the no-op self-evident to programmatic callers.
Tests: 6 targeted + 8 adjacent, all passing.
* chore: map contributor email for @yflmq001
* fix(acp): probe CLI for --acp support before spawning subprocess
CopilotACPClient unconditionally passes [self._acp_command] +
self._acp_args (default ['--acp', '--stdio']) to subprocess.Popen.
When the resolved CLI doesn't accept --acp (e.g. Claude Code
v2.1.233, where 'claude --acp --stdio' exits 1 with
'error: unknown option') the subprocess dies in ~250ms with the
error on stderr, but the parent ACP loop has no fast-fail for this
shape and waits the full child_timeout_seconds (default 600s,
observed 109s+ before user interruption) for stdout that never
arrives.
Add _acp_supported() that probes the CLI's --help output for the
--acp flag in ~50ms, then call it at the top of _run_prompt before
any spawn happens. When the probe fails, raise a RuntimeError that
names the unsupported flag, lists the expected fix (install
@github/copilot late 2025+, or set HERMES_COPILOT_ACP_*), and
returns control to the caller in ~280ms instead of hanging the
delegate_task parent for hundreds of seconds.
Measured locally against Claude Code v2.1.233:
- Before: delegate_task acp_command=claude hangs 109s+ then
returns tokens={input:0, output:0}.
- After: delegate_task acp_command=claude raises RuntimeError
in 280ms with a clear actionable message.
This does NOT change behavior for supported CLIs (the new
@github/copilot ships with --acp) — the probe returns True and
the spawn proceeds unchanged.
Refs the bundled claude-review-delegate skill which already
documents this class of transport-mismatch pitfall for users
who call 'claude -p' directly; this fix closes the same gap for
the delegate_task MCP path.
* fix(acp): make --acp probe tri-state, cached, and mock-safe
Salvage hardening on top of #87308 (thanks @Dudeman456):
- Tri-state verdict: inconclusive probes (binary missing, --help
failed/timed out) return None and fall through to the normal spawn
path, preserving the established 'Could not start Copilot ACP
command' error instead of masking it. This also fixes the two
test_copilot_acp_client HOME-env regressions that went red on the
PR: their mocked-Popen path was intercepted by the new unmocked
subprocess.run probe.
- Cache definitive verdicts per binary path so CLIs that DO support
--acp pay the ~50ms --help cost once per process, not per prompt.
- Skip the probe entirely when custom ACP args don't include --acp.
- Fix the help-text regex: the old pattern never matched '[--acp]'
(leading '[' is neither start-of-string nor whitespace) and \b
after 'p' matched '--acpfoo'.
- Hermeticity: stub subprocess.run in the two HOME-env tests; add 6
probe-specific tests (fast-fail, fall-through, caching, skip).
* fix(cli): bound the Windows process-scan probes so a slow WMI scan cannot wedge hermes update (#87134)
subprocess.run(capture_output=True, timeout=N) is not hang-safe on
Windows: after the timeout fires, run()'s cleanup kills the direct child
and then joins the pipe reader threads with an UNBOUNDED communicate().
A descendant (conhost.exe under wmic/powershell) holding duplicated pipe
handles keeps the pipes from EOF and the join never returns.
_scan_gateway_pids() runs its wmic / Get-CimInstance Win32_Process scans
exactly that way, and on machines where the full process scan genuinely
exceeds its 10/15s budget (cold WMI on first boot, ARM VMs, heavy
Update/AV activity) hermes update wedged forever inside
_pause_windows_gateways_for_update() before printing a single line —
observed live on a fresh Windows 11 ARM64 VM with a faulthandler stack
pinning the main thread in subprocess._communicate and only a conhost.exe
child surviving. The single-flight update lock then blocks retries until
the wedged process is killed by hand.
This is the same deadlock class bounded_git_probe already fixed for git
probes (#68609 / #66037). Generalize that proven pattern into a shared
bounded_probe_run() — explicit communicate(timeout), kill_process_tree on
failure, bounded 1s drain, then abandon the daemonic readers — and
migrate the whole call-site class onto it:
- hermes_cli/gateway.py _scan_gateway_pids (the site that hung; reached
from hermes update, cron, gateway restart/status, dashboard)
- hermes_cli/dashboard_procs.py wmic scan (same shape, reached on update)
- hermes_cli/claw.py tasklist + PowerShell probes (same shape; its
try/except cannot catch a hang because a hang raises nothing)
- bounded_git_probe now delegates to bounded_probe_run (identical
contract, one copy of the cleanup logic)
Unlike bounded_git_probe, bounded_probe_run returns the CompletedProcess
(or None) rather than collapsing to stdout, because the gateway scan
branches on returncode to trip its wmic -> powershell fallback.
Tests: tests/hermes_cli/test_bounded_probe_run.py covers success,
nonzero-exit passthrough, spawn failure, bounded timeout (fails against
the old unbounded semantics — verified by sabotage), errors= decoding,
DEVNULL stdin, POSIX process-group placement, and the bounded_git_probe
delegation contract. Existing test_git_probe_tree_kill.py passes
unchanged against the delegated implementation.
Closes #87134
* test(cli): retarget the wmic-encoding regression test at bounded_probe_run
The Windows-only test asserted encoding/errors kwargs on a mocked
subprocess.run, but the scan now routes through bounded_probe_run
(#87134), so subprocess.run is never invoked. Assert the probe call's
contract instead (errors='ignore', finite timeout), verify the parsed
PIDs, and add a fail-open case for probe failure. The test no longer
needs a Windows host once the probe is mocked, so the windows_only
gate is dropped.
* fix(agent): attribute background-review usage and add cost controls
Persist fork token usage under session_model_usage task=background_review,
emit a per-fork completion log line, and expose enabled/max_iterations/
prompt_file so operators can see and bound the automatic review cost.
Address review feedback: load auxiliary.background_review once per spawn,
classify completion logs by summarize action prefixes, treat explicit
api_call_count=None as the documented default of 1, and WARNING on the
fail-open enabled-gate path.
* fix(desktop): route registry 'local' entry to the genuinely-local runtime
ensureRegistryBackend delegated kind==='local' to ensureBackend(), which
follows the v1 connection.json routing table — under a v1 REMOTE global
mode (the migration keeps the mandatory 'local' entry AND makes that
remote the registry primary) the roster's 'This device' rows enumerated
and dialed the REMOTE primary: every profile appeared twice (forcing
-slug handles) and clicking a local agent talked to the remote box.
resolveRegistryLocalRoute() (pure, colocated with the registry helpers)
now decides the local entry's path: delegate to the legacy route only
when v1 is itself local (single-source behavior byte-identical);
otherwise spawn/reuse a forced-local pool child via spawnPoolBackend's
new forceLocal option, pooled under the composite conn:local::<profile>
key so it cannot collide with the v1 remote descriptor cached at the
bare profile key.
* fix(desktop): key fan-out event consumption by (connectionId, profile)
Secondary-gateway events were tagged with connectionId (store/gateway
fan-out) but no consumer read it: working/attention tracking, the
pruneSecondaryGateways keep-set, and the profile-scoped event gates
(skin.changed / change-watcher broadcasts / approval-mode reconcile)
all keyed by session id + bare profile name. Every registered source
exposes a 'default' profile (the roster force-unshifts it), so two
connected gateways collided — gateway B's 'default' activity was
attributed to gateway A's 'default', keeping the wrong socket alive
and applying the wrong source's config/skin/cron changes.
Thread connectionId through consumption using the existing composite
backendScopeKey helper:
- session-states records each registry-tagged event's (connectionId,
profile) scope per runtime session; liveSessionScopes() projects the
busy/needs-input ones as composite keys for the gateway keep-set.
- recomputeKeptGateways (use-gateway-boot) seeds the keep-set with
those scopes; pruneSecondaryGateways matches registry-scoped entries
ONLY on their composite key, while local entries keep matching bare
profile names (single-source path unchanged).
- gateway-event's 'from the active profile' gates now compare the
event's composite scope against the active gateway's connection via
the new activeGatewayConnectionId(); untagged local/primary events
behave byte-identically.
Display-only surfaces that already use roster handles are untouched.
* style(desktop): order @hermes/shared import before nanostores (perfectionist/sort-imports)
* fmt(js): `npm run fix` on merge (#87839)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* fmt(js): `npm run fix` on merge (#87844)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* fix(agent): trim background_review to the enabled switch
Follow-up to #87400: drop the max_iterations and prompt_file knobs from
auxiliary.background_review. The aux model routing (provider/model/
base_url/...) predates #87400 and stays; the enabled switch and the
usage telemetry stay. The fork's iteration budget returns to the
historical hardcoded 16.
* fix(update): reload _subprocess_compat and dashboard_procs after git pull
hermes update runs in the PRE-pull Python process. After git pull updates
source files on disk, modules already in sys.modules still hold the OLD
code. The existing _reload_config_modules() reloaded only config modules,
but the post-update dashboard cleanup path (_finish_dashboard_update_cleanup
-> _scan_dashboard_processes) imports hermes_cli._subprocess_compat lazily;
a new symbol added there (e.g. bounded_probe_run) is invisible to the
cached module object, causing ImportError during the cleanup step.
Extend the reload list to include hermes_cli._subprocess_compat and
hermes_cli.dashboard_procs so the cleanup uses freshly-pulled code.
* fix(update): reload process-scan modules at the dashboard-cleanup entry point
Widen PR #87757 to cover the ZIP path: _update_via_zip() also calls
_finish_dashboard_update_cleanup() but never runs _reload_config_modules,
so the Windows git-broken fallback would still crash with the same
ImportError (cannot import name 'bounded_probe_run' from the stale cached
hermes_cli._subprocess_compat).
- new _reload_process_scan_modules() called inside
_finish_dashboard_update_cleanup itself, so every current and future
call site is covered; reloads dependency-first
(_subprocess_compat, then dashboard_procs)
- reload failures log at warning (a miss surfaces seconds later as an
ImportError in the same process)
- regression tests: reload-before-kill ordering, node-failure skip,
stale-module symbol restoration (the exact #87134 boundary state),
nonfatal reload failure, and the #87757 reload-list contract
* chore: release v0.20.2 (2026.8.16)
* fix(tui): modified Enter and bare LF insert a newline in the composer across IDE and macOS terminals (#87854)
* fix(tui): send atomic CSI u for modified Enter in IDE terminals
VS Code/Cursor/Windsurf terminals bound Shift/Ctrl/Cmd+Enter to the
legacy \\r\n sequence, which Ink's parse-keypress split into a
backslash keypress plus a plain Return — inserting a stray backslash and
submitting instead of adding a newline. Emit Kitty CSI u sequences that
encode the modifier atomically, and migrate keybindings users already
have on disk.
Co-authored-by: yatesjalex <yatesjalex@users.noreply.github.com>
* fix(tui): treat a bare LF as a newline in macOS composer terminals
Terminals that can't send a distinct Shift+Enter collapse a modified
Enter / Ctrl+J down to a bare LF. shouldPreserveCtrlJNewline() already
handles the env-detectable cases (SSH, Windows Terminal, Ghostty, WSL),
but plain macOS terminals (Terminal.app, iTerm2 defaults) do the same and
aren't env-detectable, leaving no keyboard-driven newline there. Fold the
return-key decision into shouldInsertNewlineOnReturn() and accept a bare
LF as a multiline fallback on macOS too, keeping CR as submit everywhere.
Co-authored-by: LeonSGP43 <LeonSGP43@users.noreply.github.com>
---------
Co-authored-by: yatesjalex <yatesjalex@users.noreply.github.com>
Co-authored-by: LeonSGP43 <LeonSGP43@users.noreply.github.com>
* fix(state): classify structural DB corruption as its own persistence cause
'database disk image is malformed' contains the word 'disk', so
classify_persistence_error bucketed SQLITE_CORRUPT / SQLITE_NOTADB
failures as 'disk' and the turn-completion explainer told users to
free disk space for a structurally damaged state.db (the #77386-family
misdiagnosis, reproduced in the v0.20.0 malformed-DB incident report).
- hermes_state: new 'corrupt' bucket in PERSISTENCE_ERROR_CAUSES,
matched via _DB_CORRUPTION_MARKERS BEFORE the locked/disk buckets
- run_agent: explainer text for 'corrupt' points at hermes doctor and
explicitly says freeing space will not help
- cron explainer-variant suppression picks the new variant up
automatically (it iterates PERSISTENCE_ERROR_CAUSES)
* fix(openviking): strip PYTHONPATH from autostarted server child env (#78153)
(cherry picked from commit 7afd99155667cde480c0ab4ee31e242dab849d40)
* fix(openviking): read .env BOM-tolerantly when rewriting credentials
f1ea4a56c ("cover the remaining setup-time .env reads with utf-8-sig",
following 75afc47ba for mem0/hindsight) swept this class; openviking's
_write_env_vars was missed and still reads with strict utf-8.
It copies every existing line through on each update, so the read decides
whether a credential update lands:
BOM'd .env -> the first key never matches, so the old line survives and
the new value is appended as a duplicate. .env loaders keep
the first occurrence, so the update silently does nothing.
cp1252 .env -> UnicodeDecodeError aborts setup outright.
Read exactly like the canonical hermes_cli/config.py save_env_value
(utf-8-sig + errors="replace"). A plain UTF-8 file rewrites byte-identically.
Scope: hermes_cli/memory_setup.py has the same read but is already the
subject of #30281 / #60587, so it is left alone here.
(cherry picked from commit 175c6852c2c255b3219575b5de0b1b70f1f0efcb)
* fix(openviking): preserve non-UTF-8 env bytes on update
* docs(openviking): correct environment handling explanations
Clarify that the Desktop backend can add Hermes venv packages to PYTHONPATH and that current .env loaders use the last duplicate value.
* Revert "fix(agent): preserve local reasoning timeout opt-out"
This reverts commit 26b2b475935d5f5f369142fe1648cf5c95e7b056.
* Revert "fix(agent): harden canonical tool call deduplication"
This reverts commit 8fc4189edd23dde055232cc07ea14d1d525e44ee.
* fix(update): restart hermes-serve systemd units alongside gateways
hermes update discovered and restarted hermes-gateway* systemd units but
never looked for hermes-serve* — the Desktop app's backend — so it kept
running stale pre-update code until the user restarted it by hand (#83438).
Extend the systemd unit discovery/restart loop to also match hermes-serve*
units. They don't wire SIGUSR1 to a graceful drain (only gateway/run.py
does), so restart eligibility for the graceful path is now gated on unit
name via a small, directly-tested helper; hermes-serve units fall straight
to the existing blunt systemctl restart path, matching the workaround the
issue already documents.
* fix(update): tighten hermes-serve unit gate, dedupe fleet/cleanup restarts
Review on #83595 flagged two service-lifecycle gaps in the hermes-serve
restart support:
- The unit-name gate accepted anything starting with "hermes-serve",
which also matched the unrelated hermes-server.service. Require the
exact base unit or the hyphenated profile family instead.
- The fleet-restart loop and _finish_dashboard_update_cleanup() could
both restart the same hermes-serve unit — the loop restarts it
directly, then cleanup's PID scan finds the fresh process and
restarts its owning unit again. Thread the fleet loop's restarted
unit names through to _kill_stale_dashboard_processes() so it skips
units already handled.
* fix(update): tighten gateway-side unit gates to exact/hyphenated shape
Mirror the strict unit-name shape from the hermes-serve gate (review on
PR #83595) on the gateway side too: the discovery gate and the SIGUSR1
eligibility helper now accept only `hermes-gateway.service` or the
`hermes-gateway-<profile>` family, so a near-prefix unit like
`hermes-gatewayd.service` can neither enter the restart path nor be sent
a SIGUSR1 it does not handle.
* fix(desktop): ignore stale remote connection attempts
* chore: map contributor email for xkam7ar
* fix(apps): dial primary sleep/wake reconnect at window backend not active profile
* fix(desktop): scope pluginSocket's connection to the active profile
pluginSocket (hermes.ts) is documented as "the live twin of pluginRest,
scoped the same way", but it calls window.hermesDesktop.getConnection()
with no profile argument, while pluginRest passes the active profile via
profileScoped(). getConnection's IPC handler (ensureBackend in
electron/main.ts) falls back to the primary profile whenever the profile
argument is empty, so an unscoped call always resolves to the primary
profile's backend regardless of which profile is actually active.
For a plugin used from a non-primary profile (e.g. kanban), this means REST
calls go to the correct pooled backend while the plugin's WebSocket silently
connects to the wrong one — a multi-profile user sees one profile's data
with another profile's live events.
Fix (adapted to the post-#87600 registry-agent store shape during salvage):
resolve the plugin socket's connection through the same (connectionId,
profile) source of truth ensureGatewayProfile/ensureGatewayAgent maintain
for $connection — store/gateway's setActive now pushes the active scope's
registry connection id into the hermes module (setApiRequestConnection,
the no-store-import twin of setApiRequestProfile), and pluginSocket
resolves via getConnectionFor for registry-agent scopes and
getConnection(profile) for the local pool. The plugin socket therefore
follows registry-agent activations too, not just profile switches.
voice-playback.ts's resolveSpeakStreamUrl had the same gap originally, but
main has since fixed it independently (via the getApiRequestProfile()
getter rather than direct store access) — dropped from this PR as
redundant, keeping only the still-open pluginSocket gap.
Co-authored-by: Hermes Agent <hermes@nousresearch.com>
* fmt(js): `npm run fix` on merge (#87880)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* fix(image_gen): disable default-on upscaling everywhere — opt-in only
The Aug 8 default-on upscaling policy (66ea4e686) chained the Clarity
Upscaler after every sub-2MP generation. Clarity is an SD1.5 creative
tile-diffusion enhancer (creativity 0.35, "masterpiece" prompt prefix) —
it redraws content, which degraded output on 100% of generations for
models like GPT Image 2 and Ideogram whose value is precise text
rendering, CJK, and photorealistic detail.
Policy now: no model upscales by default, on FAL or Krea. The `upscale`
tool param remains as a per-call opt-in (`upscale: true`); explicit
requests still chain Clarity (FAL) / Krea Enhance as before.
- FAL catalog: all 17 default-on entries flipped to upscale=False
- Krea plugin: medium + medium-turbo per-model defaults flipped off
- Tool schema: upscale param described as opt-in with a fidelity warning
- Tests updated: catalog invariant now pins all-off; default-on cases
now assert no upscaler call
- Docs (en + zh) updated to the opt-in policy
* fix: make every tool interruptible — sequential executor abandons on user interrupt
The sequential tool path only noticed a user interrupt after the running
tool returned: with the deadline disabled it ran the tool inline (fully
blocking), and with a deadline it waited in 5s slices without ever
checking agent._interrupt_requested. Any tool without cooperative
is_interrupted() polling (image_generate, tts, transcription, skills
sync, ...) held the whole turn hostage — the reported symptom was a
redirect queued ~40s behind a FAL image generation + upscale pass.
Executor backstop (class fix, covers ALL tools):
- _run_sequential_tool_execution_middleware always dispatches on the
daemon worker (timeout None no longer means inline blocking) and polls
the interrupt flag every 1s.
- On interrupt: 3s cooperative grace (mirrors the concurrent path), then
synthesize a cancelled tool result (_ToolCancelledResult), emit the
terminal post_tool_call with status=cancelled, and abandon the worker.
- _ToolCancelledResult suppresses downstream post-hook double emission
exactly like _ToolTimeoutResult, so an abandoned worker finishing late
cannot report success for a cancelled call.
- clarify (interactive, _NEVER_PARALLEL_TOOLS) keeps the inline path —
it owns its own human wait.
Cooperative layer in the reported offender:
- image_generation_tool: blind handler.get() (generation + Clarity
upscale) replaced with _wait_fal_result(), which polls is_interrupted()
in 0.5s slices and raises ImageGenerationInterrupted immediately.
- _upscale_image propagates the interrupt instead of swallowing it into
the "upscale failed, use original" fallback.
Message alternation is preserved: the cancelled result is a normal tool
result for the call_id. Sabotage-verified: with the old wait loop
restored, the new tests fail (tool blocks full runtime); with the fix
they pass in ~4s.
* feat(computer-use): support Cua Driver 0.20 runtime contracts
* fix(computer-use): reconcile existing cua-driver installs
* fix(computer-use): enforce existing-profile grant, unblock the opt-in
Live-testing the Cua Driver 0.20 convergence on Windows 11 (session 2,
cua-driver 0.20.0) surfaced three defects in the existing-profile browser
path and in install status.
1. The config grant was silently nullified by an approval bypass.
`--yolo` / `-z` map onto a private unrestricted daemon, which answers every
browser_prepare. Because the host delegated the entire existing-profile
decision to the driver, that bypass also nullified
`computer_use.grant_existing_profile: false`: a plain `hermes -z` attached
to the user's real Chrome profile and read live page content over CDP, with
the driver reporting it as "the approved existing Chromium profile". It was
never approved.
An approval bypass is consent to skip prompts, not consent to read an
existing profile's pages, cookies, and storage. CuaTypedBrowserRoute.prepare
now enforces the key itself, regardless of permission mode. bounded stays
exempt - its reviewed capability manifest is the authorization boundary.
The authorization inputs are resolved in the backend from config and the
backend's immutable mode, never from model-supplied kwargs.
2. The grant, once set, still could not be used.
With `grant_existing_profile: true` the runtime is launched
`--grant existing-profile` correctly…
vashkartik
added a commit
to vashkartik/hermes-agent
that referenced
this pull request
Aug 17, 2026
* fix(web_server): discover root user plugins under profile-scoped processes
When the backend is spawned profile-scoped (`--profile <name>` sets
HERMES_HOME=<root>/profiles/<name>), _discover_dashboard_plugins()
scanned only get_process_hermes_home()/plugins — the profile directory,
which has no plugins/ content. Pooled per-profile backends therefore
discovered zero user plugins, mounted no plugin API routes, and every
plugin REST call fell through to the SPA catch-all 404.
Also scan get_default_hermes_root()/plugins (which unwraps
<root>/profiles/<name> to <root> and leaves a custom HERMES_HOME
untouched when it is itself the root), matching how hermes_cli.plugins
resolves install locations. The profile home is scanned first, so a
profile-local plugin of the same name stays authoritative via the
existing seen_names dedupe.
Adds regression tests for root-plugin discovery under a profile-scoped
process and for profile-over-root precedence.
Fixes #87197 (plugin discovery half — the misleading /api/* catch-all
half is addressed separately in #87270).
* fix(telegram): keep /loop and synthetic sends in the active DM topic
Fixes #87051
* fix(desktop): show failed status for timed-out subagents in fallback stream path
Fixes #87200
* fix(desktop): match custom provider aliases in model catalog menu
Fixes #87035
* chore(contributors): map emails for P2-sweep salvage wave
* fix(desktop): avoid PowerShell parent marker boot gate
* fix(desktop): give Windows start-marker PowerShell probe a 30s budget
PowerShell 5.1 cold starts take 2.4-8s on affected Windows hosts, so the
shared 3s execText timeout hard-failed the parent start-marker probe for
any PID that still needs the PowerShell path (e.g. backend children).
Make execText's timeout overridable and raise the marker probe to 30s.
Fixes #87169
* perf(desktop): hydrate transcripts with a small tail page + on-demand older-page backfill
Replace the fixed 500-message REST hydration (getLatestSessionMessages)
with a 120-row newest-first tail page. When the page comes back full, a
new per-session tail store records "possibly truncated + next offset";
"Show earlier" — once the DOM budget and the in-memory store window are
both exhausted — fetches the next older page via the new
getOlderSessionMessages helper (order latest + offset, matching the
backend's back-from-newest paging semantics) and prepends it to the
session store, deduped by durable row id and race-guarded against
session switches. Legacy backends without pagination metadata fall back
to the one-shot full transcript and retire the action.
Tail-page refreshes (background sync, post-turn rehydrate, re-activate,
cold-resume prefetch) graft the refreshed tail onto any backfilled
prefix instead of clobbering it, preserving reference identity on
no-ops. includeCompacted stays on every read — compaction-archived rows
remain part of the durable display history.
* feat(desktop): MCP fleet cost/usage overlay with schema token estimates and 30-day usage
Each configured server row on the MCP Capabilities page now shows what it
costs and whether it earns its keep:
- ~per-call token estimate of the server's tool schemas, summed over ENABLED
tools only (ceil(schema_chars/4) via the existing include/exclude filter)
- 30-day usage count from getUsageAnalytics(30), cached per scope profile
like the Toolsets tab's toolCallsCache, mapped to servers via the
mcp__<server>__<tool> registry-name convention (tools/mcp_tool.py)
- a subtle muted "unused" pill on enabled, probed-ok servers with nonzero
schema cost and zero 30-day uses — never a dialog
Backend: the /api/mcp/servers/{name}/test probe now fills an additive
per-tool `schema_chars` (length of the SAME converted registry schema the
agent registers). Older backends omit it → renderer shows counts only;
older renderers ignore the extra key. Display-only: nothing changes what
schemas are sent to models, no config knobs.
i18n keys (costTokens/usage30d/unusedPill) added to types/en/zh/zh-hant/ja
(ar inherits en via defineLocale overrides). Pure math lives in
lib/mcp-cost.ts with unit tests; Python wire shape pinned in
tests/hermes_cli/test_web_server_profile_unification.py.
* fix(tui): map lineage edit ordinals past compression prefix
Desktop/TUI count full displayed lineage after compression, but
prompt.submit validated truncate ordinals against tip-only history.
Translate via display_history_prefix and recover stale 4018s on Desktop.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(desktop): hoist GatewayMock type into #82462 edit recovery suite
CI typecheck failed because GatewayMock lived only in the previous describe.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(desktop): hermes:// deep link to install MCP servers with explicit confirmation
Adds hermes://mcp/install?name=NAME&config=B64 (base64url or standard
base64 JSON), mirroring Cursor's mcp/install deep link, so vendors and
docs can offer an "Add to Hermes" button.
- Electron: the existing generic hermes:// handler already forwards
{kind, name, params}; only its comment is updated (no new handler).
- Renderer: use-desktop-integrations routes kind=mcp/name=install into
a pending-install store; a new confirmation dialog shows the server
name and the FULL pretty-printed config (attacker-controllable input),
with a prominent caution for stdio command entries. Nothing is written
until the user confirms; existing names require a rename or cancel.
On confirm the server is merged over a fresh fetch of the current map
via saveMcpServers, then navigation lands on /skills?tab=mcp&server=…
so useDeepLinkHighlight focuses the new row.
- Validation: name ^[A-Za-z0-9._-]{1,64}$; config must decode to an
object with a string http(s) `url` or a string `command` (never both);
payloads over 32KB rejected; failures surface as a toast.
- Pure parser in src/lib/mcp-deeplink.ts with unit tests (url shape,
command shape, bad base64, non-object, javascript: URL, oversized).
- i18n keys in types + en/zh/zh-hant/ja/ar.
- Docs: "Add to Hermes link" section in the MCP config reference.
* fmt(js): `npm run fix` on merge (#87599)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* feat(desktop): background MCP health checks with re-auth nudges
MCP server problems (expired OAuth tokens especially) were only
discovered when the user visited the MCP page and a probe ran. Now a
renderer-side background checker (store/mcp-health.ts) sweeps the
active profile's enabled HTTP/SSE MCP servers on gateway connect and
every 30 minutes, and fires an in-app notification with a "Sign in"
action ("<name> MCP needs re-authentication") that navigates to the
MCP page with ?server=<name> so useDeepLinkHighlight focuses the
server and its Authenticate button. Navigation only — OAuth flows are
never auto-launched.
stdio servers are deliberately excluded: probing a stdio server SPAWNS
a local process, so a background timer must never touch them. Only
url-shaped servers (where OAuth expiry lives) are swept, sequentially.
The tab's probeCache/serverFingerprint/probeKey/NEEDS_AUTH_RE moved to
a shared lib/mcp-probe-cache.ts (behavior identical) so the page and
the checker share one probe cache and its 5-minute TTL — neither
surface re-probes what the other just learned.
Notifications fire only on a TRANSITION into needs-auth/error (pure
state machine, unit-tested), hard-capped at one per server per app
session, keyed per profile. Profile switches drop pending timers and
re-arm for the new profile; sweeps never run while the gateway is
disconnected. No new config knobs. i18n keys added across
en/zh/zh-hant/ja/ar + types.
* fmt(js): `npm run fix` on merge (#87607)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* fix(desktop): scope messaging to active remote profile
Complete the sidebar profile-scope contract across remote Electron routing, older-backend fallbacks, standalone messaging refreshes, and pagination. Reject stale profile responses and keep the explicit all-profiles view unified.
Co-authored-by: 墨綠BG <s5460703@gmail.com>
Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
* fix(desktop): retain messaging totals per profile
Key resolved platform totals by Desktop profile and source so profile switches neither inherit another profile's count nor discard a count that was already resolved. Keep the full reset for connection configuration changes.
Co-authored-by: frendo <frendo.wu@gmail.com>
* fix(desktop): ignore stale messaging page responses
Sequence per-profile platform pagination so an older overlapping response cannot replace a newer, larger page.
* docs(desktop): document profile scope helpers
Add JSDoc to the exported helpers introduced by the profile-scoped sidebar change.
* fix(desktop): reject stale profile refreshes
* fix(desktop): harden profile-scoped refreshes
* fix(desktop): resolve sessions from sidebar caches
Consult messaging and cron caches before the by-id fallback so opening a sidebar row neither depends on a redundant network lookup nor duplicates it into regular recents.
Co-authored-by: protas-box <protas.box@icloud.com>
* fix(tools-config): stop reconfigure flow clobbering image_gen.use_gateway on managed FAL rows
The Nous Subscription image_gen row carries imagegen_backend="fal", so
_reconfigure_provider's post-model-picker step ran
img_cfg["use_gateway"] = False
unconditionally at two sites, immediately after the managed branch had
written use_gateway=True. A user who picked Nous Subscription and then
re-entered `hermes tools` to change the model was silently flipped onto
their personal FAL_KEY.
Same bug class as fe63353cb, which fixed the plugin-provider selector
but missed these two legacy-backend sites in the reconfigure flow. Both
now write bool(managed_feature), matching the existing correct site in
_configure_provider.
Adds regression tests driven through the real TOOL_CATEGORIES managed
row; sabotage-verified (tests fail with the old behavior restored).
* feat(desktop): make the multi-gateway Connections registry discoverable
The multi-connection registry (Settings -> Connections) shipped with no
entry point outside the settings nav, and the product-owner report was
blunt: 'I didn't see any obvious way to hook up multiple gateways.'
- Profile rail: a plug pill pinned beside Manage ('Connect another
Hermes gateway...') deep-links to /settings?tab=connections. Always
visible, including for single-profile first-run users.
- Command palette: Settings -> Connections is now a searchable entry
(keywords: add gateway, remote, ssh, cloud, instances, registry).
- i18n: profiles.connectGateway added to en/types/zh; other locales
fall back through defineLocale.
- Tests: profile-rail-connect.test.tsx covers the deep link and the
single-profile visibility guarantee.
* docs: full multi-gateway setup guide for Hermes Desktop
Expand user-guide/multi-connection-desktop.md into a complete setup
walkthrough: where to find the pane (settings nav, profile-rail plug,
command palette), the exact add-connection editor fields (Name,
Gateway URL, Authentication: Session token/OAuth, SSH host), Primary /
This device pills, Test semantics, agent roster + profile-rail
switching and per-profile session/cron/messaging scoping, token
storage via Electron safeStorage with the keyring-less Linux plain-
text opt-in, and troubleshooting. All quoted labels match the desktop
i18n strings. Cross-link the rail entry point from desktop.md.
* fix(desktop): never resolve a missing named gateway scope to the primary
activeGateway() fell back to the primary gateway when the active key named
a registry-agent scope (conn:<id>::<profile>) whose secondaries entry had
been evicted — e.g. closeSecondaryGateways() during a soft gateway switch —
so sends and session ops silently executed against the WRONG machine.
A named scope now resolves to its own socket or null, and every eviction
path (closeSecondaryGateways, pruneSecondaryGateways) explicitly restores
the primary as active when it evicts the active scope, keeping the
'activeKey always resolves' invariant with the atoms following.
* fix(desktop): sync connection atoms and share the switch mutex for agent activation
ensureGatewayForAgent (the SDK ensureAgent door) skipped the two invariants
the profile path provides:
- $connection / $activeGatewayProfile were only updated when a socket was
freshly dialed (setConnection inside openSecondary), so activating an
ALREADY-OPEN registry agent left both describing the previous backend —
/api/fs, /api/media and image.attach routed to the wrong machine (same
class as #46651) and newSessionInProfile targeted the stale profile.
- Activations bypassed the gatewaySwitch mutex, so a rapid agent/profile
interleave could complete out of order with the earlier setActive()
landing last.
Add profile.ts ensureGatewayAgent: the (connectionId, profile) analogue of
ensureGatewayProfile that shares the same gatewaySwitch mutex, moves
$activeGatewayProfile on every activation, and resyncs $connection from
getConnectionFor (best-effort, like the profile path). The SDK ensureAgent
now routes through it; local/null connectionId falls through to the
profile path unchanged.
* feat(desktop): expose busy turn flags on plugin SDK
Plugins can now read host.state.busy and host.state.awaitingResponse
for the focused chat. These follow the same session slice the chat pane
uses, so a draft falls back to the global flags and a background turn
does not leak.
* fix(desktop): make plugin SDK turn flags follow the focused chat
Follow-up to the salvaged #87558 commit: the PR's docs promised the flags
follow "the focused chat", but PRIMARY_SESSION_VIEW is the primary
workspace tab only — a focused session TILE would read the wrong chat.
Wire host.state.busy / host.state.awaitingResponse through the focused
slice ($focusedStoredSessionId / $focusedSessionState), same semantics
as the statusbar busy pulse, with the primary view (and its draft
fallback) while the workspace holds focus.
Adds a tile-focus vitest case and corrects the docs wording.
* feat(desktop): paste-anything MCP server import
Add a compact Import popover to the MCP Capabilities page that accepts
anything a user might copy from an MCP server README and infers the
server config:
- mcp.json snippets (mcpServers-wrapped, bare name->config maps, single
unnamed server objects, Cursor/Claude `type` normalized to `transport`)
- bare npx/bunx/uvx/node/docker command lines (name inferred from the
package basename, e.g. server-filesystem -> filesystem)
- `claude mcp add NAME [--transport http|sse] [-e K=V] [-H ...] [--] CMD
ARGS...` and `claude mcp add NAME URL`
- bare http(s) URLs (name inferred from the hostname)
- Cursor deeplinks (cursor://anysphere.cursor-deeplink/mcp/install with
a base64-encoded JSON config payload)
The parser is a pure module (src/lib/mcp-import.ts) with unit tests for
every format plus garbage input. The popover previews the inferred
name + config and, on confirm, merges the entries into the editor draft
exactly like addServer's starter entry: unique keys, dirty (unsaved)
draft, first new block focused. Placeholder env values (YOUR_KEY,
TOKEN_HERE, ...) are kept verbatim for the user to edit in the editor
before saving.
i18n keys added under settings.mcp for en, zh, zh-hant, ja (ar falls
back through defineLocale).
* feat(desktop): running is not busy
Gate composer submit and plugin host busy on the target session slice, not a leftover foreground busyRef. Staff can keep typing while a worker session is running.
Includes the follow-up test that submit uses the target session busy flag.
* fix(desktop): lint and map contributor email for running-is-not-busy
Drop the redundant Boolean() on selected in $primaryBusy and add the
professorpalmer9@gmail.com mapping so attribution CI can resolve the PR.
* feat(desktop-sdk): expose focused-session state atoms to plugins
Disk plugins read app state exclusively through host.state, which only
exposed the primary workspace tab ($activeSessionId). In the multi-tile
layout, clicking a tile never touches that atom — and tile focus is a
pure renderer concern, invisible to both gateway RPC and the event
stream — so a plugin cannot follow the session the user is actually
looking at.
The core statusbar solves this same problem by reading the focused-
session atoms (use-statusbar-items.tsx). Widen the generic plugin
surface with the same signals, per the contribution rubric:
- host.state.focusedSessionId — runtime id of the focused session
(interacted tile, else the primary), the key for session.* RPC
- host.state.focusedStoredSessionId — durable id for navigation and
session-list matching
- host.state.focusedUsage — live streamed UsageStats projection
(context_used/max/percent, tokens, cost_usd), no RPC needed
Additive only; no existing behavior changes. tsc --noEmit clean.
Verified end-to-end with a disk plugin that now tracks the focused
session across tiles.
* test(desktop-sdk): contract-test the focused-session host.state atoms
Locks the plugin-facing contract: the focused atoms exist as readonly
nanostores, mirror the primary session while no tile is focused, project
the focused session's usage, and — the behavior this PR exists for —
follow the interacted tile while the primary-only $activeSessionId
stays put.
* fix(desktop-sdk): type focusedUsage as Partial<UsageStats>, fix expect arity
ClientSessionState.usage is Partial<UsageStats> (app/types.ts) — the
backend streams whichever fields changed — so the computed produces
ReadableAtom<Partial<UsageStats> | null>. Annotate the entry honestly
instead of claiming full UsageStats, and document the fallback rule for
plugin authors. Also collapse the three-argument expect() calls in the
contract test (vitest takes one message arg). Addresses triage review on
PR #80461.
* fix(desktop-sdk): address adversarial review — type honesty, real tile coverage, docs
Independent second-pass review found three gaps:
- focusedUsage is null | UsageStats, not Partial — ClientSessionState.usage
is the full type (app/types.ts) and its only write site seeds the four
required fields before merging (gateway-event.ts). The earlier Partial
annotation traced the wrong type (SessionRuntimeInfo, an RPC payload).
Comment now names the genuinely optional fields instead.
- The tile-focus contract test never seeded $sessionTiles/$sessionStates,
so it proved focusedStoredSessionId follows a tile but could not
distinguish focusedSessionId/focusedUsage working from broken. Seed a
bound runtime with distinct usage and assert both readout atoms move.
- The two public host.state references (website docs + bundled skill
reference) enumerated the old six atoms; plugin authors would never
discover the new ones. Both lists updated.
tsc/eslint/vitest green (4/4).
* chore: map contributor email for focused-session atoms salvage
* fix(desktop): exclude process-less descriptors from backend pool LRU cap
Remote/cloud registry descriptors (entry.process === null) shared the
POOL_MAX_BACKENDS cap with real spawned local backends, so a roster
refresh across N registered remote connections could LRU-evict a live
local backend idle past the keepalive window. Cap accounting and
cap-driven eviction now count only entries with a live child process;
descriptors remain subject to the idle reaper.
* fix(desktop): tear down renderer secondaries when a registry connection is removed
Removing a connection stopped its pooled backends and ssh tunnels but
never told the renderer: for remote/cloud sources there is no local
process to die, so the removed connection's WebSocket stayed open and
kept streaming ghost events into the UI until page reload. If the
socket did drop, openSecondary -> getConnectionFor threw 'No connection
with id' and scheduleReconnect retried forever (backoff caps at 15s,
entry never evicted).
- main now broadcasts 'hermes:connections:changed' on removal (and on
material edits); preload exposes connections.onChanged.
- use-gateway-boot subscribes and calls the new
disposeSecondariesForConnection(), which disposes + evicts every
secondary scoped to the connection id (redialing on edits).
- reconnectSecondary fail-stops: when the Electron main reports the
connection no longer exists, the entry is disposed and evicted
instead of retrying forever; ordinary transport errors keep the
existing backoff behavior.
* fix(desktop): recycle live backends and sockets when a connection edit changes its target
saveRegistryConnection only rewrote the registry file: editing a
connection's URL/token/host left pooled backend descriptors under
'conn:<id>::*' and open renderer sockets pointing at the OLD endpoint —
the UI showed the new target while traffic kept flowing to the old one
until idle-reap.
When a save MATERIALLY changes an existing connection (endpoint / auth /
ssh routing fields, via the new connectionDialFieldsChanged helper),
main now stops that connection's pooled backends and tunnels
(stopRegistryConnectionBackends, same teardown as removal) and
broadcasts 'hermes:connections:changed' with reason 'updated' so
renderers dispose and re-dial their secondaries at the new target.
Label-only renames do not recycle.
* fix(cli): restore Kitty keyboard protocol push and complete the extended-key alias table
Commit 4c34eeb416 fixed dead Ctrl+C by removing the Kitty protocol push
(CSI >1u) from _EXTENDED_ENTER_KEYS_SEQ, keeping only modifyOtherKeys
level 2. That regressed kitty-the-terminal completely: kitty removed
xterm modifyOtherKeys support (kovidgoyal/kitty#4075) and only speaks
its own protocol, so after the removal kitty users lost Shift+Enter and
every other extended key — the CSI >4;2m we still pushed is a no-op
there (kitty even logs a PARSE ERROR for it).
The original reason for removing the push is obsolete: #87511 mapped
CSI-u control sequences, so Ctrl+C as ESC[99;5u now parses to
Keys.ControlC and fires the existing c-c binding. (The kernel-INTR
concern in that commit was moot — prompt_toolkit's raw mode clears
ISIG, so Ctrl+C is always handled by the binding, never the kernel.)
Restore the dual push (CSI >1u + CSI >4;2m), exactly mirroring the Ink
TUI, and complete the alias table for what the kitty disambiguate flag
actually emits — #87511 left real gaps, some of which its PR body
wrongly claimed were covered:
- Esc key: ESC[27u (+ modifiers) — previously leaked '[27u' as text
- Ctrl+Backspace -> backward-kill-word (#78285 was closed on the wrong
claim that codepoint-127 mapping existed; it did not)
- Shift+Space -> space (#86866's second symptom; the Ctrl+Space
mapping never covered modifier 2)
- Alt+Enter -> newline tuple; Shift+Tab -> BackTab; Ctrl+Tab -> Tab;
Alt/Shift+Backspace
- Multi-modifier letters (Shift+Alt 4, Ctrl+Shift 6, Ctrl+Alt 7,
Ctrl+Alt+Shift 8) normalized onto their Ctrl/Escape-prefix targets,
both unshifted (kitty) and shifted (mok emitters) codepoints
- Kitty PUA functional keys: keypad -> non-keypad equivalents,
F13-F24, and Ignore for lock/media/modifier-event keys so they are
consumed instead of leaking (kitty emits these even in legacy mode)
Also: clear the VT100 parser's prefix cache after installing (stale
answers could misparse), and re-push extended keys after
_recover_terminal_input_modes' reset — the recovery previously popped
both modes mid-session and never re-enabled them, silently killing
Shift+Enter until restart.
Refs #87511, #87074, #56684, #56645, #78285, #86866, #87390.
* fix(cron): process .pth files for Windows uv-venv script jobs (#86567)
_windows_cron_python_invocation bypasses the uv venv launcher (to avoid
flashing a console window) and re-attaches the venv via PYTHONPATH — but
PYTHONPATH entries are plain sys.path additions and never get .pth
processing, so editable installs (pip install -e) were invisible to cron
script jobs (ModuleNotFoundError).
Bootstrap the script with site.addsitedir() on the venv site-packages,
then exec it as __main__ via runpy.run_path, preserving the script
directory on sys.path (python script.py semantics). Falls back to a
plain invocation when the venv layout is unresolvable.
* fix(cron): log and document the .pth bootstrap fallback (#86816 review)
- WARN when the venv site-packages layout is unresolvable and the script
falls back to plain PYTHONPATH execution, so 'editable installs
invisible' failures are diagnosable.
- Docstring: note that runpy does not set __package__/__spec__ the way a
direct python script.py invocation does.
* fix(update): surface config mutations applied silently during version-bump-only updates
* fix: honor JSON-array string forms for skills.disabled and agent.disabled_toolsets
`hermes config set` and JSON-mode editor saves store lists as quoted
strings (e.g. '["skill-a","skill-b"]' or "['memory']"). Both disable
filters treated such a string as a single name, so curated disable
lists silently filtered nothing with zero diagnostics.
Add parse_config_string_list() in agent.skill_utils and use it in
_normalize_string_set (skills.disabled / platform_disabled) and at
every agent.disabled_toolsets read site: tools_config resolve +
reconcile, CLI, gateway agent construction (both sites), cron
scheduler, and prompt_size. A scalar string still names a single
entry (#13026); malformed JSON falls back to the single-name
behavior instead of raising.
Fixes #86661
* fix(desktop-update): put --daemonized ahead of ORIGINAL_ARGS in posix.sh re-exec
Appending --daemonized after ORIGINAL_ARGS put it past the `--`
relaunch-args separator on Linux, so it was absorbed into
RELAUNCH_ARGS instead of being parsed as a flag. HANDOFF_DAEMONIZED
never got set, so the one-shot self-detach block re-fired on every
re-exec -- an unbounded self-exec loop (thousands of iterations/sec,
100%+ CPU, argv growing until execve fails with E2BIG) whenever
relaunch args were present, which is the normal invocation shape on
Linux.
Fixes #86957
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(cli): deliver the Bedrock API key through a named provider
The Bedrock API-key flow stored the bearer token in OPENAI_API_KEY and set
a bare `provider: custom`. Since #28660 that variable is only honoured for
openai.com hosts, so for bedrock-mantle.*.api.aws the token was dropped and
requests went out with api_key="no-key-required", a 401 on every call.
Write a named `providers.bedrock-mantle` entry with
key_env: AWS_BEARER_TOKEN_BEDROCK instead. The named-provider branch in
runtime_provider.py resolves key_env; the bare-custom branch cannot.
Fixes authentication only. Per-model mantle route selection is separate.
* fix(gateway): improve Windows detach diagnostics
* refactor(gateway): share breakaway marker constant
* fix(tui): settle session close against active turns
* fix(tui): stop queued dispatch after session close
* fix(cron): reject NUL-bearing script paths before any Path call (#76762 class)
_run_job_script wrapped only expanduser() in its ingestion try/except.
On Linux an unexpandable NUL-bearing value raises inside that call, so it
landed on the clean fail-with-report path; on Windows expanduser() never
expands '~user' (and so never raises), and the NUL surfaces later as an
uncaught ValueError from resolve()/exists() — crashing the scheduler.
Align with cron.lifecycle_guard._expand_candidate_path, which already
documents this as the whole-class fix (the per-syscall catching produced
#76762, #77703, #77780, #78256): reject '\\x00' eagerly at the ingestion
boundary so both platforms fail identically.
* test(cron): pin the eager NUL rejection contract for _run_job_script (#86829)
* fix(cron): coerce script_path to str in the NUL guard so it can never crash (#86829, review #86832)
"\x00" in script_path raises TypeError when a caller passes a non-str
(e.g. a pathlib.Path, which is not iterable) — the guard itself would
crash the scheduler. All current call sites pass plain str, but the
guard must be crash-proof: str() first, then check. Adds a regression
test running a real script through _run_job_script with a Path argument,
which fails with TypeError on the pre-fix guard.
* docs(agents): record multiplex profile-scoped env fail-closed rule (#86905)
Lesson from the feishu DM multiplex investigation: under multiplex,
os.environ holds the default profile's values, so any profile-level env
config (credentials AND authorization) must be read scope-aware, and a
scoped miss with a scope installed must fail closed instead of borrowing
from os.environ. The _get_scoped_secret wrapper is copy-pasted across
~15 platform adapters — new adapters and edits to existing ones must
keep the fail-closed semantics.
* fix(auth): resolve provider auto-detection keys through the profile scope (#86917)
resolve_provider's auto path read provider API keys with bare
os.getenv — under multiplex a secondary profile's keys live only in its
secret scope, so auto-detection found nothing and every secondary
profile with model.provider: auto failed with 'No LLM provider
configured' at agent init (reproduced on a live 7-profile gateway).
Route both env-key reads (the OPENAI/OPENROUTER tier and the
PROVIDER_REGISTRY loop) through _scoped_key_env, the scope-aware helper
auxiliary_client already uses: secret scope wins under multiplex,
UnscopedSecretError falls back to os.environ (default-profile/CLI
paths unchanged). Same bug class as #86905.
Verified in a gateway-accurate simulation (hermes_home_override +
profile scope): resolve_provider('auto') now returns the secondary
profile's own provider (deepseek) instead of erroring.
* test(auth): cover profile-scoped key resolution in resolve_provider (#86917)
Three regression tests: scoped DEEPSEEK_API_KEY is visible to auto
detection under multiplex (the #86917 failure); unscoped paths keep the
os.environ read; explicit config provider still wins.
* fix(auth): only fall back to os.getenv on ImportError in resolve_provider (#86918 review)
The previous except Exception silently fell back to os.getenv if the
_scoped_key_env import ever failed — under multiplex that is exactly the
fail-open this PR removes (secondary profiles would regress to 'No LLM
provider configured' with zero trace). Catch only ImportError, log a
WARNING naming the consequence, and let any other failure propagate.
Also replaces the lambda fallback with a named nested function.
* test(gitlock): pin the git-process guard in sweep tests (deflake slice 8)
The stale-lock removal tests asserted the sweep result while leaving
_git_proc_running() live: on CI the parallel per-file runner almost
always has a real git subprocess in flight, pgrep -x git hits, and
clear_stale_git_locks correctly refuses to sweep — failing the tests
for reasons unrelated to the code under test (surfaced on PR #86918,
which doesn't touch gitlock at all).
Monkeypatch the guard to False in the sweep tests and add an explicit
test pinning the guard's block-while-git-running behavior.
* fix(gateway): complete /loop ticks after streamed already_sent turns
Streamed replies return None so the adapter does not send twice.
The /loop hook then saw empty text and never ran, so
awaiting_response stayed true and later ticks never fired.
Stash the delivered text on the event and use it for the post-turn
hooks. /goal uses the same path.
Tests: tests/gateway/test_loop_command.py
* fix(cli): chat -c fails loudly on stderr and gains --create-if-missing
`hermes chat -c "<title>" -q "<text>"` silently no-oped when no session
matched the title under quiet/programmatic use: the not-found message was
written to stdout (the channel quiet callers parse as the final response),
so a background send to a not-yet-existing named session vanished with no
error. Surfaces via Hermes-Bot-Mode bot-to-bot handoffs (#86794).
- not-found message now goes to stderr (exit 1 unchanged), so programmatic
callers always see it even with -Q/--quiet
- new --create-if-missing: with `-c <title>` and no matching session, create
a fresh session carrying the title and proceed — the deterministic
"send to this named thread, making it if needed" primitive plugins asked for
- extract the -c resolution block into _resolve_continue_arg for testability
Tests: flag parsing, titled-session creation, stderr routing, source guard.
* fix(cli): address review feedback on chat -c fail-loudly PR
Response to AI review (Enough1122) on #86812:
1. `_create_titled_session`: log the underlying exception before returning
None so programmatic callers aren't left with an undebuggable "could
not be created" — failures (DB lock, I/O, import) now land in errors.log
via logger.exception.
2. Drop the source-reading `TestSourceGuard` — it violated the repo's
"never read source code in tests" rule and was a change-detector
(passed even if behavior regressed). The stderr routing is already
covered by the real-path `test_missing_session_fails_on_stderr`.
3. Bare `-c` + `--create-if-missing` now prints a stderr note explaining
the flag needs a session name, instead of silently ignoring it — makes
the no-op self-evident to programmatic callers.
Tests: 6 targeted + 8 adjacent, all passing.
* chore: map contributor email for @yflmq001
* fix(acp): probe CLI for --acp support before spawning subprocess
CopilotACPClient unconditionally passes [self._acp_command] +
self._acp_args (default ['--acp', '--stdio']) to subprocess.Popen.
When the resolved CLI doesn't accept --acp (e.g. Claude Code
v2.1.233, where 'claude --acp --stdio' exits 1 with
'error: unknown option') the subprocess dies in ~250ms with the
error on stderr, but the parent ACP loop has no fast-fail for this
shape and waits the full child_timeout_seconds (default 600s,
observed 109s+ before user interruption) for stdout that never
arrives.
Add _acp_supported() that probes the CLI's --help output for the
--acp flag in ~50ms, then call it at the top of _run_prompt before
any spawn happens. When the probe fails, raise a RuntimeError that
names the unsupported flag, lists the expected fix (install
@github/copilot late 2025+, or set HERMES_COPILOT_ACP_*), and
returns control to the caller in ~280ms instead of hanging the
delegate_task parent for hundreds of seconds.
Measured locally against Claude Code v2.1.233:
- Before: delegate_task acp_command=claude hangs 109s+ then
returns tokens={input:0, output:0}.
- After: delegate_task acp_command=claude raises RuntimeError
in 280ms with a clear actionable message.
This does NOT change behavior for supported CLIs (the new
@github/copilot ships with --acp) — the probe returns True and
the spawn proceeds unchanged.
Refs the bundled claude-review-delegate skill which already
documents this class of transport-mismatch pitfall for users
who call 'claude -p' directly; this fix closes the same gap for
the delegate_task MCP path.
* fix(acp): make --acp probe tri-state, cached, and mock-safe
Salvage hardening on top of #87308 (thanks @Dudeman456):
- Tri-state verdict: inconclusive probes (binary missing, --help
failed/timed out) return None and fall through to the normal spawn
path, preserving the established 'Could not start Copilot ACP
command' error instead of masking it. This also fixes the two
test_copilot_acp_client HOME-env regressions that went red on the
PR: their mocked-Popen path was intercepted by the new unmocked
subprocess.run probe.
- Cache definitive verdicts per binary path so CLIs that DO support
--acp pay the ~50ms --help cost once per process, not per prompt.
- Skip the probe entirely when custom ACP args don't include --acp.
- Fix the help-text regex: the old pattern never matched '[--acp]'
(leading '[' is neither start-of-string nor whitespace) and \b
after 'p' matched '--acpfoo'.
- Hermeticity: stub subprocess.run in the two HOME-env tests; add 6
probe-specific tests (fast-fail, fall-through, caching, skip).
* fix(cli): bound the Windows process-scan probes so a slow WMI scan cannot wedge hermes update (#87134)
subprocess.run(capture_output=True, timeout=N) is not hang-safe on
Windows: after the timeout fires, run()'s cleanup kills the direct child
and then joins the pipe reader threads with an UNBOUNDED communicate().
A descendant (conhost.exe under wmic/powershell) holding duplicated pipe
handles keeps the pipes from EOF and the join never returns.
_scan_gateway_pids() runs its wmic / Get-CimInstance Win32_Process scans
exactly that way, and on machines where the full process scan genuinely
exceeds its 10/15s budget (cold WMI on first boot, ARM VMs, heavy
Update/AV activity) hermes update wedged forever inside
_pause_windows_gateways_for_update() before printing a single line —
observed live on a fresh Windows 11 ARM64 VM with a faulthandler stack
pinning the main thread in subprocess._communicate and only a conhost.exe
child surviving. The single-flight update lock then blocks retries until
the wedged process is killed by hand.
This is the same deadlock class bounded_git_probe already fixed for git
probes (#68609 / #66037). Generalize that proven pattern into a shared
bounded_probe_run() — explicit communicate(timeout), kill_process_tree on
failure, bounded 1s drain, then abandon the daemonic readers — and
migrate the whole call-site class onto it:
- hermes_cli/gateway.py _scan_gateway_pids (the site that hung; reached
from hermes update, cron, gateway restart/status, dashboard)
- hermes_cli/dashboard_procs.py wmic scan (same shape, reached on update)
- hermes_cli/claw.py tasklist + PowerShell probes (same shape; its
try/except cannot catch a hang because a hang raises nothing)
- bounded_git_probe now delegates to bounded_probe_run (identical
contract, one copy of the cleanup logic)
Unlike bounded_git_probe, bounded_probe_run returns the CompletedProcess
(or None) rather than collapsing to stdout, because the gateway scan
branches on returncode to trip its wmic -> powershell fallback.
Tests: tests/hermes_cli/test_bounded_probe_run.py covers success,
nonzero-exit passthrough, spawn failure, bounded timeout (fails against
the old unbounded semantics — verified by sabotage), errors= decoding,
DEVNULL stdin, POSIX process-group placement, and the bounded_git_probe
delegation contract. Existing test_git_probe_tree_kill.py passes
unchanged against the delegated implementation.
Closes #87134
* test(cli): retarget the wmic-encoding regression test at bounded_probe_run
The Windows-only test asserted encoding/errors kwargs on a mocked
subprocess.run, but the scan now routes through bounded_probe_run
(#87134), so subprocess.run is never invoked. Assert the probe call's
contract instead (errors='ignore', finite timeout), verify the parsed
PIDs, and add a fail-open case for probe failure. The test no longer
needs a Windows host once the probe is mocked, so the windows_only
gate is dropped.
* fix(agent): attribute background-review usage and add cost controls
Persist fork token usage under session_model_usage task=background_review,
emit a per-fork completion log line, and expose enabled/max_iterations/
prompt_file so operators can see and bound the automatic review cost.
Address review feedback: load auxiliary.background_review once per spawn,
classify completion logs by summarize action prefixes, treat explicit
api_call_count=None as the documented default of 1, and WARNING on the
fail-open enabled-gate path.
* fix(desktop): route registry 'local' entry to the genuinely-local runtime
ensureRegistryBackend delegated kind==='local' to ensureBackend(), which
follows the v1 connection.json routing table — under a v1 REMOTE global
mode (the migration keeps the mandatory 'local' entry AND makes that
remote the registry primary) the roster's 'This device' rows enumerated
and dialed the REMOTE primary: every profile appeared twice (forcing
-slug handles) and clicking a local agent talked to the remote box.
resolveRegistryLocalRoute() (pure, colocated with the registry helpers)
now decides the local entry's path: delegate to the legacy route only
when v1 is itself local (single-source behavior byte-identical);
otherwise spawn/reuse a forced-local pool child via spawnPoolBackend's
new forceLocal option, pooled under the composite conn:local::<profile>
key so it cannot collide with the v1 remote descriptor cached at the
bare profile key.
* fix(desktop): key fan-out event consumption by (connectionId, profile)
Secondary-gateway events were tagged with connectionId (store/gateway
fan-out) but no consumer read it: working/attention tracking, the
pruneSecondaryGateways keep-set, and the profile-scoped event gates
(skin.changed / change-watcher broadcasts / approval-mode reconcile)
all keyed by session id + bare profile name. Every registered source
exposes a 'default' profile (the roster force-unshifts it), so two
connected gateways collided — gateway B's 'default' activity was
attributed to gateway A's 'default', keeping the wrong socket alive
and applying the wrong source's config/skin/cron changes.
Thread connectionId through consumption using the existing composite
backendScopeKey helper:
- session-states records each registry-tagged event's (connectionId,
profile) scope per runtime session; liveSessionScopes() projects the
busy/needs-input ones as composite keys for the gateway keep-set.
- recomputeKeptGateways (use-gateway-boot) seeds the keep-set with
those scopes; pruneSecondaryGateways matches registry-scoped entries
ONLY on their composite key, while local entries keep matching bare
profile names (single-source path unchanged).
- gateway-event's 'from the active profile' gates now compare the
event's composite scope against the active gateway's connection via
the new activeGatewayConnectionId(); untagged local/primary events
behave byte-identically.
Display-only surfaces that already use roster handles are untouched.
* style(desktop): order @hermes/shared import before nanostores (perfectionist/sort-imports)
* fmt(js): `npm run fix` on merge (#87839)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* fmt(js): `npm run fix` on merge (#87844)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* fix(agent): trim background_review to the enabled switch
Follow-up to #87400: drop the max_iterations and prompt_file knobs from
auxiliary.background_review. The aux model routing (provider/model/
base_url/...) predates #87400 and stays; the enabled switch and the
usage telemetry stay. The fork's iteration budget returns to the
historical hardcoded 16.
* fix(update): reload _subprocess_compat and dashboard_procs after git pull
hermes update runs in the PRE-pull Python process. After git pull updates
source files on disk, modules already in sys.modules still hold the OLD
code. The existing _reload_config_modules() reloaded only config modules,
but the post-update dashboard cleanup path (_finish_dashboard_update_cleanup
-> _scan_dashboard_processes) imports hermes_cli._subprocess_compat lazily;
a new symbol added there (e.g. bounded_probe_run) is invisible to the
cached module object, causing ImportError during the cleanup step.
Extend the reload list to include hermes_cli._subprocess_compat and
hermes_cli.dashboard_procs so the cleanup uses freshly-pulled code.
* fix(update): reload process-scan modules at the dashboard-cleanup entry point
Widen PR #87757 to cover the ZIP path: _update_via_zip() also calls
_finish_dashboard_update_cleanup() but never runs _reload_config_modules,
so the Windows git-broken fallback would still crash with the same
ImportError (cannot import name 'bounded_probe_run' from the stale cached
hermes_cli._subprocess_compat).
- new _reload_process_scan_modules() called inside
_finish_dashboard_update_cleanup itself, so every current and future
call site is covered; reloads dependency-first
(_subprocess_compat, then dashboard_procs)
- reload failures log at warning (a miss surfaces seconds later as an
ImportError in the same process)
- regression tests: reload-before-kill ordering, node-failure skip,
stale-module symbol restoration (the exact #87134 boundary state),
nonfatal reload failure, and the #87757 reload-list contract
* chore: release v0.20.2 (2026.8.16)
* fix(tui): modified Enter and bare LF insert a newline in the composer across IDE and macOS terminals (#87854)
* fix(tui): send atomic CSI u for modified Enter in IDE terminals
VS Code/Cursor/Windsurf terminals bound Shift/Ctrl/Cmd+Enter to the
legacy \\r\n sequence, which Ink's parse-keypress split into a
backslash keypress plus a plain Return — inserting a stray backslash and
submitting instead of adding a newline. Emit Kitty CSI u sequences that
encode the modifier atomically, and migrate keybindings users already
have on disk.
Co-authored-by: yatesjalex <yatesjalex@users.noreply.github.com>
* fix(tui): treat a bare LF as a newline in macOS composer terminals
Terminals that can't send a distinct Shift+Enter collapse a modified
Enter / Ctrl+J down to a bare LF. shouldPreserveCtrlJNewline() already
handles the env-detectable cases (SSH, Windows Terminal, Ghostty, WSL),
but plain macOS terminals (Terminal.app, iTerm2 defaults) do the same and
aren't env-detectable, leaving no keyboard-driven newline there. Fold the
return-key decision into shouldInsertNewlineOnReturn() and accept a bare
LF as a multiline fallback on macOS too, keeping CR as submit everywhere.
Co-authored-by: LeonSGP43 <LeonSGP43@users.noreply.github.com>
---------
Co-authored-by: yatesjalex <yatesjalex@users.noreply.github.com>
Co-authored-by: LeonSGP43 <LeonSGP43@users.noreply.github.com>
* fix(state): classify structural DB corruption as its own persistence cause
'database disk image is malformed' contains the word 'disk', so
classify_persistence_error bucketed SQLITE_CORRUPT / SQLITE_NOTADB
failures as 'disk' and the turn-completion explainer told users to
free disk space for a structurally damaged state.db (the #77386-family
misdiagnosis, reproduced in the v0.20.0 malformed-DB incident report).
- hermes_state: new 'corrupt' bucket in PERSISTENCE_ERROR_CAUSES,
matched via _DB_CORRUPTION_MARKERS BEFORE the locked/disk buckets
- run_agent: explainer text for 'corrupt' points at hermes doctor and
explicitly says freeing space will not help
- cron explainer-variant suppression picks the new variant up
automatically (it iterates PERSISTENCE_ERROR_CAUSES)
* fix(openviking): strip PYTHONPATH from autostarted server child env (#78153)
(cherry picked from commit 7afd99155667cde480c0ab4ee31e242dab849d40)
* fix(openviking): read .env BOM-tolerantly when rewriting credentials
f1ea4a56c ("cover the remaining setup-time .env reads with utf-8-sig",
following 75afc47ba for mem0/hindsight) swept this class; openviking's
_write_env_vars was missed and still reads with strict utf-8.
It copies every existing line through on each update, so the read decides
whether a credential update lands:
BOM'd .env -> the first key never matches, so the old line survives and
the new value is appended as a duplicate. .env loaders keep
the first occurrence, so the update silently does nothing.
cp1252 .env -> UnicodeDecodeError aborts setup outright.
Read exactly like the canonical hermes_cli/config.py save_env_value
(utf-8-sig + errors="replace"). A plain UTF-8 file rewrites byte-identically.
Scope: hermes_cli/memory_setup.py has the same read but is already the
subject of #30281 / #60587, so it is left alone here.
(cherry picked from commit 175c6852c2c255b3219575b5de0b1b70f1f0efcb)
* fix(openviking): preserve non-UTF-8 env bytes on update
* docs(openviking): correct environment handling explanations
Clarify that the Desktop backend can add Hermes venv packages to PYTHONPATH and that current .env loaders use the last duplicate value.
* Revert "fix(agent): preserve local reasoning timeout opt-out"
This reverts commit 26b2b475935d5f5f369142fe1648cf5c95e7b056.
* Revert "fix(agent): harden canonical tool call deduplication"
This reverts commit 8fc4189edd23dde055232cc07ea14d1d525e44ee.
* fix(update): restart hermes-serve systemd units alongside gateways
hermes update discovered and restarted hermes-gateway* systemd units but
never looked for hermes-serve* — the Desktop app's backend — so it kept
running stale pre-update code until the user restarted it by hand (#83438).
Extend the systemd unit discovery/restart loop to also match hermes-serve*
units. They don't wire SIGUSR1 to a graceful drain (only gateway/run.py
does), so restart eligibility for the graceful path is now gated on unit
name via a small, directly-tested helper; hermes-serve units fall straight
to the existing blunt systemctl restart path, matching the workaround the
issue already documents.
* fix(update): tighten hermes-serve unit gate, dedupe fleet/cleanup restarts
Review on #83595 flagged two service-lifecycle gaps in the hermes-serve
restart support:
- The unit-name gate accepted anything starting with "hermes-serve",
which also matched the unrelated hermes-server.service. Require the
exact base unit or the hyphenated profile family instead.
- The fleet-restart loop and _finish_dashboard_update_cleanup() could
both restart the same hermes-serve unit — the loop restarts it
directly, then cleanup's PID scan finds the fresh process and
restarts its owning unit again. Thread the fleet loop's restarted
unit names through to _kill_stale_dashboard_processes() so it skips
units already handled.
* fix(update): tighten gateway-side unit gates to exact/hyphenated shape
Mirror the strict unit-name shape from the hermes-serve gate (review on
PR #83595) on the gateway side too: the discovery gate and the SIGUSR1
eligibility helper now accept only `hermes-gateway.service` or the
`hermes-gateway-<profile>` family, so a near-prefix unit like
`hermes-gatewayd.service` can neither enter the restart path nor be sent
a SIGUSR1 it does not handle.
* fix(desktop): ignore stale remote connection attempts
* chore: map contributor email for xkam7ar
* fix(apps): dial primary sleep/wake reconnect at window backend not active profile
* fix(desktop): scope pluginSocket's connection to the active profile
pluginSocket (hermes.ts) is documented as "the live twin of pluginRest,
scoped the same way", but it calls window.hermesDesktop.getConnection()
with no profile argument, while pluginRest passes the active profile via
profileScoped(). getConnection's IPC handler (ensureBackend in
electron/main.ts) falls back to the primary profile whenever the profile
argument is empty, so an unscoped call always resolves to the primary
profile's backend regardless of which profile is actually active.
For a plugin used from a non-primary profile (e.g. kanban), this means REST
calls go to the correct pooled backend while the plugin's WebSocket silently
connects to the wrong one — a multi-profile user sees one profile's data
with another profile's live events.
Fix (adapted to the post-#87600 registry-agent store shape during salvage):
resolve the plugin socket's connection through the same (connectionId,
profile) source of truth ensureGatewayProfile/ensureGatewayAgent maintain
for $connection — store/gateway's setActive now pushes the active scope's
registry connection id into the hermes module (setApiRequestConnection,
the no-store-import twin of setApiRequestProfile), and pluginSocket
resolves via getConnectionFor for registry-agent scopes and
getConnection(profile) for the local pool. The plugin socket therefore
follows registry-agent activations too, not just profile switches.
voice-playback.ts's resolveSpeakStreamUrl had the same gap originally, but
main has since fixed it independently (via the getApiRequestProfile()
getter rather than direct store access) — dropped from this PR as
redundant, keeping only the still-open pluginSocket gap.
Co-authored-by: Hermes Agent <hermes@nousresearch.com>
* fmt(js): `npm run fix` on merge (#87880)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* fix(image_gen): disable default-on upscaling everywhere — opt-in only
The Aug 8 default-on upscaling policy (66ea4e686) chained the Clarity
Upscaler after every sub-2MP generation. Clarity is an SD1.5 creative
tile-diffusion enhancer (creativity 0.35, "masterpiece" prompt prefix) —
it redraws content, which degraded output on 100% of generations for
models like GPT Image 2 and Ideogram whose value is precise text
rendering, CJK, and photorealistic detail.
Policy now: no model upscales by default, on FAL or Krea. The `upscale`
tool param remains as a per-call opt-in (`upscale: true`); explicit
requests still chain Clarity (FAL) / Krea Enhance as before.
- FAL catalog: all 17 default-on entries flipped to upscale=False
- Krea plugin: medium + medium-turbo per-model defaults flipped off
- Tool schema: upscale param described as opt-in with a fidelity warning
- Tests updated: catalog invariant now pins all-off; default-on cases
now assert no upscaler call
- Docs (en + zh) updated to the opt-in policy
* fix: make every tool interruptible — sequential executor abandons on user interrupt
The sequential tool path only noticed a user interrupt after the running
tool returned: with the deadline disabled it ran the tool inline (fully
blocking), and with a deadline it waited in 5s slices without ever
checking agent._interrupt_requested. Any tool without cooperative
is_interrupted() polling (image_generate, tts, transcription, skills
sync, ...) held the whole turn hostage — the reported symptom was a
redirect queued ~40s behind a FAL image generation + upscale pass.
Executor backstop (class fix, covers ALL tools):
- _run_sequential_tool_execution_middleware always dispatches on the
daemon worker (timeout None no longer means inline blocking) and polls
the interrupt flag every 1s.
- On interrupt: 3s cooperative grace (mirrors the concurrent path), then
synthesize a cancelled tool result (_ToolCancelledResult), emit the
terminal post_tool_call with status=cancelled, and abandon the worker.
- _ToolCancelledResult suppresses downstream post-hook double emission
exactly like _ToolTimeoutResult, so an abandoned worker finishing late
cannot report success for a cancelled call.
- clarify (interactive, _NEVER_PARALLEL_TOOLS) keeps the inline path —
it owns its own human wait.
Cooperative layer in the reported offender:
- image_generation_tool: blind handler.get() (generation + Clarity
upscale) replaced with _wait_fal_result(), which polls is_interrupted()
in 0.5s slices and raises ImageGenerationInterrupted immediately.
- _upscale_image propagates the interrupt instead of swallowing it into
the "upscale failed, use original" fallback.
Message alternation is preserved: the cancelled result is a normal tool
result for the call_id. Sabotage-verified: with the old wait loop
restored, the new tests fail (tool blocks full runtime); with the fix
they pass in ~4s.
* feat(computer-use): support Cua Driver 0.20 runtime contracts
* fix(computer-use): reconcile existing cua-driver installs
* fix(computer-use): enforce existing-profile grant, unblock the opt-in
Live-testing the Cua Driver 0.20 convergence on Windows 11 (session 2,
cua-driver 0.20.0) surfaced three defects in the existing-profile browser
path and in install status.
1. The config grant was silently nullified by an approval bypass.
`--yolo` / `-z` map onto a private unrestricted daemon, which answers every
browser_prepare. Because the host delegated the entire existing-profile
decision to the driver, that bypass also nullified
`computer_use.grant_existing_profile: false`: a plain `hermes -z` attached
to the user's real Chrome profile and read live page content over CDP, with
the driver reporting it as "the approved existing Chromium profile". It was
never approved.
An approval bypass is consent to skip prompts, not consent to read an
existing profile's pages, cookies, and storage. CuaTypedBrowserRoute.prepare
now enforces the key itself, regardless of permission mode. bounded stays
exempt - its reviewed capability manifest is the authorization boundary.
The authorization inputs are resolved in the backend from config and the
backend's immutable mode, never from model-supplied kwargs.
2. The grant, once set, still could not be used.
With `grant_existing_profile: true` the runtime is launched
`--grant existing-profile` correctly, but cua_browser_prepare then hit a
runtime approval prompt anyway - re-asking the user to authorize what the
config already authorized, and making the documented opt-in unusable on any
non-interactive run, where the prompt has nobody to answer it and the call
dies on approval timeout. The durable, file-backed grant now stands in for
that prompt. Scope is narrow: only the existing-profile prepare, only when
the grant is present; isolated launches still prompt and any resolution
failure falls closed to prompting.
3. `computer-use status` hid a custom override and spliced its output.
With HERMES_CUA_DRIVER_CMD pointed at cmd.exe, status printed the child's
multi-line banner and prompt inside the one-line version field, never
mentioned the override, and advised `hermes computer-use install` - which
install itself (correctly) refuses to run against an overridden path. It now
names the override and mirrors install's update-or-unset guidance, and
version output is reduced to one bounded line.
Verified on the reported host: `-z` existing-profile attach now refuses and
names the key; `grant: true` no longer prompts (33s vs a 300s approval
timeout); status names the override and prints one line. No change to the
reconciliation path - driver SHA256 unchanged end to end.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(computer-use): make the typed-browser bind/snapshot split discoverable
`cua_browser_state` has two branches, chosen implicitly: any call carrying
pid or window_id is a *binding* (browser_route.py:252), anything else is a
*snapshot*. A binding clears session state, mints fresh tab_ids, returns
binding metadata with no page content, and sets verification_required.
Nothing in the response says that. A caller that keeps passing pid/window_id
- the natural reading of "bind to this window, then read it" - re-binds
forever: the tab_id it just received is unbound by the next bind, so every
cua_browser_navigate comes back browser_verification_required, and the
refusal ("take a fresh snapshot") points at the same call that just re-bound.
Observed live as 11 consecutive refused navigates before the model gave up
and fell back to foreground SendInput on the address bar.
The same confusion silently swallowed include_screenshot: both calls that
requested one were bindings, which carry no page content, so the flag had
nothing to attach to and was dropped without comment.
A binding response now reports snapshot_required, next_step
(fresh_browser_state, matching the existing token convention) and a hint
naming the exact next call; requesting a screenshot on a binding reports
screenshot_deferred instead of dropping it. The verification refusal now
says to call cua_browser_state WITHOUT pid/window_id and why re-sending them
does not help. The schema documents that include_screenshot applies to
snapshots.
Behavior of the bind and snapshot branches themselves is unchanged - this is
purely about making the split legible to the caller.
Unit-tested. Not verified end to end on the reporting host: the driver
refuses the bind upstream there (`browser_requires_setup: no owned DevTools
endpoint`, and it does not accept a user-launched --remote-debugging-port),
so the typed route never reaches this branch. That attach failure is a
separate cua-driver issue.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(computer-use): keep a v3 capability manifest on approval-bypassed runs
`--yolo` / `-z` route the session onto a private embedded daemon in
`unrestricted` mode. That daemon was constructed without the configured
capability manifest, and the serve command only attached
`--capability-manifest` when the mode was exactly `bounded`. So the moment a
run was bypassed, the user's declared ceiling was dropped:
without -z: --permission-mode bounded --capability-manifest ...
with -z: --permission-mode unrestricted --dangerously-bypass-approvals
No manifest, no warning. The most carefully configured run - a reviewed
ceiling, written by hand - became the least constrained one, silently, and
it failed open.
That was never a driver limitation. cua-driver documents the manifest as a
ceiling across modes ("A manifest can narrow a profile but never widen it";
its own authorization table calls it `optional_capability_manifest_ceiling`),
and accepts it alongside `--permission-mode unrestricted`.
The forwarding is version-aware, because the two manifest schemas differ
(cua-driver session_manifest.rs):
* v1/v2 are legacy and must declare `mode: bounded`. Handing one to an
unrestricted runtime aborts startup with "legacy capability manifest mode
must be bounded", so a naive forward would turn a working session into a
hard failure. These are forwarded for bounded only, and a warning names
the migration when one cannot apply.
* v3 must not declare a mode. It is the mode-independent ceiling, and it now
rides along with unrestricted.
Unreadable or unparseable manifests are not forwarded outside bounded, on
the same fail-safe reasoning; bounded still forwards unconditionally and
lets the driver be the authority there.
Verified against cua-driver 0.20.0 on Windows. Launch args now carry
`--permission-mode unrestricted --dangerously-bypass-approvals
--capability-manifest <v3> --approve-capability-manifest`, and the ceiling
is enforced in the bypassed run - a tool outside the manifest is refused
("outside the capability manifest for this session ... blocked as a
protected resource") where the same config previously ran unbounded. A
legacy manifest was confirmed to abort driver startup when forwarded, which
is what the version gate prevents.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(computer-use): warn when an approval bypass widens the driver mode
`--yolo` / `-z` read as "don't prompt me", but they also swap computer_use
onto a private `unrestricted` daemon, dropping the ceilings the configured
mode would have applied. Nothing said so. A script picks up `-z` for quiet
output and loses its limits as a side effect, and the only trace is a driver
process nobody inspects.
The mapping itself stays. It is deliberate, and `unrestricted` is reachable
no other way: it is intentionally not a config value so a stale config line
can never silently bypass approvals (see `_cua_configured_permission_mode`).
Removing the mapping would delete the capability rather than fix it, and
splitting it onto a second CLI flag was declined to avoid growing the
surface.
So the widening is now stated instead: one warning per session naming the
configured mode it left, what stopped applying, and the two ways to keep a
ceiling - drop the bypass flag, or declare a version-3 capability manifest,
which now rides along with unrestricted as of the previous commit.
Once per session, not per dispatch: the resolver runs on every tool call.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(computer-use): report unusable driver exit status
(cherry picked from commit 8bda6191ca548d65648263dfe30d2d18950a6e60)
* fix(computer-use): preserve missing driver overrides
* fix(computer-use): verify Windows driver repair
* fix(computer-use): align browser guidance and screenshots
* fix(desktop): keep the local pack out of electron-builder's publish path
`hermes desktop` runs `npm run pack` through _npm_lifecycle_env(), which
sets CI=1. electron-builder 26 reads that as an implicit publish request
(`onTagOrDraft`) when --publish is absent, so a local --dir build enters
publish resolution it has no business being in.
Pin `--publish never` on the pack script. This is also what electron-builder
asks for directly -- the implicit CI behavior is removed in v27.
Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
Co-authored-by: fangliquanflq <fangliquanflq@users.noreply.github.com>
* fix(desktop): declare the repository so publish resolution can succeed
With a GH_TOKEN/GITHUB_TOKEN in the environment, electron-builder auto-selects
the github provider and resolves owner/repo from the repository field, falling
back to reading <projectDir>/.git/config. projectDir is apps/desktop, which has
no .git of its own, and app-builder-lib does not walk up to the workspace root
-- so resolution returned null and threw "Cannot detect repository by
.git/config".
On Linux this fires from onAfterPack for a plain `dir` target: the darwin and
Windows branches return early for non-installer targets, Linux has no such
guard. That is why the same build worked elsewhere.
--publish never keeps `pack` from reaching this at all, but `dist:*` and
test-desktop.mjs still resolve publish config on a machine with a token, so
declare t…
lisajlau
pushed a commit
to lisajlau/hermes-agent
that referenced
this pull request
Aug 20, 2026
…ded-key alias table Commit 4c34eeb fixed dead Ctrl+C by removing the Kitty protocol push (CSI >1u) from _EXTENDED_ENTER_KEYS_SEQ, keeping only modifyOtherKeys level 2. That regressed kitty-the-terminal completely: kitty removed xterm modifyOtherKeys support (kovidgoyal/kitty#4075) and only speaks its own protocol, so after the removal kitty users lost Shift+Enter and every other extended key — the CSI >4;2m we still pushed is a no-op there (kitty even logs a PARSE ERROR for it). The original reason for removing the push is obsolete: NousResearch#87511 mapped CSI-u control sequences, so Ctrl+C as ESC[99;5u now parses to Keys.ControlC and fires the existing c-c binding. (The kernel-INTR concern in that commit was moot — prompt_toolkit's raw mode clears ISIG, so Ctrl+C is always handled by the binding, never the kernel.) Restore the dual push (CSI >1u + CSI >4;2m), exactly mirroring the Ink TUI, and complete the alias table for what the kitty disambiguate flag actually emits — NousResearch#87511 left real gaps, some of which its PR body wrongly claimed were covered: - Esc key: ESC[27u (+ modifiers) — previously leaked '[27u' as text - Ctrl+Backspace -> backward-kill-word (NousResearch#78285 was closed on the wrong claim that codepoint-127 mapping existed; it did not) - Shift+Space -> space (NousResearch#86866's second symptom; the Ctrl+Space mapping never covered modifier 2) - Alt+Enter -> newline tuple; Shift+Tab -> BackTab; Ctrl+Tab -> Tab; Alt/Shift+Backspace - Multi-modifier letters (Shift+Alt 4, Ctrl+Shift 6, Ctrl+Alt 7, Ctrl+Alt+Shift 8) normalized onto their Ctrl/Escape-prefix targets, both unshifted (kitty) and shifted (mok emitters) codepoints - Kitty PUA functional keys: keypad -> non-keypad equivalents, F13-F24, and Ignore for lock/media/modifier-event keys so they are consumed instead of leaking (kitty emits these even in legacy mode) Also: clear the VT100 parser's prefix cache after installing (stale answers could misparse), and re-push extended keys after _recover_terminal_input_modes' reset — the recovery previously popped both modes mid-session and never re-enabled them, silently killing Shift+Enter until restart. Refs NousResearch#87511, NousResearch#87074, NousResearch#56684, NousResearch#56645, NousResearch#78285, NousResearch#86866, NousResearch#87390.
13 tasks
4 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What's broken
After yesterday's fix for Ctrl+C (#56684 / commit 4c34eeb), every other key combo in the CLI regressed. Ctrl+A, Ctrl+E, Ctrl+U, Ctrl+K, Ctrl+R, Ctrl+D, Ctrl+W, Ctrl+Z — all dead. Shift+letter typing produces garbage ("caps locked sessions"). Arrow keys, Alt+key — nothing works.
Root cause
Commit
4c34eeb416removed the Kitty keyboard protocol push (CSI >1u) but keptmodifyOtherKeyslevel 2 (CSI >4;2m) so Shift+Enter stays distinguishable from Enter.Under
modifyOtherKeys=2, terminals re-encode every Ctrl+key combo asESC[27;5;<codepoint>~instead of the raw control byte (\x01,\x03, etc.). prompt_toolkit 3.x only has 3 mappings for this format (all for Enter/M = codepoint 13). Every other Ctrl+letter combo arrives as an unmapped escape sequence that leaks as literal text[27;5;97~or gets swallowed by the parser.Shift+letter combos (
ESC[27;2;<codepoint>~) have the same problem — that's the "caps locked" symptom where typed text appears corrupted or stuck.The Ink TUI (Node.js) handles this correctly via a generic regex parser (
MODIFY_OTHER_KEYS_REinparse-keypress.ts). prompt_toolkit 3.x uses exact dict lookup only, with no generic parser.The fix
Add
install_modify_other_keys_aliases()tohermes_cli/pt_input_extras.pythat populates prompt_toolkit'sANSI_SEQUENCESdict with 294 mappings:ESC[27;5;<code>~+ESC[<code>;5uKeys.ControlA..Keys.ControlZKeys.Control0..Keys.Control9[,\,],^,_,@, Space)Keys.Control*ESC[27;3;<code>~+ESC[<code>;3u(Escape, <letter>)tupleESC[27;2;<code>~+ESC[<code>;2uUses
setdefaultsemantics — never clobbers existing mappings frominstall_shift_enter_aliasorinstall_ctrl_enter_alias.Before / after
Before — pressing Ctrl+A under modifyOtherKeys:
After — pressing Ctrl+A under modifyOtherKeys:
Who is affected
Anyone using the CLI on iTerm2, Ghostty, WezTerm, kitty, VS Code terminal, Windows Terminal, or tmux — the allowlisted terminals where
_enable_extended_enter_keys()pushes modifyOtherKeys=2. That's the majority of macOS/Linux users.Related issues and PRs
This PR is the systematic fix for the entire modifyOtherKeys unmapped-sequence bug class. Disposition of related work:
Issues this PR closes
[27;2;<code>~instead of capital on Ghostty. Our PR maps all Shift+letter combos to uppercase characters.Keys.ControlHvia the raw-byte lookup (same as plain Backspace).textInput.tsx).PRs superseded by this PR
ESC[<code>;5u). It was closed because the Kitty protocol push was removed. Our PR covers BOTH formats (modifyOtherKeysESC[27;5;<code>~AND CSI-uESC[<code>;5u), so it supersedes that work.Alternative approach considered
Issue #87390 proposes changing
modifyOtherKeyslevel 2 → level 1 as the fix. Level 1 only reports modified keys whose unmodified form is a control character (Enter=13, Backspace=127), so Shift+letters arrive as plain capitals. This would fix Shift+letter but NOT fix Ctrl+letter combos — under level 1, Ctrl+A still arrives asESC[27;5;97~instead of\x01. Our approach (keep level 2, map everything) fixes the entire bug class.Test plan
tests/cli/test_modify_other_keys_aliases.py— covers every Ctrl+letter (a-z), Ctrl+digit (0-9), Ctrl+symbol, Alt+letter, and Shift+letter combo under both modifyOtherKeys and CSI-u formatstest_ctrl_enter_newline.py,test_cli_shift_enter_newline.py,test_cli_cmd_backspace.pystill passRefs #56684, #86866, #87390, #78285, #37680, #86874, #56645, #37687.