Skip to content

feat(desktop): multi-source agents end-to-end — sockets, roster, SDK, fan-out updates - #86875

Merged
teknium1 merged 1 commit into
mainfrom
feat/desktop-multi-source-agents
Aug 15, 2026
Merged

feat(desktop): multi-source agents end-to-end — sockets, roster, SDK, fan-out updates#86875
teknium1 merged 1 commit into
mainfrom
feat/desktop-multi-source-agents

Conversation

@teknium1

Copy link
Copy Markdown
Contributor

Summary

Multi-source agents are now usable end to end: the renderer keeps concurrent sockets to agents on every registered connection, the union roster (with @name-device handles) is exposed to plugins for Bot Mode, and Settings → Connections can dispatch hermes update to every eligible instance at once.

Phases 3-5 of the multi-connection campaign in one PR (on top of #86679 registry + #86839 composite-key backend routing).

Changes

Renderer socket registry

  • backendScopeKey lives in apps/shared (@hermes/shared) so main-process pool keys and renderer socket keys derive from one rule; the electron twin (tsconfig project boundary) is pinned byte-identical by a cross-copy contract test.
  • store/gateway secondaries are scope-keyed with (connectionId, profile): registry entries dial through getConnectionFor + getGatewayWsUrlFor (fresh OAuth tickets against the right host), events carry the bare profile + a connectionId tag, keepalive touches the scope key, pruning keeps entries whose profile has live work. New ensureGatewayForAgent / openGatewayForAgent fall through to the profile path for local sources — single-source behavior byte-identical.

Union roster + plugin SDK (the Bot Mode door)

  • hermes:agents:roster: concurrent per-connection /api/profiles enumeration (eager REST, lazy sockets; unreachable sources report per-row; undialed ssh stays connect-on-demand) flattened through pure buildAgentRoster — the duplicate-handle rule applied once across all sources.
  • SDK: host.connections(), host.agents(), host.warmAgent(), host.ensureAgent() — feature-detected, plugins degrade cleanly on older builds.

Fan-out updates

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

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

Validation

Check Result
Registry suite (incl. cross-copy contract, roster, eligibility) 39/39 with UI tests
FULL desktop suite (rebased clone, current main) 5471 passed
tsc renderer + electron + shared clean
eslint on touched files clean

Infographic

Multi-Source Agents

@github-actions

github-actions Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

૮ >ﻌ< ა ci review

ran on 7796824 — feat(desktop): multi-source agents end-to-end — sockets, ros

⚠️ Warnings

OSV vulnerability scan · View job

5 known vulnerabilities found in pinned dependencies.

How to fix:

Review the findings in the Security tab. Update the affected dependencies if a patched version is available.


debug info

CI timings

CI timings · View report · View job

Wall time 3m26s vs 3m37s (-5.1%). 11 job(s) slower, 8 faster,

  • JS & TS checks / tests-js / check: -28.0s
  • JS & TS checks / ui-tui / check: -25.0s
  • Docs Site / docs-site-checks: -21.0s
  • JS & TS checks / apps/shared / check: -11.0s
  • JS & TS checks / apps/desktop / check:lint: +11.0s

@alt-glitch alt-glitch added type/feature New feature or request P3 Low — cosmetic, nice to have comp/desktop Electron desktop app (apps/desktop/*) area/profiles Multi-profile isolation, HERMES_HOME scoping labels Aug 15, 2026
… fan-out updates

Phases 3-5 of the multi-connection campaign in one PR (per Teknium), on top
of the registry (#86679) and composite-key backend routing (#86839). Agents
from every registered connection are now usable side by side.

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

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

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

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

Tests: +4 pure cases (cross-copy contract, roster handles, unreachable
sources, update eligibility); FULL desktop suite 5115 passed; tsc renderer +
electron + shared clean; eslint clean.
@teknium1
teknium1 force-pushed the feat/desktop-multi-source-agents branch from cbadd78 to 7796824 Compare August 15, 2026 10:05
@teknium1
teknium1 merged commit aff19d0 into main Aug 15, 2026
38 checks passed
@teknium1
teknium1 deleted the feat/desktop-multi-source-agents branch August 15, 2026 10:11
teknium1 added a commit that referenced this pull request Aug 16, 2026
…#68)

Pulls the multi-source roster into the bundled plugin: profiles.list rows
from the active gateway are merged with the host.agents() union roster
(hermes-agent #86875), so the Bots panel shows agents from every registered
Desktop connection with @name-device handles for duplicates. Feature-detected
and best-effort — an older Desktop build or roster failure leaves the
single-source list untouched.

Adapted for the bundle:
- useRoster queryFn combines the bot_mode_protocol capability read (which
  landed after #68 was cut) with the multi-source merge
- multi-source-roster tests updated for the namespace SDK import harness
- soul-protocol-backfill anchor widened for the new botHandle(name, bot)
  signature

Plugin suite: 143/143.
teknium1 added a commit that referenced this pull request Aug 17, 2026
…#68)

Pulls the multi-source roster into the bundled plugin: profiles.list rows
from the active gateway are merged with the host.agents() union roster
(hermes-agent #86875), so the Bots panel shows agents from every registered
Desktop connection with @name-device handles for duplicates. Feature-detected
and best-effort — an older Desktop build or roster failure leaves the
single-source list untouched.

Adapted for the bundle:
- useRoster queryFn combines the bot_mode_protocol capability read (which
  landed after #68 was cut) with the multi-source merge
- multi-source-roster tests updated for the namespace SDK import harness
- soul-protocol-backfill anchor widened for the new botHandle(name, bot)
  signature

Plugin suite: 143/143.
teknium1 added a commit that referenced this pull request Aug 17, 2026
…#68)

Pulls the multi-source roster into the bundled plugin: profiles.list rows
from the active gateway are merged with the host.agents() union roster
(hermes-agent #86875), so the Bots panel shows agents from every registered
Desktop connection with @name-device handles for duplicates. Feature-detected
and best-effort — an older Desktop build or roster failure leaves the
single-source list untouched.

Adapted for the bundle:
- useRoster queryFn combines the bot_mode_protocol capability read (which
  landed after #68 was cut) with the multi-source merge
- multi-source-roster tests updated for the namespace SDK import harness
- soul-protocol-backfill anchor widened for the new botHandle(name, bot)
  signature

Plugin suite: 143/143.
vashkartik added a commit to vashkartik/hermes-agent that referenced this pull request Aug 17, 2026
* 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 the field too.

Tests call the real app-builder-lib resolver rather than asserting on the text
of package.json, so they track electron-builder's behavior instead of our
formatting.

Co-authored-by: airo7 <airo7@users.noreply.github.com>
Co-authored-by: frankmendes1979 <frankmendes1979@users.noreply.github.com>

* fix(computer-use): auto-repair an installed driver that fails the runtime contract

A same-day version-floor bump (0.20 runtime contract) left every install
with an older cua-driver hard-failing on all computer_use calls: the
start() gate fails closed, while the `hermes update` refresh defers to the
driver's own check-update verb — whose ~20h cache routinely answers "no
update available" right after we raise the floor. Hermes knew it required
0.20+ but never acted on that knowledge.

Two changes:

- tools_config.install_cua_driver(): a contract-failed installed driver is
  repaired on the upgrade=True path too (previously only upgrade=False).
  The contract failure itself is the confirmation, so the
  require_confirmed_update gate and the check-update short-circuit are
  bypassed for repairs — an indeterminate or stale-cached check can no
  longer pin users on an unusable driver.

- cua_backend.CuaDriverBackend.start(): when the contract gate fails on an
  installed binary, attempt one automatic repair per process via the
  standard install path, then re-probe. HERMES_CUA_DRIVER_CMD overrides
  are never repaired (explicit override is authoritative even when broken)
  and a missing binary still just reports the install hint. A failing
  installer can't loop: the second start() surfaces the original error.

Tests: contract-repair coverage in test_computer_use.py (auto-repair
success, failed repair surfaces the original error, once-per-process
guard, override never repaired, missing binary never repaired) and
test_install_cua_driver.py (incompatible driver repairs despite an
indeterminate check-update, check-update not consulted). All new tests
verified to fail against the unfixed source (sabotage run).

* docs(computer-use): note driver contract auto-repair at update and runtime

The runtime-contract repair now also runs during hermes update and once
per session at the first computer_use call (PR #87923); the docs only
mentioned setup and toolset enablement.

* feat: raise Codex OAuth context to live-verified 350K for gpt-5.6 family and gpt-5.4

The Codex /models catalog advertises 272K for the gpt-5.6 (sol/terra/luna)
and gpt-5.4 slugs, but the backend actually accepts ~371K input tokens
(verified live against chatgpt.com/backend-api/codex/responses, Aug 16 2026:
~371K completed OK on all four slugs; ~382K+ rejected with
context_length_exceeded). 350K keeps ~22K margin under the observed ~372K
enforcement.

The bump applies ONLY when the resolved value is exactly the known-stale
272,000 advertisement — any other advertised value (higher or lower) is
trusted as a real server-side change, so a future catalog correction
deactivates the override automatically. gpt-5.5 and gpt-5.4-mini both
genuinely enforce 272K (rejected 360K live) and are excluded.

* fix(tui): restore Alt+Enter for newlines (#87066)

* fix(tui): restore Alt+Enter for newlines

Restore Alt+Enter support for inserting a new line in the TUI after the behavior was lost during newer input-handling updates.

Legacy terminals encode Alt+Enter as ESC followed by carriage return. Preserve those bytes as a single tokenizer sequence and parse the result as Return with the Meta modifier so TextInput inserts a newline instead of submitting.

Keep plain CR and LF mapped to unmodified Return, and cover the legacy ESC+CR sequence with a regression test.

* fix(tui): scope legacy Alt+Enter tokenization

* feat(desktop): expose connection-aware plugin routing

* fix(desktop): report remote plugin target profiles

* fix(desktop): route plugin profiles through registry

* fix(desktop): harden plugin route lifecycle

* fix(desktop): preserve registry route identity

* chore: add contributor email mapping for addelh

* fix(desktop): scope session/pin lists per connection across windows

Multiple Desktop windows share one renderer origin (one localStorage
area) while each window can be connected to a DIFFERENT gateway. The
sidebar pin set (hermes.desktop.pinnedSessions), the manual session
order, and the remembered last-session/route navigation keys were all
persisted under single global (or profile-only) keys, so two windows on
different gateways read and reconciled the same lists: pin-sync's
pullRemotePins() in one window adopted/dropped pins belonging to the
other window's backend, producing the overlapping mixed PINNED/SESSIONS
lists reported after the v0.19.1 update relaunch.

Introduce a connection-scope persistence layer (connectionScopedAtom in
src/lib/connection-scoped.ts): the local connection keeps the bare
legacy key (byte-identical for single-backend users, same contract as
backendScopeKey), while remote connections persist under
`<key>.remote.<encoded baseUrl>.<encoded profile>` — the shape
workspaceCwdKey already established. setConnection() rescopes every
scoped atom when the window's connection changes (null descriptors keep
the current scope, as with syncCronModelImpactConnection), and pin-sync
resets its mirrored/pending/unconfirmed bookkeeping on rescope so a
reconcile never PATCHes one gateway's pins to another.

Legacy globally-keyed values are deliberately not migrated into remote
scopes: ownership of rows accumulated by every window is unknowable
(the #67709 precedent), and backend-mirrored pins self-heal from the
gateway's own `pinned` rows.

Fixes #77318

* fix(desktop): keep profile rail alive across remote/Cloud connection switches

A connection/mode apply (soft re-home) moves /api/profiles routing to a new
backend, but nothing deterministically re-fetched the rail's $profiles list
and a stale in-flight response from the previous backend could land last and
collapse the rail to Home (#85731).

- store/profile: epoch-guard refreshProfiles/refreshActiveProfile so a
  response fetched against the previous backend never writes the shared cache
  (invalidateProfileListFetches), and bump the epoch on live profile swaps.
- store/gateway-switch: strand in-flight profile-list fetches in the same
  wipe every connection/mode apply funnels through.
- use-gateway-boot: explicitly re-pull the active profile + list from the NEW
  backend during softSwitch, best-effort like its sibling fetches.

Fixes #85731

* fix(desktop): read cron run-history from the owning gateway

When Hermes Desktop works against a REGISTERED gateway connection, cron
jobs execute on that gateway and persist their run sessions in the
gateway's state.db. But every REST call in the app — the cron surface
included — carried only `profile`, so `hermes:api` routed it through the
local profile pool and `_list_cron_job_runs_sync` read a local state.db
with zero `source='cron'` rows. Every job showed "No runs yet" while the
same endpoint on the gateway returned the real runs (#87882).

Fix at the routing seam:

- HermesApiRequest gains an optional `connectionId`. The renderer's cron
  helpers (list/get/runs/delivery-targets/create/update/pause/resume/
  trigger/delete/blueprints) now tag the active registry connection via a
  new connectionScoped() twin of profileScoped(), fed from the same
  setApiRequestConnection seam store/gateway already maintains for the
  plugin socket.
- The hermes:api main-process handler resolves a tagged request through
  ensureRegistryBackend — the SAME pool the job list and WS traffic use —
  instead of the legacy profile route. Shared remote/cloud hosts (one
  gateway, many profiles) get the path scoped with ?profile= via the new
  pathWithProfileScope helper, factored out of pathWithGlobalRemoteProfile.
- '' / 'local' / absent connectionId keep the byte-identical v1 route, so
  single-source and connection-config-remote users are unaffected.

This covers the run-history panel, the sidebar cron peek, and every other
cron surface in one place, since they all funnel through the same helpers.

Fixes #87882

* fmt(js): `npm run fix` on merge (#88014)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* test(desktop): pin steered-turn transcript order end-to-end

A steered turn's contract — pre-steer output above the correction bubble,
post-steer output and the settled reply below it — was fixed across
several PRs (#73793/#83151 class, settle fixes) but only covered piecewise:
the mid-turn insert as a unit, the settle math as a unit. Nothing drove the
real stream reducer through a whole steered turn, and nothing asserted the
durable-row hydration renders the same order after reload.

Two suites close that:
- steer-arrival-order: full event sequences through useMessageStream's real
  handler + the real optimistic insert — single steer with tool activity,
  steer racing message.complete, double steer in one turn.
- steered-turn-hydration-order: toChatMessages over persisted row shapes
  copied from a real state.db steered turn, including a tool result that
  lands after the correction row.

* test(desktop): harden steer-order suite against fake-timer id collisions

Review follow-ups: steer ids now come from a monotonic counter instead of
Date.now() (frozen under fake timers — two steers without a clock advance
would have collided), and the settle-above assertion documents its
load-bearing sealed-bubble assumption.

* test(desktop): steer suite drives the real redirectPrompt path; hydration fixture carries durable row shape

The live suite previously called appendMidTurnUserMessage directly, leaving
redirectPrompt's appendAfterActiveReply guard — the production decision of
WHERE a correction lands — outside the harness. Both hooks now mount together
sharing one state map, exactly as the desktop wires them, so a regression in
the caller (not just the insert) goes red. Verified by mutation: disabling the
guard fails 2/4.

Also covers the rejected-redirect path: a not_running response discards the
optimistic bubble instead of stranding a correction the model never saw.

The hydration fixture now carries the durable row shape the client actually
receives (row_id, reasoning, provider call_id/response_item_id on tool_calls)
instead of a hand-simplified echo, so the 'mirrors real state.db rows' claim
is honest. The fake-timer steer id counter is gone with the local insert —
ids come from redirectPrompt itself.

* fmt(js): `npm run fix` on merge (#88016)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* feat(desktop): bundle Bot Mode (hermes-bots) as a built-in, default-on plugin

Adopts the Hermes-Bot-Mode desktop plugin (NousResearch/Hermes-Bot-Mode)
into apps/desktop/src/plugins/hermes-bots/, registered by the bundled
vite glob and ON by default. It stays a pure @hermes/plugin-sdk consumer
in plain-ESM plugin.js form; users disable it live in Settings > Plugins.

- contrib/plugins.ts: bundled glob accepts plugin.js entries
- contrib/runtime-loader.ts: a disk/runtime copy of an id that ships
  bundled is skipped (standalone installs predating adoption cannot
  double-register)
- package.json: check:test:plugins runs the plugin's node:test suite in
  CI (138 tests)
- source: Hermes-Bot-Mode @ c19baba, incl. today's #107/#103/#99 merges

* feat(agent): core Bot Mode teammate protocol — stable-tier prompt section

Replaces the plugin-side SOUL.md protocol append: on Bot-Mode-managed
installs (any profile carrying ui_meta['hermes-bots']) the prompt builder
injects the "Messaging other agents" section into every session of every
profile — including headless `hermes -p <bot> chat` sessions a teammate
starts — so bot handoffs work without mutating user-authored SOUL files.

- tools/bot_mode_probe.py: silent-when-unmanaged probe, cached per
  (process, home), keyed off the agent's OWN home (not ambient
  HERMES_HOME); silent when SOUL.md already carries the legacy section
- agent/system_prompt.py + agent_init.py + config_defaults.py: wired as
  agent.bot_mode_protocol (default True), stable tier, byte-stable
  across rebuilds (E2E-verified against the real build_system_prompt)
- tui_gateway profiles.list gains bot_mode_protocol capability flag;
  the bundled plugin gates ALL SOUL protocol writes on it (backfill,
  composeSoul, Edit save) — older gateways keep the SOUL-append path
- overhead: ~916 bytes, only on Bot-Mode installs; zero elsewhere

Supersedes the SOUL backfill half of Hermes-Bot-Mode#99 (credit
@kaduxo — the handle fix, `hermes profile list` correction, and
idempotent-append guards from that PR ship in the bundled plugin).

* fix: track bundled plugin.js sources past the tsc-artifact gitignore

apps/desktop/src/**/*.js is gitignored (stale tsc output shadows .tsx),
which silently dropped the hermes-bots plugin.js from the adoption
commit — tests shipped, source didn't, CI ENOENT'd. Negate the pattern
for src/plugins/*/plugin.js: adopted plain-ESM plugins have no .tsx
sibling, so the shadow hazard cannot apply.

* fix(agent): scope the Bot Mode protocol section to canonical Bot Chat sessions

Per review: the protocol belongs only in official Bot Mode interactions,
not every session on a managed install. The prompt builder now injects
the section only when the agent's session row is titled "Bot Chat"
(BOT_CHAT_TITLE, matching the desktop's createCanonicalChat pin and the
`hermes -p <bot> chat -c "Bot Chat"` resume target). Regular sessions
never carry it; the desktop composer middleware owns @mention sends.

Title is read once at first prompt build and the rendered prompt is
cached + DB-restored — cache-safe. E2E against the real AIAgent +
SessionDB: absent in an untitled session, present in Bot Chat,
byte-stable across rebuilds, absent after retitle, absent with the
flag off. Overhead unchanged (~916B, Bot Chat sessions only).

* fix(hermes-bots): composeSoul honors the bot_mode_protocol capability

Found in live desktop E2E: the generated-identity path of composeSoul
still appended the protocol section even when the backend injects it
into the system prompt. New agents now get a clean identity-only SOUL
against capable backends; older gateways keep the append. Covered in
the capability-suppression test.

* fix(agent): Bot Chat gate reads a session-title hint before the DB

Live desktop E2E caught a write-ordering bug the automated E2E missed:
tui_gateway applies pending_title to state.db AFTER the first turn, but
the system prompt builds at turn START — the DB-title gate saw nothing
and the Bot Chat was cached protocol-less forever. The gateway now
hands the agent its intended title at construction and the gate checks
the hint first, DB second (CLI/messaging-gateway paths unchanged).

Live-verified on the running desktop: fresh bot's Bot Chat persisted
with the protocol section, handle, and roster in its system prompt;
regular sessions and SOUL.md untouched.

* feat(agent): capability-refresh + timeless prompts for eternal Bot Chat sessions

Bot Chats break the "new sessions come often" assumption behind
build-once system prompts: capability edits used to sit invisible until
/new or compression, and the frozen birth date became misinformation.

- tools/bot_mode_probe.py: capability_fingerprint() hashes the profile's
  capability surface (disabled skills, toolset pins, MCP config, SOUL.md,
  installed skills, Bot-Mode roster); Bot Chat prompts embed the 12-hex
  epoch stamp
- agent/conversation_loop.py restore path: stored Bot Chat prompt whose
  epoch mismatches disk → ONE rebuild (through a cleared skills-prompt
  cache so new installs appear), persisted so the next turn reuses the
  new bytes verbatim. Prompts without a stamp — every non-Bot-Chat
  session — never take the branch; probe failure fails closed to reuse
- agent/system_prompt.py: Bot Chat prompts are timeless — the
  "Conversation started:" date is dropped (timezone kept); no ticking
  fields in an eternal session
- tui_gateway: _sync_bot_capabilities at turn start rebuilds the live
  agent (tool definitions are construction-baked) when the fingerprint
  moves, same session id/history, with a user-visible notice

Cache stance: this is the /model exception applied to capabilities — a
loud, user-initiated, once-per-change prefix break. Unchanged state
hashes identically and stored bytes are reused verbatim (E2E-proven).

Validation: 9 probe unit tests incl. per-axis fingerprint changes;
E2E v3 against the real restore path (fresh build → verbatim reuse →
skill install → single refresh w/ new skill in index → verbatim reuse;
regular sessions dated, unstamped, never refreshed); tests/agent/
4647/4647.

* feat(agent): one-time protocol upgrade for legacy Bot Chat sessions

Bot Chats created before the epoch mechanism persisted prompts with no
protocol section and no stamp — the staleness check only fires on
stamped prompts, so pre-existing bots would never learn to message
teammates. stored_bot_chat_prompt_needs_upgrade() migrates them: one
rebuild, title-gated to Bot Chat, only when the probe would actually
emit a section (SOUL-append legacies and unmanaged installs are left
alone — rebuilding those would loop). The rebuilt prompt carries the
stamp, so the upgrade can never re-fire.

E2E v3b through the real restore path: legacy Bot Chat upgraded once
then verbatim-reused; legacy regular sessions byte-untouched.
tests/agent/ 4648/4648.

* fix: capability fingerprint reads config via the canonical loader

The config-read guard (test_config_read_guard) correctly flagged the
probe's raw yaml.safe_load of config.yaml — raw reads miss the managed
overlay, env expansion, and normalization. Use load_config_readonly()
under a scoped HERMES_HOME override instead. E2E v3/v3b and the guard
both green.

* feat: sync bundled Bot Mode with multi-source roster (Hermes-Bot-Mode#68)

Pulls the multi-source roster into the bundled plugin: profiles.list rows
from the active gateway are merged with the host.agents() union roster
(hermes-agent #86875), so the Bots panel shows agents from every registered
Desktop connection with @name-device handles for duplicates. Feature-detected
and best-effort — an older Desktop build or roster failure leaves the
single-source list untouched.

Adapted for the bundle:
- useRoster queryFn combines the bot_mode_protocol capability read (which
  landed after #68 was cut) with the multi-source merge
- multi-source-roster tests updated for the namespace SDK import harness
- soul-protocol-backfill anchor widened for the new botHandle(name, bot)
  signature

Plugin suite: 143/143.

* feat: raise Codex OAuth context to 900K for gpt-5.6 family and gpt-5.4 (subscription 1M rollout)

OpenAI enabled the large-context window for ChatGPT-subscription Codex
accounts (announced by @thsottiaux Aug 16 2026; previously API-key-only).
Live re-probe the same day: 911,276 input tokens completed OK on
gpt-5.6-sol; ~925K+ rejected with context_length_exceeded (1.05M window
minus reserved output headroom). terra, luna, and gpt-5.4 all completed
900,026 tokens OK. The Codex catalog still advertises 272K, so the
stale-advertisement override from #87981 is the right lever — this just
raises its value 350K -> 900K.

gpt-5.5 and gpt-5.4-mini still enforce 272K live (rejected 500K) and
remain excluded. Override semantics unchanged: fires only on an
exactly-272,000 advertisement; any live catalog change is trusted
verbatim.

* fix(desktop): map SSH profile aliases in REST paths

* chore: map contributor email for attribution audit

* fmt(js): `npm run fix` on merge (#88079)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* fix(cron): stop retry storms when the gateway is deliberately stopped (OOF-266)

Since the managed-cron redesign (#84339, v2026.8.13) the dashboard fire
webhook forwards fires to the gateway process and returns 503 when it is
unreachable so NAS/QStash retries. Correct for transient windows — but an
operator-STOPPED gateway can never be fixed by retrying: every fire on
every job burns the full scheduler retry budget, NAS converts each 503 to
a retryable 502, and the resulting storms page on-call for a non-incident
(OOF-266 and its five duplicate tickets; +93% relay callback failures as
the fleet adopted v2026.8.13).

Split the unreachable path by durable operator intent:

- desired_state == "stopped" (written only by the s6 lifecycle commands;
  the same intent signal container-boot reconciliation trusts) -> drop
  the fire with 200 + a structured log line, mirroring NAS's own
  instance_stopped drop. Jobs are not lost: the Chronos provider
  reconciles and re-arms every job on the next gateway start.
- Anything else (crash loop, scale-to-zero wake, restart, legacy state
  file without desired_state) -> keep the retryable 503, now stamped
  with Retry-After: 60 so a scheduler that honors it spaces retries
  past the wake/restart window instead of exhausting them inside it.
  The gateway's own pass-through 503s (draining) get the same hint.

The intent check fails open (any parse/resolution error -> retryable
path) and is only consulted when the gateway is actually unreachable, so
a stale state file can never shadow a live gateway.

* feat(state): support the context-manager protocol on SessionDB

A SessionDB handle cannot be released by dropping the last reference.
Once its background token writer starts, the instance pins ITSELF two
ways: the writer thread's target is a bound method, and
queue_token_counts registers atexit.register(_drain_token_queue_at_exit),
which only close() unregisters. A dropped-but-pinned handle keeps its
state.db/-wal/-shm descriptors for the life of the process, and __del__
never runs for it, so the existing safety net is dead code for exactly
the instances that leak.

That is why owning call sites are expected to close explicitly, in those
words, in the ownership comments in run_agent.py and
tui_gateway/methods_session.py. This adds the ergonomic half of that
contract so an owner can scope a handle and be exception-safe by
construction:

    with SessionDB(path) as db:
        db.append_message(...)

Purely additive. __enter__ returns self, __exit__ closes and returns
False so a caller's exception always propagates, and close() is already
idempotent, so a scope that closes early still exits cleanly. Nothing
changes for callers that already close directly.

Four regressions cover the scope closing the handle, __enter__ returning
the instance itself, the failure path closing while still propagating,
and an early close leaving the exit clean. They assert on the
sqlite_safe_read tracking registry rather than raw descriptor counts,
matching test_session_db_read_conn_pool.py, because SQLite's unix VFS
parks a closed descriptor on a per-inode reuse list and makes raw counts
lag the real connection count.

Refs #88033

* fix(state): release abandoned session database handles

* fix(state): avoid overlapping context manager change

* fix(gateway): ignore invalid managed Node directories

Signed-off-by: Shawn Wang <32839114+enwaiax@users.noreply.github.com>

* fix(gateway): accept CJK full-width punctuation as MEDIA path terminators

MEDIA_TAG_CLEANUP_RE (and MEDIA_EXTENSIONLESS_TAG_RE) only recognized
ASCII terminators after a MEDIA:<path> tag. Chinese-language agent
output naturally writes MEDIA:D:\...\zhibao.pdf(782.6 KB)or ...pdf:内容 —
the full-width punctuation failed the trailing lookahead and the
attachment was silently dropped (cron even reported 'delivered') (#88038).

Both lookaheads now accept a CJK full-width terminator set (()〈〉《》:,。;
!?、curly quotes【】) alongside the ASCII set. The #68773 adjacent-tag
splitting guard is covered by a regression test.

* fix(skills): rescan skill commands cache when active profile changes

Switching Desktop profiles mid-session changes HERMES_HOME but not the
platform scope, so get_skill_commands() kept serving the previous
profile's skill list. A skill only available under the new profile then
looked like a cache miss to callers such as slash.exec, which fall
through to the slash_worker dead path (#88023).

* fix(gateway): scope slash.exec's skill-command check to the session's profile

Independent review of the prior commit found the cache-invalidation key
alone doesn't fix the reported #88023 dead path: slash.exec runs as a
_LONG_HANDLER on the pool with a copied context, and no binding of
_HERMES_HOME_OVERRIDE happens between the transport read and the handler
body, so get_skill_commands() there always fell back to the process-level
HERMES_HOME regardless of which profile's session issued the request.

Bind the session's own profile_home around the get_skill_commands() check,
mirroring the same bind/reset-in-finally pattern already used at every
other per-turn HERMES_HOME scoping site (e.g. server.py's prompt-turn and
system-prompt-rebuild paths). This makes the #88023 dead path actually
reachable by the fix instead of only exercising the cache primitive in
isolation.

* feat(desktop): add status bar reconnect for offline gateways

Expose the existing profile-aware gateway boot reconnect path through a
single-flight renderer action, and surface a Reconnect button in the
gateway status menu panel whenever the socket is not open. Repeated
clicks share one in-flight reconnect; failures surface through the
existing non-destructive notification UI. Localized copy for all
supported Desktop locales.

Salvaged from PR #80694 (net diff re-applied onto current main; panel
code lives in app/shell/gateway-menu-panel.tsx now).

* fix(desktop): self-heal dropped SSH/HTTP registered remote connections

A dropped registered remote connection (SSH or HTTP) never recovered on
its own: the next boot attempt failed with a transient transport error
("Could not verify the existing SSH backend", ERR_CONNECTION_RESET,
mint timeout), the failure was correctly NOT latched, but nothing ever
re-attempted the boot — the renderer's reconnect machinery only arms
after a completed boot. The app parked on "Desktop boot failed" until
the user manually deleted and re-entered the same connection details,
which merely forced the fresh bootstrap an automatic retry would have
performed (issue 82679, feature ask 80430).

Root causes and fixes:

- electron/backend-start-failure.ts: new isRetryableRemoteBootFailure()
  predicate — a remote, non-reauth boot failure is transient and may be
  retried; local failures and confirmed 401/403 rejections are not
  (a missing capability differs from a transient failure).
- electron/main.ts: the boot-failure progress broadcast now carries
  `retryable` (rides with `error` through updateBootProgress), and a
  failed reuse probe against a cached SSH master tears the stale
  master/tunnel down so the next attempt bootstraps fresh — exactly
  what manual re-entry did.
- use-gateway-boot.ts: bounded self-heal loop for a failed boot whose
  progress is marked retryable — up to 5 re-attempts with the same
  full-jitter backoff as the socket reconnect loop (2s base, 15s cap).
  Exhausted retries end in the real boot-failure recovery overlay,
  never an infinite spinner. Reset on success and on soft switch;
  timer cleared on unmount.
- store/boot.ts: resumeDesktopBootForRetry() re-arms the overlay with a
  retry status while an automatic retry is in flight.

Secondaries already had full-jitter backoff (store/gateway.ts); this
closes the same class for the PRIMARY/registered-connection path.

Tests: predicate matrix (retryable vs reauth-latch mutually exclusive),
plus renderer hook tests proving a transient SSH failure self-heals on
the next attempt, retries are bounded (6 total dials then the recovery
overlay, no further attempts), and non-retryable failures never enter
the loop. Sabotage-verified (disabling either half fails 4 tests).

Fixes #82679
Fixes #80430

* feat(desktop): support remote gateway headers

* feat(desktop): carry remote gateway headers through the connections registry, test probes, and Settings UI

Completes PR #74468 (remote gateway headers for Cloudflare Access, #74466)
against the v2 multi-connection registry that landed after the PR was
authored, and closes the review blockers:

- connection-registry: additive optional `headers` field on remote/cloud
  entries (normalized through the same forbidden-name filter, secret
  envelopes like `token`); inherited on edit, treated as dial material by
  connectionDialFieldsChanged, preserved by normalizeRegistry, and carried
  through migrateV1ToRegistry. v2 registries without the field load
  unchanged — no version bump.
- main.ts registry paths: connectRegistryBackend dials with the entry's
  headers (readiness probe, ticket mint, descriptor REST via
  getJsonForBackend/fetchJsonForBackend, registry ws-url minting with
  rememberRemoteWsHeaders so renderer upgrades get them injected).
- saveRegistryConnection encrypts incoming plaintext header values with the
  same safeStorage/allowPlainText seam as tokens; sanitizeRegistryConnection
  exposes only header NAMES to the renderer — values never cross IPC.
- Connection tests exercise the leg they validate: both
  hermes:connection-config:test and hermes:connections:test now send the
  configured headers on the HTTP status call, the ws-ticket mint, AND the
  live WebSocket probe (probeGatewayWebSocket grew an injectable `headers`
  option passed as the undici WebSocket constructor's second argument).
- Settings → Connections gains an "Extra gateway headers" editor for
  remote/cloud entries (name + secret value rows, stored values shown as
  saved-but-hidden, clearable), with i18n keys (en + zh; other locales fall
  back through defineLocale).

* chore: map contributor email for tigercraft4 (PR #74468 salvage)

* feat(delegation): record model/provider in live-transcript manifest (#telemetry)

* fix(gateway): attribute scoped credential lock conflicts to the owning profile (OOF-3)

Scoped credential locks (Telegram bot token, Discord bot token, etc.) are
machine-global, but the conflict error only reported the holder's PID:

    Telegram bot token already in use (PID 559). Stop the other gateway first.

On multi-profile hosts (e.g. hosted instances running 13 profiles), a bare
PID gives the operator no way to tell WHICH profile owns the credential —
the exact failure mode observed on zerocool-9781, where the 'default'
profile was misconfigured with the same bot token as 'lead-gen-outreach'
and logged an unattributable conflict every ~5 minutes (4,602 rows).

Fix:
- acquire_scoped_lock() now stamps a 'profile' label on lock records,
  inferred from the process HERMES_HOME (<root>/profiles/<name> layouts,
  'default' for the root home). Omitted when not inferable.
- New scoped_lock_owner_label() resolves the owning profile from a lock
  record: prefers the explicit field, falls back to inferring from the
  persisted hermes_home for locks written before the field existed.
  Labels are validated against the profile-id grammar before use (lock
  files are plain JSON on disk and the label flows into log lines and a
  suggested CLI command).
- _acquire_platform_lock() conflict message now names the owning profile
  and gives the correct remedy:

    Telegram bot token already in use by the 'lead-gen-outreach' profile
    gateway (PID 559). Stop that gateway first
    (hermes --profile lead-gen-outreach gateway stop).

  Records with no attribution signal keep the original PID-only wording.

Testing:
- New TestScopedLockOwnerLabel suite covering label inference (named,
  Docker, root/default, unknown layouts), grammar validation, explicit-
  field preference, hermes_home fallback, and legacy/malformed records.
- acquire_scoped_lock tests for profile stamping and omission.
- Adapter-level tests for profile-attributed, legacy-home-inferred, and
  PID-only conflict messages.
- 76/76 targeted gateway tests pass; broad gateway suite failures are
  baseline-identical (verified via git stash comparison). Ruff clean.

* fix(gateway): surface multiplex profile failures (OOF-3)

* fix(status): aggregate independent per-profile gateway failures; harden key filter (OOF-3)

- /api/status now folds LIVE independent per-profile gateways' platform
  failures (gateway_mode == 'multiple', the OOF-3 deployment mode) into
  gateway_platforms under the validated <profile>:<platform> grammar, so
  NAS fleet health sees them without a schema change. ?profile= requests
  stay unmerged (single-profile view).
- Namespaced-key validation no longer fails open: colon-containing keys
  are grammar-checked even when configured-platform loading throws.
- Platform key segment now accepts hyphens, matching plugin platform IDs
  (plugins/platforms/<dir> names, e.g. foo-bar).

* fix(status): freshness-filter aggregated per-profile platform entries (OOF-3)

Gateway startup deliberately preserves plain platform entries in
gateway_state.json across restarts, and the active-profile endpoint
compensates by filtering against current configuration. The cross-profile
aggregation copied raw maps, so a fatal entry for a platform the operator
had since disabled/removed could keep NAS reporting the instance degraded
indefinitely.

The aggregation has no cheap per-profile config context (platform sets
depend on tokens in each profile's .env behind its secret scope), so use
freshness instead: an entry is aggregatable only when its updated_at is
at/after the live gateway process's create time (validated PID via
get_runtime_status_running_pid + psutil create_time; the record's own
start_time field is a PID-reuse fingerprint in clock ticks, not a
timestamp). Config changes require a restart to take effect, so
restart-anchored freshness is exactly the config filter's semantics.
Fail closed: unparseable timestamps or no live process exclude the entry
— a false 'degraded forever' is the worse failure mode.

* fix(status): strict writer-identity ownership for aggregated platform entries (OOF-3)

The freshness window (updated_at >= live process create_time - 2s) had a
P1 boundary hole: a stale failure written by the PREVIOUS process
immediately before a fast restart landed inside the slack and was
aggregated; if that platform was then removed, the new process never
replaces the entry and NAS stays degraded indefinitely.

Replace clock heuristics with persisted writer identity:

- write_runtime_status now stamps every platform entry with the writing
  process's (writer_pid, writer_start_time) — the same PID-reuse
  fingerprint the liveness checks use, so a recycled PID never
  masquerades as the original writer.
- The aggregation ownership filter requires exact equality between an
  entry's stamp and the profile's validated live gateway process
  (get_runtime_status_running_pid + _get_process_start_time). No slack,
  no timestamps. Legacy entries without a stamp fail closed.
- Writer stamps are process recon (same class as the auth-gated
  gateway_pid) and are stripped from all /api/status projections, both
  active-profile and merged cross-profile entries.

Near-boundary regression test: prior-process entry stamped 100ms before
restart is excluded; recycled-pid-different-fingerprint excluded;
legacy no-stamp excluded; current-process entry kept.

* docs(state): soften stale SessionDB self-pin wording after #88063

#88048 documented the token-writer self-pin (bound-method thread target +
strong atexit hook) as a permanent contract: "__del__ never runs for
exactly the instances that leak". #88063 then removed both pins (idle
writer retirement + weakref atexit hook), making abandoned handles
eventually collectible.

Reword the __enter__ docstring and the context-manager test module
docstring to describe the pin as historical motivation, note the #88063
behavior, and keep the guidance that owners close deterministically.
No code changes.

* fix(desktop): keep cloud bot avatar eye catchlights inside the eyes

The white catchlight dots in BotFace were static circles pinned at the
circle-face eye line (cy 16.5), whi…
mtbitcr added a commit to mtbitcr/hermes-agent that referenced this pull request Aug 17, 2026
* 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 the field too.

Tests call the real app-builder-lib resolver rather than asserting on the text
of package.json, so they track electron-builder's behavior instead of our
formatting.

Co-authored-by: airo7 <airo7@users.noreply.github.com>
Co-authored-by: frankmendes1979 <frankmendes1979@users.noreply.github.com>

* fix(computer-use): auto-repair an installed driver that fails the runtime contract

A same-day version-floor bump (0.20 runtime contract) left every install
with an older cua-driver hard-failing on all computer_use calls: the
start() gate fails closed, while the `hermes update` refresh defers to the
driver's own check-update verb — whose ~20h cache routinely answers "no
update available" right after we raise the floor. Hermes knew it required
0.20+ but never acted on that knowledge.

Two changes:

- tools_config.install_cua_driver(): a contract-failed installed driver is
  repaired on the upgrade=True path too (previously only upgrade=False).
  The contract failure itself is the confirmation, so the
  require_confirmed_update gate and the check-update short-circuit are
  bypassed for repairs — an indeterminate or stale-cached check can no
  longer pin users on an unusable driver.

- cua_backend.CuaDriverBackend.start(): when the contract gate fails on an
  installed binary, attempt one automatic repair per process via the
  standard install path, then re-probe. HERMES_CUA_DRIVER_CMD overrides
  are never repaired (explicit override is authoritative even when broken)
  and a missing binary still just reports the install hint. A failing
  installer can't loop: the second start() surfaces the original error.

Tests: contract-repair coverage in test_computer_use.py (auto-repair
success, failed repair surfaces the original error, once-per-process
guard, override never repaired, missing binary never repaired) and
test_install_cua_driver.py (incompatible driver repairs despite an
indeterminate check-update, check-update not consulted). All new tests
verified to fail against the unfixed source (sabotage run).

* docs(computer-use): note driver contract auto-repair at update and runtime

The runtime-contract repair now also runs during hermes update and once
per session at the first computer_use call (PR #87923); the docs only
mentioned setup and toolset enablement.

* feat: raise Codex OAuth context to live-verified 350K for gpt-5.6 family and gpt-5.4

The Codex /models catalog advertises 272K for the gpt-5.6 (sol/terra/luna)
and gpt-5.4 slugs, but the backend actually accepts ~371K input tokens
(verified live against chatgpt.com/backend-api/codex/responses, Aug 16 2026:
~371K completed OK on all four slugs; ~382K+ rejected with
context_length_exceeded). 350K keeps ~22K margin under the observed ~372K
enforcement.

The bump applies ONLY when the resolved value is exactly the known-stale
272,000 advertisement — any other advertised value (higher or lower) is
trusted as a real server-side change, so a future catalog correction
deactivates the override automatically. gpt-5.5 and gpt-5.4-mini both
genuinely enforce 272K (rejected 360K live) and are excluded.

* fix(tui): restore Alt+Enter for newlines (#87066)

* fix(tui): restore Alt+Enter for newlines

Restore Alt+Enter support for inserting a new line in the TUI after the behavior was lost during newer input-handling updates.

Legacy terminals encode Alt+Enter as ESC followed by carriage return. Preserve those bytes as a single tokenizer sequence and parse the result as Return with the Meta modifier so TextInput inserts a newline instead of submitting.

Keep plain CR and LF mapped to unmodified Return, and cover the legacy ESC+CR sequence with a regression test.

* fix(tui): scope legacy Alt+Enter tokenization

* feat(desktop): expose connection-aware plugin routing

* fix(desktop): report remote plugin target profiles

* fix(desktop): route plugin profiles through registry

* fix(desktop): harden plugin route lifecycle

* fix(desktop): preserve registry route identity

* chore: add contributor email mapping for addelh

* fix(desktop): scope session/pin lists per connection across windows

Multiple Desktop windows share one renderer origin (one localStorage
area) while each window can be connected to a DIFFERENT gateway. The
sidebar pin set (hermes.desktop.pinnedSessions), the manual session
order, and the remembered last-session/route navigation keys were all
persisted under single global (or profile-only) keys, so two windows on
different gateways read and reconciled the same lists: pin-sync's
pullRemotePins() in one window adopted/dropped pins belonging to the
other window's backend, producing the overlapping mixed PINNED/SESSIONS
lists reported after the v0.19.1 update relaunch.

Introduce a connection-scope persistence layer (connectionScopedAtom in
src/lib/connection-scoped.ts): the local connection keeps the bare
legacy key (byte-identical for single-backend users, same contract as
backendScopeKey), while remote connections persist under
`<key>.remote.<encoded baseUrl>.<encoded profile>` — the shape
workspaceCwdKey already established. setConnection() rescopes every
scoped atom when the window's connection changes (null descriptors keep
the current scope, as with syncCronModelImpactConnection), and pin-sync
resets its mirrored/pending/unconfirmed bookkeeping on rescope so a
reconcile never PATCHes one gateway's pins to another.

Legacy globally-keyed values are deliberately not migrated into remote
scopes: ownership of rows accumulated by every window is unknowable
(the #67709 precedent), and backend-mirrored pins self-heal from the
gateway's own `pinned` rows.

Fixes #77318

* fix(desktop): keep profile rail alive across remote/Cloud connection switches

A connection/mode apply (soft re-home) moves /api/profiles routing to a new
backend, but nothing deterministically re-fetched the rail's $profiles list
and a stale in-flight response from the previous backend could land last and
collapse the rail to Home (#85731).

- store/profile: epoch-guard refreshProfiles/refreshActiveProfile so a
  response fetched against the previous backend never writes the shared cache
  (invalidateProfileListFetches), and bump the epoch on live profile swaps.
- store/gateway-switch: strand in-flight profile-list fetches in the same
  wipe every connection/mode apply funnels through.
- use-gateway-boot: explicitly re-pull the active profile + list from the NEW
  backend during softSwitch, best-effort like its sibling fetches.

Fixes #85731

* fix(desktop): read cron run-history from the owning gateway

When Hermes Desktop works against a REGISTERED gateway connection, cron
jobs execute on that gateway and persist their run sessions in the
gateway's state.db. But every REST call in the app — the cron surface
included — carried only `profile`, so `hermes:api` routed it through the
local profile pool and `_list_cron_job_runs_sync` read a local state.db
with zero `source='cron'` rows. Every job showed "No runs yet" while the
same endpoint on the gateway returned the real runs (#87882).

Fix at the routing seam:

- HermesApiRequest gains an optional `connectionId`. The renderer's cron
  helpers (list/get/runs/delivery-targets/create/update/pause/resume/
  trigger/delete/blueprints) now tag the active registry connection via a
  new connectionScoped() twin of profileScoped(), fed from the same
  setApiRequestConnection seam store/gateway already maintains for the
  plugin socket.
- The hermes:api main-process handler resolves a tagged request through
  ensureRegistryBackend — the SAME pool the job list and WS traffic use —
  instead of the legacy profile route. Shared remote/cloud hosts (one
  gateway, many profiles) get the path scoped with ?profile= via the new
  pathWithProfileScope helper, factored out of pathWithGlobalRemoteProfile.
- '' / 'local' / absent connectionId keep the byte-identical v1 route, so
  single-source and connection-config-remote users are unaffected.

This covers the run-history panel, the sidebar cron peek, and every other
cron surface in one place, since they all funnel through the same helpers.

Fixes #87882

* fmt(js): `npm run fix` on merge (#88014)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* test(desktop): pin steered-turn transcript order end-to-end

A steered turn's contract — pre-steer output above the correction bubble,
post-steer output and the settled reply below it — was fixed across
several PRs (#73793/#83151 class, settle fixes) but only covered piecewise:
the mid-turn insert as a unit, the settle math as a unit. Nothing drove the
real stream reducer through a whole steered turn, and nothing asserted the
durable-row hydration renders the same order after reload.

Two suites close that:
- steer-arrival-order: full event sequences through useMessageStream's real
  handler + the real optimistic insert — single steer with tool activity,
  steer racing message.complete, double steer in one turn.
- steered-turn-hydration-order: toChatMessages over persisted row shapes
  copied from a real state.db steered turn, including a tool result that
  lands after the correction row.

* test(desktop): harden steer-order suite against fake-timer id collisions

Review follow-ups: steer ids now come from a monotonic counter instead of
Date.now() (frozen under fake timers — two steers without a clock advance
would have collided), and the settle-above assertion documents its
load-bearing sealed-bubble assumption.

* test(desktop): steer suite drives the real redirectPrompt path; hydration fixture carries durable row shape

The live suite previously called appendMidTurnUserMessage directly, leaving
redirectPrompt's appendAfterActiveReply guard — the production decision of
WHERE a correction lands — outside the harness. Both hooks now mount together
sharing one state map, exactly as the desktop wires them, so a regression in
the caller (not just the insert) goes red. Verified by mutation: disabling the
guard fails 2/4.

Also covers the rejected-redirect path: a not_running response discards the
optimistic bubble instead of stranding a correction the model never saw.

The hydration fixture now carries the durable row shape the client actually
receives (row_id, reasoning, provider call_id/response_item_id on tool_calls)
instead of a hand-simplified echo, so the 'mirrors real state.db rows' claim
is honest. The fake-timer steer id counter is gone with the local insert —
ids come from redirectPrompt itself.

* fmt(js): `npm run fix` on merge (#88016)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* feat(desktop): bundle Bot Mode (hermes-bots) as a built-in, default-on plugin

Adopts the Hermes-Bot-Mode desktop plugin (NousResearch/Hermes-Bot-Mode)
into apps/desktop/src/plugins/hermes-bots/, registered by the bundled
vite glob and ON by default. It stays a pure @hermes/plugin-sdk consumer
in plain-ESM plugin.js form; users disable it live in Settings > Plugins.

- contrib/plugins.ts: bundled glob accepts plugin.js entries
- contrib/runtime-loader.ts: a disk/runtime copy of an id that ships
  bundled is skipped (standalone installs predating adoption cannot
  double-register)
- package.json: check:test:plugins runs the plugin's node:test suite in
  CI (138 tests)
- source: Hermes-Bot-Mode @ c19baba, incl. today's #107/#103/#99 merges

* feat(agent): core Bot Mode teammate protocol — stable-tier prompt section

Replaces the plugin-side SOUL.md protocol append: on Bot-Mode-managed
installs (any profile carrying ui_meta['hermes-bots']) the prompt builder
injects the "Messaging other agents" section into every session of every
profile — including headless `hermes -p <bot> chat` sessions a teammate
starts — so bot handoffs work without mutating user-authored SOUL files.

- tools/bot_mode_probe.py: silent-when-unmanaged probe, cached per
  (process, home), keyed off the agent's OWN home (not ambient
  HERMES_HOME); silent when SOUL.md already carries the legacy section
- agent/system_prompt.py + agent_init.py + config_defaults.py: wired as
  agent.bot_mode_protocol (default True), stable tier, byte-stable
  across rebuilds (E2E-verified against the real build_system_prompt)
- tui_gateway profiles.list gains bot_mode_protocol capability flag;
  the bundled plugin gates ALL SOUL protocol writes on it (backfill,
  composeSoul, Edit save) — older gateways keep the SOUL-append path
- overhead: ~916 bytes, only on Bot-Mode installs; zero elsewhere

Supersedes the SOUL backfill half of Hermes-Bot-Mode#99 (credit
@kaduxo — the handle fix, `hermes profile list` correction, and
idempotent-append guards from that PR ship in the bundled plugin).

* fix: track bundled plugin.js sources past the tsc-artifact gitignore

apps/desktop/src/**/*.js is gitignored (stale tsc output shadows .tsx),
which silently dropped the hermes-bots plugin.js from the adoption
commit — tests shipped, source didn't, CI ENOENT'd. Negate the pattern
for src/plugins/*/plugin.js: adopted plain-ESM plugins have no .tsx
sibling, so the shadow hazard cannot apply.

* fix(agent): scope the Bot Mode protocol section to canonical Bot Chat sessions

Per review: the protocol belongs only in official Bot Mode interactions,
not every session on a managed install. The prompt builder now injects
the section only when the agent's session row is titled "Bot Chat"
(BOT_CHAT_TITLE, matching the desktop's createCanonicalChat pin and the
`hermes -p <bot> chat -c "Bot Chat"` resume target). Regular sessions
never carry it; the desktop composer middleware owns @mention sends.

Title is read once at first prompt build and the rendered prompt is
cached + DB-restored — cache-safe. E2E against the real AIAgent +
SessionDB: absent in an untitled session, present in Bot Chat,
byte-stable across rebuilds, absent after retitle, absent with the
flag off. Overhead unchanged (~916B, Bot Chat sessions only).

* fix(hermes-bots): composeSoul honors the bot_mode_protocol capability

Found in live desktop E2E: the generated-identity path of composeSoul
still appended the protocol section even when the backend injects it
into the system prompt. New agents now get a clean identity-only SOUL
against capable backends; older gateways keep the append. Covered in
the capability-suppression test.

* fix(agent): Bot Chat gate reads a session-title hint before the DB

Live desktop E2E caught a write-ordering bug the automated E2E missed:
tui_gateway applies pending_title to state.db AFTER the first turn, but
the system prompt builds at turn START — the DB-title gate saw nothing
and the Bot Chat was cached protocol-less forever. The gateway now
hands the agent its intended title at construction and the gate checks
the hint first, DB second (CLI/messaging-gateway paths unchanged).

Live-verified on the running desktop: fresh bot's Bot Chat persisted
with the protocol section, handle, and roster in its system prompt;
regular sessions and SOUL.md untouched.

* feat(agent): capability-refresh + timeless prompts for eternal Bot Chat sessions

Bot Chats break the "new sessions come often" assumption behind
build-once system prompts: capability edits used to sit invisible until
/new or compression, and the frozen birth date became misinformation.

- tools/bot_mode_probe.py: capability_fingerprint() hashes the profile's
  capability surface (disabled skills, toolset pins, MCP config, SOUL.md,
  installed skills, Bot-Mode roster); Bot Chat prompts embed the 12-hex
  epoch stamp
- agent/conversation_loop.py restore path: stored Bot Chat prompt whose
  epoch mismatches disk → ONE rebuild (through a cleared skills-prompt
  cache so new installs appear), persisted so the next turn reuses the
  new bytes verbatim. Prompts without a stamp — every non-Bot-Chat
  session — never take the branch; probe failure fails closed to reuse
- agent/system_prompt.py: Bot Chat prompts are timeless — the
  "Conversation started:" date is dropped (timezone kept); no ticking
  fields in an eternal session
- tui_gateway: _sync_bot_capabilities at turn start rebuilds the live
  agent (tool definitions are construction-baked) when the fingerprint
  moves, same session id/history, with a user-visible notice

Cache stance: this is the /model exception applied to capabilities — a
loud, user-initiated, once-per-change prefix break. Unchanged state
hashes identically and stored bytes are reused verbatim (E2E-proven).

Validation: 9 probe unit tests incl. per-axis fingerprint changes;
E2E v3 against the real restore path (fresh build → verbatim reuse →
skill install → single refresh w/ new skill in index → verbatim reuse;
regular sessions dated, unstamped, never refreshed); tests/agent/
4647/4647.

* feat(agent): one-time protocol upgrade for legacy Bot Chat sessions

Bot Chats created before the epoch mechanism persisted prompts with no
protocol section and no stamp — the staleness check only fires on
stamped prompts, so pre-existing bots would never learn to message
teammates. stored_bot_chat_prompt_needs_upgrade() migrates them: one
rebuild, title-gated to Bot Chat, only when the probe would actually
emit a section (SOUL-append legacies and unmanaged installs are left
alone — rebuilding those would loop). The rebuilt prompt carries the
stamp, so the upgrade can never re-fire.

E2E v3b through the real restore path: legacy Bot Chat upgraded once
then verbatim-reused; legacy regular sessions byte-untouched.
tests/agent/ 4648/4648.

* fix: capability fingerprint reads config via the canonical loader

The config-read guard (test_config_read_guard) correctly flagged the
probe's raw yaml.safe_load of config.yaml — raw reads miss the managed
overlay, env expansion, and normalization. Use load_config_readonly()
under a scoped HERMES_HOME override instead. E2E v3/v3b and the guard
both green.

* feat: sync bundled Bot Mode with multi-source roster (Hermes-Bot-Mode#68)

Pulls the multi-source roster into the bundled plugin: profiles.list rows
from the active gateway are merged with the host.agents() union roster
(hermes-agent #86875), so the Bots panel shows agents from every registered
Desktop connection with @name-device handles for duplicates. Feature-detected
and best-effort — an older Desktop build or roster failure leaves the
single-source list untouched.

Adapted for the bundle:
- useRoster queryFn combines the bot_mode_protocol capability read (which
  landed after #68 was cut) with the multi-source merge
- multi-source-roster tests updated for the namespace SDK import harness
- soul-protocol-backfill anchor widened for the new botHandle(name, bot)
  signature

Plugin suite: 143/143.

* feat: raise Codex OAuth context to 900K for gpt-5.6 family and gpt-5.4 (subscription 1M rollout)

OpenAI enabled the large-context window for ChatGPT-subscription Codex
accounts (announced by @thsottiaux Aug 16 2026; previously API-key-only).
Live re-probe the same day: 911,276 input tokens completed OK on
gpt-5.6-sol; ~925K+ rejected with context_length_exceeded (1.05M window
minus reserved output headroom). terra, luna, and gpt-5.4 all completed
900,026 tokens OK. The Codex catalog still advertises 272K, so the
stale-advertisement override from #87981 is the right lever — this just
raises its value 350K -> 900K.

gpt-5.5 and gpt-5.4-mini still enforce 272K live (rejected 500K) and
remain excluded. Override semantics unchanged: fires only on an
exactly-272,000 advertisement; any live catalog change is trusted
verbatim.

* fix(desktop): map SSH profile aliases in REST paths

* chore: map contributor email for attribution audit

* fmt(js): `npm run fix` on merge (#88079)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* fix(cron): stop retry storms when the gateway is deliberately stopped (OOF-266)

Since the managed-cron redesign (#84339, v2026.8.13) the dashboard fire
webhook forwards fires to the gateway process and returns 503 when it is
unreachable so NAS/QStash retries. Correct for transient windows — but an
operator-STOPPED gateway can never be fixed by retrying: every fire on
every job burns the full scheduler retry budget, NAS converts each 503 to
a retryable 502, and the resulting storms page on-call for a non-incident
(OOF-266 and its five duplicate tickets; +93% relay callback failures as
the fleet adopted v2026.8.13).

Split the unreachable path by durable operator intent:

- desired_state == "stopped" (written only by the s6 lifecycle commands;
  the same intent signal container-boot reconciliation trusts) -> drop
  the fire with 200 + a structured log line, mirroring NAS's own
  instance_stopped drop. Jobs are not lost: the Chronos provider
  reconciles and re-arms every job on the next gateway start.
- Anything else (crash loop, scale-to-zero wake, restart, legacy state
  file without desired_state) -> keep the retryable 503, now stamped
  with Retry-After: 60 so a scheduler that honors it spaces retries
  past the wake/restart window instead of exhausting them inside it.
  The gateway's own pass-through 503s (draining) get the same hint.

The intent check fails open (any parse/resolution error -> retryable
path) and is only consulted when the gateway is actually unreachable, so
a stale state file can never shadow a live gateway.

* feat(state): support the context-manager protocol on SessionDB

A SessionDB handle cannot be released by dropping the last reference.
Once its background token writer starts, the instance pins ITSELF two
ways: the writer thread's target is a bound method, and
queue_token_counts registers atexit.register(_drain_token_queue_at_exit),
which only close() unregisters. A dropped-but-pinned handle keeps its
state.db/-wal/-shm descriptors for the life of the process, and __del__
never runs for it, so the existing safety net is dead code for exactly
the instances that leak.

That is why owning call sites are expected to close explicitly, in those
words, in the ownership comments in run_agent.py and
tui_gateway/methods_session.py. This adds the ergonomic half of that
contract so an owner can scope a handle and be exception-safe by
construction:

    with SessionDB(path) as db:
        db.append_message(...)

Purely additive. __enter__ returns self, __exit__ closes and returns
False so a caller's exception always propagates, and close() is already
idempotent, so a scope that closes early still exits cleanly. Nothing
changes for callers that already close directly.

Four regressions cover the scope closing the handle, __enter__ returning
the instance itself, the failure path closing while still propagating,
and an early close leaving the exit clean. They assert on the
sqlite_safe_read tracking registry rather than raw descriptor counts,
matching test_session_db_read_conn_pool.py, because SQLite's unix VFS
parks a closed descriptor on a per-inode reuse list and makes raw counts
lag the real connection count.

Refs #88033

* fix(state): release abandoned session database handles

* fix(state): avoid overlapping context manager change

* fix(gateway): ignore invalid managed Node directories

Signed-off-by: Shawn Wang <32839114+enwaiax@users.noreply.github.com>

* fix(gateway): accept CJK full-width punctuation as MEDIA path terminators

MEDIA_TAG_CLEANUP_RE (and MEDIA_EXTENSIONLESS_TAG_RE) only recognized
ASCII terminators after a MEDIA:<path> tag. Chinese-language agent
output naturally writes MEDIA:D:\...\zhibao.pdf(782.6 KB)or ...pdf:内容 —
the full-width punctuation failed the trailing lookahead and the
attachment was silently dropped (cron even reported 'delivered') (#88038).

Both lookaheads now accept a CJK full-width terminator set (()〈〉《》:,。;
!?、curly quotes【】) alongside the ASCII set. The #68773 adjacent-tag
splitting guard is covered by a regression test.

* fix(skills): rescan skill commands cache when active profile changes

Switching Desktop profiles mid-session changes HERMES_HOME but not the
platform scope, so get_skill_commands() kept serving the previous
profile's skill list. A skill only available under the new profile then
looked like a cache miss to callers such as slash.exec, which fall
through to the slash_worker dead path (#88023).

* fix(gateway): scope slash.exec's skill-command check to the session's profile

Independent review of the prior commit found the cache-invalidation key
alone doesn't fix the reported #88023 dead path: slash.exec runs as a
_LONG_HANDLER on the pool with a copied context, and no binding of
_HERMES_HOME_OVERRIDE happens between the transport read and the handler
body, so get_skill_commands() there always fell back to the process-level
HERMES_HOME regardless of which profile's session issued the request.

Bind the session's own profile_home around the get_skill_commands() check,
mirroring the same bind/reset-in-finally pattern already used at every
other per-turn HERMES_HOME scoping site (e.g. server.py's prompt-turn and
system-prompt-rebuild paths). This makes the #88023 dead path actually
reachable by the fix instead of only exercising the cache primitive in
isolation.

* feat(desktop): add status bar reconnect for offline gateways

Expose the existing profile-aware gateway boot reconnect path through a
single-flight renderer action, and surface a Reconnect button in the
gateway status menu panel whenever the socket is not open. Repeated
clicks share one in-flight reconnect; failures surface through the
existing non-destructive notification UI. Localized copy for all
supported Desktop locales.

Salvaged from PR #80694 (net diff re-applied onto current main; panel
code lives in app/shell/gateway-menu-panel.tsx now).

* fix(desktop): self-heal dropped SSH/HTTP registered remote connections

A dropped registered remote connection (SSH or HTTP) never recovered on
its own: the next boot attempt failed with a transient transport error
("Could not verify the existing SSH backend", ERR_CONNECTION_RESET,
mint timeout), the failure was correctly NOT latched, but nothing ever
re-attempted the boot — the renderer's reconnect machinery only arms
after a completed boot. The app parked on "Desktop boot failed" until
the user manually deleted and re-entered the same connection details,
which merely forced the fresh bootstrap an automatic retry would have
performed (issue 82679, feature ask 80430).

Root causes and fixes:

- electron/backend-start-failure.ts: new isRetryableRemoteBootFailure()
  predicate — a remote, non-reauth boot failure is transient and may be
  retried; local failures and confirmed 401/403 rejections are not
  (a missing capability differs from a transient failure).
- electron/main.ts: the boot-failure progress broadcast now carries
  `retryable` (rides with `error` through updateBootProgress), and a
  failed reuse probe against a cached SSH master tears the stale
  master/tunnel down so the next attempt bootstraps fresh — exactly
  what manual re-entry did.
- use-gateway-boot.ts: bounded self-heal loop for a failed boot whose
  progress is marked retryable — up to 5 re-attempts with the same
  full-jitter backoff as the socket reconnect loop (2s base, 15s cap).
  Exhausted retries end in the real boot-failure recovery overlay,
  never an infinite spinner. Reset on success and on soft switch;
  timer cleared on unmount.
- store/boot.ts: resumeDesktopBootForRetry() re-arms the overlay with a
  retry status while an automatic retry is in flight.

Secondaries already had full-jitter backoff (store/gateway.ts); this
closes the same class for the PRIMARY/registered-connection path.

Tests: predicate matrix (retryable vs reauth-latch mutually exclusive),
plus renderer hook tests proving a transient SSH failure self-heals on
the next attempt, retries are bounded (6 total dials then the recovery
overlay, no further attempts), and non-retryable failures never enter
the loop. Sabotage-verified (disabling either half fails 4 tests).

Fixes #82679
Fixes #80430

* feat(desktop): support remote gateway headers

* feat(desktop): carry remote gateway headers through the connections registry, test probes, and Settings UI

Completes PR #74468 (remote gateway headers for Cloudflare Access, #74466)
against the v2 multi-connection registry that landed after the PR was
authored, and closes the review blockers:

- connection-registry: additive optional `headers` field on remote/cloud
  entries (normalized through the same forbidden-name filter, secret
  envelopes like `token`); inherited on edit, treated as dial material by
  connectionDialFieldsChanged, preserved by normalizeRegistry, and carried
  through migrateV1ToRegistry. v2 registries without the field load
  unchanged — no version bump.
- main.ts registry paths: connectRegistryBackend dials with the entry's
  headers (readiness probe, ticket mint, descriptor REST via
  getJsonForBackend/fetchJsonForBackend, registry ws-url minting with
  rememberRemoteWsHeaders so renderer upgrades get them injected).
- saveRegistryConnection encrypts incoming plaintext header values with the
  same safeStorage/allowPlainText seam as tokens; sanitizeRegistryConnection
  exposes only header NAMES to the renderer — values never cross IPC.
- Connection tests exercise the leg they validate: both
  hermes:connection-config:test and hermes:connections:test now send the
  configured headers on the HTTP status call, the ws-ticket mint, AND the
  live WebSocket probe (probeGatewayWebSocket grew an injectable `headers`
  option passed as the undici WebSocket constructor's second argument).
- Settings → Connections gains an "Extra gateway headers" editor for
  remote/cloud entries (name + secret value rows, stored values shown as
  saved-but-hidden, clearable), with i18n keys (en + zh; other locales fall
  back through defineLocale).

* chore: map contributor email for tigercraft4 (PR #74468 salvage)

* feat(delegation): record model/provider in live-transcript manifest (#telemetry)

* fix(gateway): attribute scoped credential lock conflicts to the owning profile (OOF-3)

Scoped credential locks (Telegram bot token, Discord bot token, etc.) are
machine-global, but the conflict error only reported the holder's PID:

    Telegram bot token already in use (PID 559). Stop the other gateway first.

On multi-profile hosts (e.g. hosted instances running 13 profiles), a bare
PID gives the operator no way to tell WHICH profile owns the credential —
the exact failure mode observed on zerocool-9781, where the 'default'
profile was misconfigured with the same bot token as 'lead-gen-outreach'
and logged an unattributable conflict every ~5 minutes (4,602 rows).

Fix:
- acquire_scoped_lock() now stamps a 'profile' label on lock records,
  inferred from the process HERMES_HOME (<root>/profiles/<name> layouts,
  'default' for the root home). Omitted when not inferable.
- New scoped_lock_owner_label() resolves the owning profile from a lock
  record: prefers the explicit field, falls back to inferring from the
  persisted hermes_home for locks written before the field existed.
  Labels are validated against the profile-id grammar before use (lock
  files are plain JSON on disk and the label flows into log lines and a
  suggested CLI command).
- _acquire_platform_lock() conflict message now names the owning profile
  and gives the correct remedy:

    Telegram bot token already in use by the 'lead-gen-outreach' profile
    gateway (PID 559). Stop that gateway first
    (hermes --profile lead-gen-outreach gateway stop).

  Records with no attribution signal keep the original PID-only wording.

Testing:
- New TestScopedLockOwnerLabel suite covering label inference (named,
  Docker, root/default, unknown layouts), grammar validation, explicit-
  field preference, hermes_home fallback, and legacy/malformed records.
- acquire_scoped_lock tests for profile stamping and omission.
- Adapter-level tests for profile-attributed, legacy-home-inferred, and
  PID-only conflict messages.
- 76/76 targeted gateway tests pass; broad gateway suite failures are
  baseline-identical (verified via git stash comparison). Ruff clean.

* fix(gateway): surface multiplex profile failures (OOF-3)

* fix(status): aggregate independent per-profile gateway failures; harden key filter (OOF-3)

- /api/status now folds LIVE independent per-profile gateways' platform
  failures (gateway_mode == 'multiple', the OOF-3 deployment mode) into
  gateway_platforms under the validated <profile>:<platform> grammar, so
  NAS fleet health sees them without a schema change. ?profile= requests
  stay unmerged (single-profile view).
- Namespaced-key validation no longer fails open: colon-containing keys
  are grammar-checked even when configured-platform loading throws.
- Platform key segment now accepts hyphens, matching plugin platform IDs
  (plugins/platforms/<dir> names, e.g. foo-bar).

* fix(status): freshness-filter aggregated per-profile platform entries (OOF-3)

Gateway startup deliberately preserves plain platform entries in
gateway_state.json across restarts, and the active-profile endpoint
compensates by filtering against current configuration. The cross-profile
aggregation copied raw maps, so a fatal entry for a platform the operator
had since disabled/removed could keep NAS reporting the instance degraded
indefinitely.

The aggregation has no cheap per-profile config context (platform sets
depend on tokens in each profile's .env behind its secret scope), so use
freshness instead: an entry is aggregatable only when its updated_at is
at/after the live gateway process's create time (validated PID via
get_runtime_status_running_pid + psutil create_time; the record's own
start_time field is a PID-reuse fingerprint in clock ticks, not a
timestamp). Config changes require a restart to take effect, so
restart-anchored freshness is exactly the config filter's semantics.
Fail closed: unparseable timestamps or no live process exclude the entry
— a false 'degraded forever' is the worse failure mode.

* fix(status): strict writer-identity ownership for aggregated platform entries (OOF-3)

The freshness window (updated_at >= live process create_time - 2s) had a
P1 boundary hole: a stale failure written by the PREVIOUS process
immediately before a fast restart landed inside the slack and was
aggregated; if that platform was then removed, the new process never
replaces the entry and NAS stays degraded indefinitely.

Replace clock heuristics with persisted writer identity:

- write_runtime_status now stamps every platform entry with the writing
  process's (writer_pid, writer_start_time) — the same PID-reuse
  fingerprint the liveness checks use, so a recycled PID never
  masquerades as the original writer.
- The aggregation ownership filter requires exact equality between an
  entry's stamp and the profile's validated live gateway process
  (get_runtime_status_running_pid + _get_process_start_time). No slack,
  no timestamps. Legacy entries without a stamp fail closed.
- Writer stamps are process recon (same class as the auth-gated
  gateway_pid) and are stripped from all /api/status projections, both
  active-profile and merged cross-profile entries.

Near-boundary regression test: prior-process entry stamped 100ms before
restart is excluded; recycled-pid-different-fingerprint excluded;
legacy no-stamp excluded; current-process entry kept.

* docs(state): soften stale SessionDB self-pin wording after #88063

#88048 documented the token-writer self-pin (bound-method thread target +
strong atexit hook) as a permanent contract: "__del__ never runs for
exactly the instances that leak". #88063 then removed both pins (idle
writer retirement + weakref atexit hook), making abandoned handles
eventually collectible.

Reword the __enter__ docstring and the context-manager test module
docstring to describe the pin as historical motivation, note the #88063
behavior, and keep the guidance that owners close deterministically.
No code changes.

* fix(desktop): keep cloud bot avatar eye catchlights inside the eyes

The white catchlight dots in BotFace were static circles pinned at the
circle-face eye line (cy 16.5), while the animation clock moves the
pupils to the shape-aware eye line (cy 22 for the cloud). On the cloud
avatar the highlights floated above the eyes instead of inside them.

- Tag the catchlights (data-hb-hl-l/r) and move them with the pupils in
  paintMathFace, offset upper-left of each pupil center.
- Render the initial eyes/catchlights/shut-lids at the shape-aware eye
  line so the first frame matches the animated frames.

* fix(desktop): make git worktrees work end-to-end on a remote gateway backend

Cmd/Ctrl+Shift+B worktree flows on a remote gateway route through the
backend's /api/git mirror (hermes_cli/web_git.py), but that mirror had
drifted behind the Electron-local git ops the same UI drives locally, so
the flows broke exactly and only on remote connections:

- Convert-a-branch: the picker offers remote-tracking refs, and the
  Electron op turns "origin/feature" into a local tracking branch. The
  mirror ran `git worktree add <dir> origin/feature` verbatim, which
  either fails or detaches HEAD. It now resolves the ref's remote via
  git (never assuming "origin"), fetches best-effort, and creates the
  worktree with `--track -b <short-name>`.
- branch_list omitted remote-tracking refs entirely and never set the
  `isRemote` flag the renderer's HermesGitBranch contract requires —
  the convert picker on a remote gateway couldn't reach a teammate's
  branch and mislabeled every row's action.
- Branching off an `origin/…` base silently wired the new branch to the
  remote upstream; the mirror now passes `--no-track` like the Electron
  op does.

Renderer side, replace the silent degradation with a capability gate:
when a remote backend predates the /api/git worktree routes, worktree
creation failed with an opaque "Expected JSON … got HTML" toast. The
route-missing shapes now surface a clear "update the Hermes backend"
message (isGitEndpointMissingError, mirroring the sidebar batch-endpoint
detector); real git errors still pass through untouched.

Sibling audit (documented, no code change needed): repo status / review /
file-diff / git-root / default-cwd already route through desktopGit()'s
REST bridge or /api/fs on remote; repo scan is deliberately a no-op there.
Stale comments claiming "empty/false on a remote backend" in projects.ts
and coding-status.ts updated to describe the backend-routed reality.

Fixes #81724

* fix(terminal): avoid FileProvider reads in lifecycle guard

* fix(cron): move cloud-placeholder refusal into _read_referenced_script and cover ~/Library/CloudStorage

Widen #88052 per review:
- The walk-level short-circuit only protected _contains_unsafe_gateway_action;
  the sibling caller _read_script_for_scanning still opened cloud-resident cron
  scripts and could hang preflight. Move the check into _read_referenced_script,
  the shared choke point, so every caller fails closed without opening.
- Generalize _is_apple_file_provider_path -> _is_cloud_placeholder_path: detect
  ~/Library/CloudStorage (Dropbox/OneDrive/Google Drive third-party FileProvider
  domains) alongside iCloud's Library/Mobile Documents.
- Regression tests: CloudStorage lexical path blocked without open; the choke
  point itself refuses cloud paths with os.open forbidden.

* fix(cron): attribute cloud-path refusals to the cloud-synced script, not a lifecycle command

When check_gateway_lifecycle refuses a cron script that lives on a
FileProvider path, the generic error implied the job contained a dangerous
gateway lifecycle command. Surface th…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/profiles Multi-profile isolation, HERMES_HOME scoping comp/desktop Electron desktop app (apps/desktop/*) P3 Low — cosmetic, nice to have type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants