docs: hermes_cli, gateway and desktop AGENTS.md keep rules, long form moves to docs

An agent working in an area gets root + that area's AGENTS.md as one project-context
block, so the area files must leave room beside the root. hermes_cli (24.2k -> 18.0k):
update-pipeline stage contract and free-tier long form -> cli-internals.md. gateway
(20.9k -> 17.2k): background notifications and /login long form -> gateway-internals.md.
apps/desktop (16.3k -> 9.4k): five judgment sections summarized with every invariant
kept, the reasoning -> new apps/desktop/ENGINEERING.md, the onboarding rehearsal ->
BUILDING.md.
This commit is contained in:
Teknium
2026-10-04 21:59:35 -07:00
parent a4140eebb1
commit f3af936694
7 changed files with 394 additions and 300 deletions

View File

@@ -5,6 +5,7 @@ it teaches the invariants and the reasoning behind them so a change fits the app
even as files move. Read it with the repository `AGENTS.md` (root rules still
apply), [`DESIGN.md`](./DESIGN.md) for the visual and interaction contract, and
`src/AGENTS.md` for the backend contract, slash-palette curation, and Bot Mode.
The reasoning behind the shorter sections below lives in [`ENGINEERING.md`](./ENGINEERING.md).
When a rule here and the code disagree, trust the code and fix whichever is
wrong — but never break an invariant to make a change easier.
@@ -48,148 +49,44 @@ Getting the scope wrong is how one profile's setting bleeds into another.
## Identity is not incidental
Sessions have more than one identity, and conflating them is a recurring source
of "session not found" and vanishing history. Reason about which identity a
surface needs: durable navigation and anything the user pins or persists key off
the stable/durable identity; live streaming keys off the runtime identity; state
that must outlive compression keys off the lineage root. Keep the mapping between
them explicit and translate at the boundary rather than passing the wrong id
inward.
Two guarantees follow from this (#111868). The user's message is durable at
send: `prompt.submit` writes the session row AND the user row before the agent
build starts, and the turn adopts that row (`_adopt_submit_user_row` →
`agent._pending_cli_user_message`) instead of appending a second one, so a
freeze or force-quit during a slow first build leaves a resumable transcript
(user message present, no reply). And renderer state keyed by profile name —
persisted tabs (`tilesByProfile`), Bot tile owner routes, cached transcript
tails, remembered session/route, session owner hints — follows a profile
rename via `migrateTilesForProfile(old, new)` (`store/session-states.ts`), the
rename sibling of `dropTilesForProfile`. Add any new profile-keyed localStorage
family to BOTH, or a rename leaves it pointing at a backend that no longer
exists ("Couldn't open this session" on every restore).
When an id is verifiably gone anyway (`goneSessionVerdict` → `'draft'`), the
window drops to a fresh draft without toasting or looping — and the unsent
text stashed under the dead key follows it: the verdict calls
`announceGoneSessionDraft(id)` and the composer's swap onto the fresh scope
consumes it once (`adoptGoneSessionDraft`, `store/composer.ts`), seeding the
composer and publishing the inline, undoable `$restoredDraftNotice`. Offer,
don't hijack: no navigation beyond the drop itself, no focus steal, no toast,
and an already non-empty fresh draft is never clobbered.
Sessions have durable, runtime and lineage-root identities; pick the one the surface needs and
translate at the boundary. The user's message is durable at send (`prompt.submit` writes the session
and user rows first and the turn adopts that row, #111868). A new profile-keyed localStorage family
joins BOTH `migrateTilesForProfile` and `dropTilesForProfile`. A verifiably gone session
(`goneSessionVerdict` → `'draft'`) drops to a fresh draft that adopts its unsent text once, with no
toast, navigation or focus steal.
## Server truth is cached, not owned
The renderer paints from a cache of backend truth, so it must reconcile, not
assume:
- **Merge, don't clobber.** A refresh is new information layered over what you
already know, not a replacement that can drop live or pinned rows.
- **Be optimistic, then honest.** Direct manipulation should paint immediately
from a snapshot; a failed write rolls back visibly and an authoritative
refresh gets the last word.
- **Guard against the past.** Async results can arrive out of order; a stale
response must never overwrite newer intent. Generation counters and request
tokens exist for this.
- **Isolate the foreground.** Only the surface the user is looking at may publish
into the shared view; background work updates its own cache quietly.
- **Coalesce noise, flush signal.** Batch high-frequency cosmetic updates, but
let terminal transitions (a turn finishing, needing input, failing) reach the
user immediately.
- **Preserve reference identity on no-ops.** Handing React a fresh array that
contains the same data re-renders expensive trees for nothing.
Merge, don't clobber; paint optimistically and roll back visibly; a stale async result never
overwrites newer intent (generation counters, request tokens); only the foreground surface publishes
into the shared view; coalesce cosmetic updates but flush terminal transitions; keep reference
identity on no-ops.
## Switching context is a re-home, not a reboot
Changing profile, connection, or mode is a workspace switch, not a cold start.
The shell and whatever the user was doing stay put; only the gateway-bound view
is cleared and repopulated, and the previous context must not leak into the next
one. Reserve the full-screen boot/connecting experience for a genuinely unusable
backend.
There are three distinct switch shapes, and conflating them is the classic bug:
- A **connection/mode apply** (local ↔ remote ↔ cloud) is the soft re-home:
shell mounted, gateway-bound stores explicitly wiped, then reconnect. Query
invalidation alone cannot evict live session stores — wipe them.
- A **runtime home change** (switching the underlying `HERMES_HOME` profile) is
a hard re-home: the window legitimately reloads and state resets by remount.
- A **live profile swap** in the same window activates another profile's socket
while background profiles keep streaming; lists merge rather than wipe, and
only an explicit user selection starts a fresh foreground draft.
Treating a soft switch as hard flickers the app; treating a hard one as soft
strands stale rows. After any swap, the active socket, active profile, and
connection atoms must agree, or REST and filesystem calls route to the wrong
backend.
A connection/mode apply is a SOFT re-home (shell stays, gateway-bound stores wiped explicitly, then
reconnect); a runtime `HERMES_HOME` change is a HARD re-home (reload); a live profile swap merges
lists while background profiles keep streaming. After any swap the active socket, profile and
connection atoms must agree.
## Cross everything as an observable ladder
Desktop lives at the seams: versions, profiles, local vs remote vs cloud,
partially installed runtimes, stale caches, older backends. The durable technique
for all of it is the same — an ordered ladder of candidates:
1. Precedence is written down, in one place, as data or a pure function.
2. A candidate is trusted only after it is validated at the right boundary.
Existence is not proof; probe what you're about to rely on.
3. A failed *read* falls to the next rung; a failed *authoritative write*
surfaces or rolls back rather than silently retargeting.
4. A missing capability and a transient failure are different: the first may
enable a compatibility path or a disabled state; the second should retry.
5. Retries are bounded and end in a real recovery affordance — never an infinite
spinner or a hot loop.
6. One resolver owns each policy so every caller gets the same answer. Scatter is
how two call sites drift apart.
This is the shape of backend discovery, command/version fallbacks, connection and
auth resolution, workspace-cwd selection, capability detection, and preview
normalization alike. Learn the shape, not a snapshot of the current rungs.
Two auth-flavored corollaries worth naming because they are easy to get wrong:
- **One-time credentials are never reused.** An OAuth gateway connection mints a
fresh WebSocket ticket on every dial and never falls back to the cached URL.
Only a confirmed 401/403 (or an explicitly tagged auth rejection) means
reauthentication; timeout, network, malformed-response, and server failures
remain connectivity errors. Only long-lived token/local auth may reuse a
cached URL as a lower rung.
- **A connection test must exercise the leg you'll actually use.** An HTTP
status probe passing while the WebSocket/auth leg fails is a false positive
that ships as "it said connected but nothing works."
- **Cookie-jar partition names contain nothing Electron percent-escapes.** A
`persist:` partition becomes a `Partitions/<escaped name>` folder; a folder
name with `%3A` (an escaped `:`) gets a cookie store Windows can neither read
nor write, so the session silently never persists. `electron/oauth-partition.ts`
pins the invariant; renaming a partition signs its users out once — say so.
Every seam (versions, profiles, local/remote/cloud, older backends) resolves through ONE ordered
ladder per policy: precedence as data, a candidate trusted only after it is probed, a failed read
falls to the next rung but a failed authoritative write surfaces or rolls back, a missing capability
is not a transient failure, and retries are bounded and end in a recovery affordance. OAuth gateway
connections mint a fresh ticket on every dial (only a confirmed 401/403 means reauth); a connection
test exercises the leg you will actually use; `persist:` partition names avoid anything Electron
percent-escapes (`electron/oauth-partition.ts`).
## Guest content never opens anything by itself
Untrusted HTML runs in two places: sandboxed `allow-scripts` iframes (artifact
previews) and the preview pane's `<webview>` (`persist:hermes-preview`). Neither
may drive the OS browser without the user's hand on it (GHSA-9f4c-93c8-jc8g):
`setWindowOpenHandler` denies everything and never opens a URL as a side
effect (`electron/window-open-policy.ts`), and the webview has no
`allowpopups` — do not add it.
A guest page's `target="_blank"` links (Streamlit's "Ask Google" traceback
button) reach the OS browser through one explicit bridge instead:
- `main.ts` installs `electron/preview-guest-preload-entry.ts` via
`will-attach-webview`, keyed on the `persist:hermes-preview` partition only.
It is the app's only guest preload; a new webview does not inherit it unless
it opts into that partition.
- The preload runs in the isolated world, exposes nothing to the page, and
forwards only a **trusted** (`event.isTrusted`) primary-button click on an
`a[target="_blank"]` to the host via `ipcRenderer.sendToHost`. A synthetic
`dispatchEvent(click)` from page script is dropped there; page `window.open`
stays blocked.
- `PreviewPane` admits `http:`/`https:` only (`src/lib/preview-external.ts`)
and hands the URL to the existing `hermes:openExternal` IPC, which applies
main's URL policy. `file:` is excluded on purpose: a guest must never reach
`shell.openPath`.
Widening any of those three (partition key, trusted-click gate, scheme set)
reopens the gesture-less forced-navigation class the advisory closed.
Artifact iframes and the preview `<webview>` never drive the OS browser on their own
(GHSA-9f4c-93c8-jc8g): `setWindowOpenHandler` denies everything, the webview has no `allowpopups`,
and a guest `target="_blank"` link reaches `hermes:openExternal` only through the
`persist:hermes-preview` guest preload's trusted-click bridge, `http:`/`https:` only. Widening the
partition key, the trusted-click gate or the scheme set reopens the advisory.
## Compatibility without carrying the past forever
@@ -216,7 +113,7 @@ property of the SESSION's client, not of the backend host. Wire its
availability off the session source the app already sends on `session.create`
(`source: 'desktop'`), never off an env var on the backend process: that
process might be a remote or cloud gateway this app merely connected to. See
the root AGENTS.md, "Surface capability is a property of the SESSION."
`tools/AGENTS.md`, "Surface capability is a property of the SESSION."
## Respect the person using it
@@ -252,14 +149,6 @@ boundaries, optimistic rollback and stale-response ordering, and both sides of a
local/remote adapter with its profile routing intact. Match how the suite is
actually run rather than inventing a command; when in doubt, read the scripts.
## Rehearsing the guided onboarding
From `apps/desktop`, use a fresh temporary directory for each rehearsal and run
`env -u NODE_ENV HERMES_GUEST_ONBOARDING=1 HERMES_HOME=<tmp>/.hermes HERMES_DESKTOP_USER_DATA_DIR=<tmp>/electron-user-data npm run dev`
(replace `<tmp>` with that directory). To use the portal stand-in, add
`HERMES_PORTAL_BASE_URL=http://127.0.0.1:8765 HERMES_ANON_API_SECRET=test-secret HERMES_SHARED_AUTH_DIR=<tmp>/.hermes/shared`
before `npm run dev`. Stop Electron and its dev server after the run.
## The taste test before you hand off
- Does every piece of state live with its authority, at the narrowest scope?
@@ -276,14 +165,7 @@ If any answer is "not sure," that's the part to go verify.
## Nous free tier: state is pulled, never latched in the renderer
The free tier (a Nous identity with no account, `hermes_cli/anon_auth.py`) reaches the renderer
through one JSON-RPC pair: `free_tier.status` (has_guest, enabled, available,
notice_pending, model, label) read from local auth state with zero network, and
`free_tier.ack_notice`, which persists the one-time notice flag on the identity itself. The
first-launch ready screen and the own-key strip are the SAME state rendered for two situations,
keyed on `notice_pending`; there is no localStorage latch, so the CLI and the desktop cannot
disagree about whether the notice was shown. Sign-in goes through the existing
`POST /api/providers/oauth/nous/start` + poll route, which over a free-tier identity registers the
connector transfer and reports `reason`, `account_email` and `model` on completion; every entry
point (Billing, status chip, ready screen) opens the one free-tier sign-in dialog. Never branch on
provider display names: the picker row carries `free_tier_row`, status cards carry `free_tier`.
`free_tier.status` / `free_tier.ack_notice` are the only source: the ready screen and the own-key
strip render the same `notice_pending` state, with no localStorage latch, and every entry point
opens the one sign-in dialog. Branch on `free_tier_row` / `free_tier`, never on provider display
names. Long form: `ENGINEERING.md`.

View File

@@ -353,3 +353,11 @@ live in the [existing install/update family](../../tests/install/BUNDLED_UPDATES
A helper test or unpacked-app smoke is not proof of native install, update,
or automatic relaunch. Historical receipts and current unresolved gates are
separate in [PM audit status](../../docs/pm-audit-status.md).
## Rehearsing the guided onboarding
From `apps/desktop`, use a fresh temporary directory for each rehearsal and run
`env -u NODE_ENV HERMES_GUEST_ONBOARDING=1 HERMES_HOME=<tmp>/.hermes HERMES_DESKTOP_USER_DATA_DIR=<tmp>/electron-user-data npm run dev`
(replace `<tmp>` with that directory). To use the portal stand-in, add
`HERMES_PORTAL_BASE_URL=http://127.0.0.1:8765 HERMES_ANON_API_SECRET=test-secret HERMES_SHARED_AUTH_DIR=<tmp>/.hermes/shared`
before `npm run dev`. Stop Electron and its dev server after the run.

163
apps/desktop/ENGINEERING.md Normal file
View File

@@ -0,0 +1,163 @@
# Desktop engineering: the long form
The reasoning and worked detail behind the summarized sections of [`AGENTS.md`](./AGENTS.md). The
rules themselves are in `AGENTS.md`, which agents load automatically; this file is read on purpose.
## Identity is not incidental
Sessions have more than one identity, and conflating them is a recurring source
of "session not found" and vanishing history. Reason about which identity a
surface needs: durable navigation and anything the user pins or persists key off
the stable/durable identity; live streaming keys off the runtime identity; state
that must outlive compression keys off the lineage root. Keep the mapping between
them explicit and translate at the boundary rather than passing the wrong id
inward.
Two guarantees follow from this (#111868). The user's message is durable at
send: `prompt.submit` writes the session row AND the user row before the agent
build starts, and the turn adopts that row (`_adopt_submit_user_row` →
`agent._pending_cli_user_message`) instead of appending a second one, so a
freeze or force-quit during a slow first build leaves a resumable transcript
(user message present, no reply). And renderer state keyed by profile name —
persisted tabs (`tilesByProfile`), Bot tile owner routes, cached transcript
tails, remembered session/route, session owner hints — follows a profile
rename via `migrateTilesForProfile(old, new)` (`store/session-states.ts`), the
rename sibling of `dropTilesForProfile`. Add any new profile-keyed localStorage
family to BOTH, or a rename leaves it pointing at a backend that no longer
exists ("Couldn't open this session" on every restore).
When an id is verifiably gone anyway (`goneSessionVerdict` → `'draft'`), the
window drops to a fresh draft without toasting or looping — and the unsent
text stashed under the dead key follows it: the verdict calls
`announceGoneSessionDraft(id)` and the composer's swap onto the fresh scope
consumes it once (`adoptGoneSessionDraft`, `store/composer.ts`), seeding the
composer and publishing the inline, undoable `$restoredDraftNotice`. Offer,
don't hijack: no navigation beyond the drop itself, no focus steal, no toast,
and an already non-empty fresh draft is never clobbered.
## Server truth is cached, not owned
The renderer paints from a cache of backend truth, so it must reconcile, not
assume:
- **Merge, don't clobber.** A refresh is new information layered over what you
already know, not a replacement that can drop live or pinned rows.
- **Be optimistic, then honest.** Direct manipulation should paint immediately
from a snapshot; a failed write rolls back visibly and an authoritative
refresh gets the last word.
- **Guard against the past.** Async results can arrive out of order; a stale
response must never overwrite newer intent. Generation counters and request
tokens exist for this.
- **Isolate the foreground.** Only the surface the user is looking at may publish
into the shared view; background work updates its own cache quietly.
- **Coalesce noise, flush signal.** Batch high-frequency cosmetic updates, but
let terminal transitions (a turn finishing, needing input, failing) reach the
user immediately.
- **Preserve reference identity on no-ops.** Handing React a fresh array that
contains the same data re-renders expensive trees for nothing.
## Switching context is a re-home, not a reboot
Changing profile, connection, or mode is a workspace switch, not a cold start.
The shell and whatever the user was doing stay put; only the gateway-bound view
is cleared and repopulated, and the previous context must not leak into the next
one. Reserve the full-screen boot/connecting experience for a genuinely unusable
backend.
There are three distinct switch shapes, and conflating them is the classic bug:
- A **connection/mode apply** (local ↔ remote ↔ cloud) is the soft re-home:
shell mounted, gateway-bound stores explicitly wiped, then reconnect. Query
invalidation alone cannot evict live session stores — wipe them.
- A **runtime home change** (switching the underlying `HERMES_HOME` profile) is
a hard re-home: the window legitimately reloads and state resets by remount.
- A **live profile swap** in the same window activates another profile's socket
while background profiles keep streaming; lists merge rather than wipe, and
only an explicit user selection starts a fresh foreground draft.
Treating a soft switch as hard flickers the app; treating a hard one as soft
strands stale rows. After any swap, the active socket, active profile, and
connection atoms must agree, or REST and filesystem calls route to the wrong
backend.
## Cross everything as an observable ladder
Desktop lives at the seams: versions, profiles, local vs remote vs cloud,
partially installed runtimes, stale caches, older backends. The durable technique
for all of it is the same — an ordered ladder of candidates:
1. Precedence is written down, in one place, as data or a pure function.
2. A candidate is trusted only after it is validated at the right boundary.
Existence is not proof; probe what you're about to rely on.
3. A failed *read* falls to the next rung; a failed *authoritative write*
surfaces or rolls back rather than silently retargeting.
4. A missing capability and a transient failure are different: the first may
enable a compatibility path or a disabled state; the second should retry.
5. Retries are bounded and end in a real recovery affordance — never an infinite
spinner or a hot loop.
6. One resolver owns each policy so every caller gets the same answer. Scatter is
how two call sites drift apart.
This is the shape of backend discovery, command/version fallbacks, connection and
auth resolution, workspace-cwd selection, capability detection, and preview
normalization alike. Learn the shape, not a snapshot of the current rungs.
Two auth-flavored corollaries worth naming because they are easy to get wrong:
- **One-time credentials are never reused.** An OAuth gateway connection mints a
fresh WebSocket ticket on every dial and never falls back to the cached URL.
Only a confirmed 401/403 (or an explicitly tagged auth rejection) means
reauthentication; timeout, network, malformed-response, and server failures
remain connectivity errors. Only long-lived token/local auth may reuse a
cached URL as a lower rung.
- **A connection test must exercise the leg you'll actually use.** An HTTP
status probe passing while the WebSocket/auth leg fails is a false positive
that ships as "it said connected but nothing works."
- **Cookie-jar partition names contain nothing Electron percent-escapes.** A
`persist:` partition becomes a `Partitions/<escaped name>` folder; a folder
name with `%3A` (an escaped `:`) gets a cookie store Windows can neither read
nor write, so the session silently never persists. `electron/oauth-partition.ts`
pins the invariant; renaming a partition signs its users out once — say so.
## Guest content never opens anything by itself
Untrusted HTML runs in two places: sandboxed `allow-scripts` iframes (artifact
previews) and the preview pane's `<webview>` (`persist:hermes-preview`). Neither
may drive the OS browser without the user's hand on it (GHSA-9f4c-93c8-jc8g):
`setWindowOpenHandler` denies everything and never opens a URL as a side
effect (`electron/window-open-policy.ts`), and the webview has no
`allowpopups` — do not add it.
A guest page's `target="_blank"` links (Streamlit's "Ask Google" traceback
button) reach the OS browser through one explicit bridge instead:
- `main.ts` installs `electron/preview-guest-preload-entry.ts` via
`will-attach-webview`, keyed on the `persist:hermes-preview` partition only.
It is the app's only guest preload; a new webview does not inherit it unless
it opts into that partition.
- The preload runs in the isolated world, exposes nothing to the page, and
forwards only a **trusted** (`event.isTrusted`) primary-button click on an
`a[target="_blank"]` to the host via `ipcRenderer.sendToHost`. A synthetic
`dispatchEvent(click)` from page script is dropped there; page `window.open`
stays blocked.
- `PreviewPane` admits `http:`/`https:` only (`src/lib/preview-external.ts`)
and hands the URL to the existing `hermes:openExternal` IPC, which applies
main's URL policy. `file:` is excluded on purpose: a guest must never reach
`shell.openPath`.
Widening any of those three (partition key, trusted-click gate, scheme set)
reopens the gesture-less forced-navigation class the advisory closed.
## Nous free tier: state is pulled, never latched in the renderer
The free tier (a Nous identity with no account, `hermes_cli/anon_auth.py`) reaches the renderer
through one JSON-RPC pair: `free_tier.status` (has_guest, enabled, available,
notice_pending, model, label) read from local auth state with zero network, and
`free_tier.ack_notice`, which persists the one-time notice flag on the identity itself. The
first-launch ready screen and the own-key strip are the SAME state rendered for two situations,
keyed on `notice_pending`; there is no localStorage latch, so the CLI and the desktop cannot
disagree about whether the notice was shown. Sign-in goes through the existing
`POST /api/providers/oauth/nous/start` + poll route, which over a free-tier identity registers the
connector transfer and reports `reason`, `account_email` and `model` on completion; every entry
point (Billing, status chip, ready screen) opens the one free-tier sign-in dialog. Never branch on
provider display names: the picker row carries `free_tier_row`, status cards carry `free_tier`.

View File

@@ -58,67 +58,20 @@ MagicMock adapters in older tests auto-create truthy attributes.
## Background process notifications
`terminal(background=true, notify_on_complete=true)` starts a gateway watcher that detects
completion and triggers a new agent turn. Verbosity: `display.background_process_notifications`
(or `HERMES_BACKGROUND_NOTIFICATIONS`): `concise` (default; one line, failures append an output
tail), `all` (running updates + final raw output), `result` (final raw output only), `error`
(final raw output only on non-zero exit), `off`.
The watcher is armed on the gateway loop at registration time (`terminal_tool_background.py::
_register_completion_watcher` → `run_notifications.py::arm_process_watcher`); `pending_watchers`
is only the fallback for processes registered before the gateway serves (checkpoint recovery) or
while it stops, drained at startup and post-turn. Agent-notify watchers send no user-facing
receipt (the agent's next turn is the report) unless the launching turn is still running at exit
— then the injection only queues a follow-up, so the concise receipt goes out immediately.
The idle completion watcher also drains `watch_match` / `watch_disabled`; no user follow-up is
required. Notify-off drains these without waking. Transport failures are retried; unavailable
durable completion owners/transports do not spend delivery attempts. Profile-namespaced process
events keep their owning adapter, including raw progress/final notices. Adapter acceptance is
at-least-once admission, not proof that a model turn or final outbound reply completed. API-server
async completions remain durable delivery rows, never autonomous new model turns. Require the
`admit_internal_event` receipt for completion/watch injection: a handler returning None is not
acceptance. Refused admission refunds every claimed batch sibling without spending an attempt;
actual delivery errors keep their bounded retry policy. Recognized raw API routes resolve after
persisted messaging origins and defer quietly when unavailable; malformed routes still warn.
Cron execution has its own session. Eligible continuable deliveries may mirror or seed the
reply-facing conversation: origin, origin-less home fallback, user-written bare-platform home,
or opted-in explicit targets. `all` expansions do not gain home mirror eligibility. Mirrored
briefs are labelled user turns appended at a turn boundary, preserving role alternation
(`cron/AGENTS.md`).
`terminal(background=true, notify_on_complete=true)` arms a watcher on the gateway loop
(`run_notifications.py::arm_process_watcher`) that starts a new turn on completion; verbosity is
`display.background_process_notifications`. Injection requires the `admit_internal_event` receipt
(a handler returning None is not acceptance); refused admission refunds every claimed batch sibling
without spending an attempt. Long form: `website/docs/developer-guide/gateway-internals.md`
§ Background process notifications.
## `/login` (off-turn, paired DM only)
`/login` is registered in `hermes_cli/commands.py` with `busy_policy="dispatch"` and
`desktop="settings"`, listed in `run_busy.py::_PLAIN_COMMANDS`, and handled by
`GatewayLoginCommandsMixin` (`gateway/slash_commands_login.py`). It refuses outside a paired DM:
`chat_type in {"dm","private"}`, a truthy `chat_id`, and a platform whose `"dm"` really is a paired
conversation — ntfy, raft and a2a all report `chat_type="dm"` for a broadcast topic, a channel and
an agent peer, so posting a consent link there would publish it.
**It binds the whole install.** The sign-in writes the singleton `providers.nous`, so whoever
approves the code owns this gateway's inference and connectors for every chat it serves. Slash
gating is opt-in (`gateway/slash_access.py`): with no `allow_admin_from` set, every user allowed to
DM the bot can run it. Operators of shared gateways must set it.
The handler returns its ack at once and drains `anon_auth.run_sign_in` on a **private single-worker
executor** (`_login_executor`), never the shared 10-thread gateway pool — a promotion wait can last
the code's full expiry, and a live worker in the shared pool makes shutdown skip the SessionDB
close/checkpoint. One attempt per process, stamped with the identity that started it: the same
identity's second `/login` supersedes (the loser ends with the superseded copy pushed into its own
chat); a different identity is refused. `task.cancel()` cannot interrupt a blocking poll inside a
worker thread, so shutdown and supersede both work through `attempt.cancelled`, which
`wait_for_promotion` now polls on a ≤1 s tick. A shutdown-cancelled attempt pushes nothing — the
task is dying and its adapters may already be gone — but if the server had already completed the
transfer it still persists, because that transfer is irreversible.
On completion the handler **evicts** every cached agent still on `nous/welcome` and clears any
session model override pinned to it. It does not switch agents in place and does not write a model
override: `settle_after_upgrade` already moved `model.default`/`model.base_url` in the config, every
turn re-resolves the config and the credentials, and `_agent_config_signature` already forces a
rebuild when the route changes — so an in-place swap buys no cache warmth and an override would pin
an expiring access token that nothing refreshes.
`GatewayLoginCommandsMixin` refuses outside a real paired DM (ntfy, raft and a2a report `dm` for
broadcast surfaces) and binds the WHOLE install's `providers.nous`, so shared gateways must set
`allow_admin_from`. It drains `anon_auth.run_sign_in` on its private single-worker `_login_executor`
(never the shared pool), allows one attempt per process, and on completion evicts agents still on
`nous/welcome` instead of writing a model override. Long form: `gateway-internals.md` § `/login`.
## Gateway lifecycle vs. the Desktop app

View File

@@ -124,71 +124,24 @@ thinking_verbs, wings), `tool_prefix`, `tool_emojis`, `branding.*` (agent_name,
response_label, prompt_symbol). Consumers: `banner.py`, `display.py`, `cli.py`. Key-by-key table
and YAML template: `website/docs/user-guide/features/skins.md`.
## Update pipeline (`hermes update`) — transactional; every stage guards a real field failure
## Update pipeline (`hermes update`)
Fleet-update campaign #91277 (Aug 2026). A PR that weakens a stage must answer for the failure class
it guards. `plan → snapshot → apply → restart-per-kind → verify → report`
Transactional, `plan → snapshot → apply → restart-per-kind → verify → report`; every stage guards a
real field failure (fleet campaign #91277), and a PR that weakens a stage answers for that class.
The stage-by-stage contract: `website/docs/developer-guide/cli-internals.md` § Update pipeline.
- **Plan** (`update_inventory.py`, `hermes update --plan`): read-only inventory — install kind, all
profiles, every live gateway with supervisor + running code version. Deployment kinds are
first-class: `git` updates in place; `docker`/`nix`/`apt` are NOT in-place-updatable and the
updater reports the correct external command instead of fighting the deployment model.
- **Snapshot** (`backup.py`): pre-update quick snapshot for EVERY profile (the code swap + fleet
restart touch all of them), each into its own `state-snapshots/`, identical file set, 1 GiB
per-file cap, keep=1. **Never add a partial/tiered snapshot set** — mixed coverage creates
torn-restore states across schema generations. Quick snapshots are FILE-LOSS RECOVERY (the
per-profile cron-jobs safety net restores from them), NOT code-rollback insurance; `--backup`
full mode owns rollback.
- **Apply**: git pull, or the Windows ZIP fallback — which fires ONLY when git itself failed
(`_should_zip_fallback_on_update_error`, argv-classified; a dependency-install failure must never
trigger a tree-clobbering re-download), REFUSES a dirty working tree (`-uall` + a pre-swap TOCTOU
re-check — but classifies a `!!` line by whether the swap would destroy it: an ignored path under a
root entry the ZIP does not ship (`.bytecode-fingerprint`, `.hermes-bootstrap-complete`,
`hermes_agent.egg-info/`; tracked root entries stand in for the ZIP set before the download, the
re-check gets the real one), a nested `__pycache__`/`node_modules`, or a `_ZIP_PRESERVED_NESTED`
output is admitted; other ignored files under shipped dirs still block), and grafts the live nested
build outputs (`_ZIP_PRESERVED_NESTED`: `apps/desktop/{release,dist,node_modules,build}`,
`hermes_cli/web_dist`, `ui-tui/{dist,node_modules,packages/hermes-ink/dist}`, `web/node_modules`,
`scripts/whatsapp-bridge/node_modules`) into the staged swap by hardlink (the GitHub source ZIP has
none of them; without the graft the swap deletes them). Post-swap, the Desktop
rebuild decision also trusts the build stamp under HERMES_HOME, so an install that already lost
its artifacts in an earlier update is rebuilt instead of "forgotten" (#90495).
- **Restart-per-kind**: systemd and launchd restarts are FLEET-WIDE within the updating install (every
`hermes-gateway*` unit / `ai.hermes.gateway*` LaunchAgent whose home is the updating root or one of its
`profiles/<name>`), drain-first (SIGUSR1), with per-unit/per-label failure isolation. Restarting only the
invoking profile's service leaves siblings on stale `sys.modules` until they crash — the largest dupe-PR
cluster in the repo's history came from that bug. The fleet is bounded by HOME, not by namespace:
`hermes_cli/update_fleet_scope.py` judges every unit/label/process by the home it actually runs on
(live environ, unit `Environment=`, plist `HERMES_HOME`), and a runtime of another `HERMES_HOME` on the
same account — a sibling install, the real `hermes-gateway.service` seen from a scratch home — is named and
left alone, never restarted (#93349).
- **Verify**: gateways stamp `code_sha`/`code_version` into `gateway_state.json` on every
runtime-status write (`gateway/status.py`); the updater compares each live gateway against the
fresh checkout and prints a fleet version matrix. A provably-stale gateway fails the update
(exit 1) — automation must never treat a mixed-version fleet as healthy.
- **Report**: every run writes a machine-readable receipt to `~/.hermes/logs/update_receipts/`
(`latest.json` pointer; steps, skips WITH reasons, restart outcome, plan, fleet snapshot).
Before a source swap, the parent captures plan/snapshots/receipt and its Windows pause token.
`update_completion.py` runs new-code PM preparation with site initialization disabled, then
selected-Python builds, maintenance, scans/restarts and verification. Git/current/ZIP share
this owner; never reload or purge modules to continue in the old interpreter. The parent keeps
the lock, waits, and accepts only a correlated terminal result. `cmd_update` still finalizes
early failures and missing/killed-child outcomes; PM refusal data survives the handoff.
See `website/docs/developer-guide/source-update-completion.md`. A begun-but-unwritten receipt is a bug.
- **Nothing runs pulled code in the pre-pull interpreter.** This tree finishes updates through
`update_completion.run_completion` / `_update_takeover.py`.
`hermes_cli/update_handoff.py` and `hermes_cli/update_serve_obligations.py` are the
FROZEN COMPAT SURFACE for releases that lazily import those module
names from the NEW tree after the checkout swap.
Keep their public names importable and behavior-preserving. They call into
`_old_updater.stop_for_relaunch` → `_run_child`. Removing a name
bricks every release mid-update. see `tests/compat/old_updater_surface.json`.
Process-scan coordination between updater, serve/dashboard, and gateway is being replaced by a
gateway-owned control socket (#92091); scans are the fallback layer for old/crashed processes — read
#92091 before adding any heuristic. Process identity rules (never argv substrings; canonical
matchers; parser-derived flag sets; never blanket-exclude gateway ancestors, #87594): root
`AGENTS.md` and `website/docs/developer-guide/cli-internals.md`.
- Snapshots cover EVERY profile with one identical file set (never partial or tiered); they are
file-loss recovery, `--backup` owns rollback.
- The Windows ZIP fallback fires only when git itself failed, refuses a dirty tree, and grafts the
`_ZIP_PRESERVED_NESTED` build outputs into the swap.
- Restarts are fleet-wide and drain-first, bounded by the home each unit runs on
(`update_fleet_scope.py`); another install's runtime is never restarted.
- A provably-stale gateway fails the update (exit 1); every run writes a receipt under
`logs/update_receipts/`.
- Nothing runs pulled code in the pre-pull interpreter. `update_handoff.py` and
`update_serve_obligations.py` are a FROZEN compat surface (`tests/compat/old_updater_surface.json`):
removing a name bricks releases mid-update.
- Read #92091 (gateway-owned control socket) before adding any process-scan heuristic.
## Profiles (multi-instance)
@@ -257,29 +210,8 @@ recorded when both exist. Process liveness is `(pid, start_time)` or the canonic
## Nous free tier (`hermes_cli/anon_auth.py`)
Sign-in completion is one function, `settle_after_upgrade`, called by every caller that persists an
account over a free-tier identity (CLI `upgrade_guest`, the desktop poller): it moves a config on the
welcome route to the account's host and the tier's recommended default
(`models.recommended_nous_default_model`, shared with `GET /api/model/recommended-default`).
The shared flow, states, and copy live in `anon_sign_in.py`; CLI rendering lives in
`anon_sign_in_cli.py`. `anon_auth.py` keeps identity, promotion polling, and settlement, and
re-exports the existing sign-in API. The flow resolves identity and persistence collaborators
through `anon_auth` at call time to preserve module-attribute monkeypatch seams.
The sign-in itself is one composition: `anon_auth.run_sign_in()` yields `SignInState`s (`Code`,
`Waiting`, `Completed`, `Declined`, `Superseded`, `TimedOut`, `Retired`, `Failed`,
`AlreadySignedIn`, `Unavailable`). It reads the current state itself, holds one absolute deadline
across both waits, persists only after a completed promotion **and** a token grant, runs
`settle_after_upgrade` exactly once per completion, and never lets a persist or settle failure
escape as an exception — it becomes `Failed`. Every state carries its own `.copy` (the chat form,
which never contains a raw exception, a URL or a `hermes` verb) and `.copy_terminal`, so no caller
maps a reason to a string. `cancelled()` stops an attempt; `cancel_wins_after_promotion` decides
what happens when the server had already completed the transfer — the desktop keeps `True` (a
DELETE means "not on this machine"), the gateway passes `False` (a supersede must not discard a
transfer the user actually approved). `scope` is entered only around the precondition and persist
blocks, never across a `yield` or a network wait, because `run_in_executor` does not carry
contextvars. `upgrade_guest` (`hermes auth upgrade`), the CLI `/login` handler and the desktop
promotion poller are renderers over it; a surface that needs the cancel check and the save to be
atomic passes `persist_guard`. The desktop's plain "connect another Nous account" device-code login
is a separate path (`_nous_plain_poller`) and must stay one.
`anon_auth.run_sign_in()` is the one sign-in composition: it yields `SignInState`s that carry their
own `.copy` / `.copy_terminal`, persists only after a completed promotion AND a token grant, and runs
`settle_after_upgrade` exactly once per completion. `hermes auth upgrade`, the CLI and gateway
`/login` and the desktop poller are renderers over it; the desktop's plain device-code login
(`_nous_plain_poller`) stays a separate path. Long form: `cli-internals.md` § Nous free tier sign-in.

View File

@@ -10,9 +10,72 @@ Companion to `hermes_cli/AGENTS.md` (the rules) — this page holds the longer e
## Update pipeline
The stage-by-stage contract (`plan → snapshot → apply → restart-per-kind → verify → report`) and the
field failure each stage guards are documented in `hermes_cli/AGENTS.md`; user-facing behaviour
(receipts, `--plan`, snapshot modes) is in [Updating](../getting-started/updating.md).
User-facing behaviour (receipts, `--plan`, snapshot modes) is in
[Updating](../getting-started/updating.md); `hermes_cli/AGENTS.md` carries the short form of the rules.
Fleet-update campaign #91277 (Aug 2026). A PR that weakens a stage must answer for the failure class
it guards. `plan → snapshot → apply → restart-per-kind → verify → report`
- **Plan** (`update_inventory.py`, `hermes update --plan`): read-only inventory — install kind, all
profiles, every live gateway with supervisor + running code version. Deployment kinds are
first-class: `git` updates in place; `docker`/`nix`/`apt` are NOT in-place-updatable and the
updater reports the correct external command instead of fighting the deployment model.
- **Snapshot** (`backup.py`): pre-update quick snapshot for EVERY profile (the code swap + fleet
restart touch all of them), each into its own `state-snapshots/`, identical file set, 1 GiB
per-file cap, keep=1. **Never add a partial/tiered snapshot set** — mixed coverage creates
torn-restore states across schema generations. Quick snapshots are FILE-LOSS RECOVERY (the
per-profile cron-jobs safety net restores from them), NOT code-rollback insurance; `--backup`
full mode owns rollback.
- **Apply**: git pull, or the Windows ZIP fallback — which fires ONLY when git itself failed
(`_should_zip_fallback_on_update_error`, argv-classified; a dependency-install failure must never
trigger a tree-clobbering re-download), REFUSES a dirty working tree (`-uall` + a pre-swap TOCTOU
re-check — but classifies a `!!` line by whether the swap would destroy it: an ignored path under a
root entry the ZIP does not ship (`.bytecode-fingerprint`, `.hermes-bootstrap-complete`,
`hermes_agent.egg-info/`; tracked root entries stand in for the ZIP set before the download, the
re-check gets the real one), a nested `__pycache__`/`node_modules`, or a `_ZIP_PRESERVED_NESTED`
output is admitted; other ignored files under shipped dirs still block), and grafts the live nested
build outputs (`_ZIP_PRESERVED_NESTED`: `apps/desktop/{release,dist,node_modules,build}`,
`hermes_cli/web_dist`, `ui-tui/{dist,node_modules,packages/hermes-ink/dist}`, `web/node_modules`,
`scripts/whatsapp-bridge/node_modules`) into the staged swap by hardlink (the GitHub source ZIP has
none of them; without the graft the swap deletes them). Post-swap, the Desktop
rebuild decision also trusts the build stamp under HERMES_HOME, so an install that already lost
its artifacts in an earlier update is rebuilt instead of "forgotten" (#90495).
- **Restart-per-kind**: systemd and launchd restarts are FLEET-WIDE within the updating install (every
`hermes-gateway*` unit / `ai.hermes.gateway*` LaunchAgent whose home is the updating root or one of its
`profiles/<name>`), drain-first (SIGUSR1), with per-unit/per-label failure isolation. Restarting only the
invoking profile's service leaves siblings on stale `sys.modules` until they crash — the largest dupe-PR
cluster in the repo's history came from that bug. The fleet is bounded by HOME, not by namespace:
`hermes_cli/update_fleet_scope.py` judges every unit/label/process by the home it actually runs on
(live environ, unit `Environment=`, plist `HERMES_HOME`), and a runtime of another `HERMES_HOME` on the
same account — a sibling install, the real `hermes-gateway.service` seen from a scratch home — is named and
left alone, never restarted (#93349).
- **Verify**: gateways stamp `code_sha`/`code_version` into `gateway_state.json` on every
runtime-status write (`gateway/status.py`); the updater compares each live gateway against the
fresh checkout and prints a fleet version matrix. A provably-stale gateway fails the update
(exit 1) — automation must never treat a mixed-version fleet as healthy.
- **Report**: every run writes a machine-readable receipt to `~/.hermes/logs/update_receipts/`
(`latest.json` pointer; steps, skips WITH reasons, restart outcome, plan, fleet snapshot).
Before a source swap, the parent captures plan/snapshots/receipt and its Windows pause token.
`update_completion.py` runs new-code PM preparation with site initialization disabled, then
selected-Python builds, maintenance, scans/restarts and verification. Git/current/ZIP share
this owner; never reload or purge modules to continue in the old interpreter. The parent keeps
the lock, waits, and accepts only a correlated terminal result. `cmd_update` still finalizes
early failures and missing/killed-child outcomes; PM refusal data survives the handoff.
See `website/docs/developer-guide/source-update-completion.md`. A begun-but-unwritten receipt is a bug.
- **Nothing runs pulled code in the pre-pull interpreter.** This tree finishes updates through
`update_completion.run_completion` / `_update_takeover.py`.
`hermes_cli/update_handoff.py` and `hermes_cli/update_serve_obligations.py` are the
FROZEN COMPAT SURFACE for releases that lazily import those module
names from the NEW tree after the checkout swap.
Keep their public names importable and behavior-preserving. They call into
`_old_updater.stop_for_relaunch` → `_run_child`. Removing a name
bricks every release mid-update. see `tests/compat/old_updater_surface.json`.
Process-scan coordination between updater, serve/dashboard, and gateway is being replaced by a
gateway-owned control socket (#92091); scans are the fallback layer for old/crashed processes — read
#92091 before adding any heuristic. Process identity rules (never argv substrings; canonical
matchers; parser-derived flag sets; never blanket-exclude gateway ancestors, #87594): root
`AGENTS.md` and `website/docs/developer-guide/cli-internals.md`.
The systemd blunt-restart fallback waits for the unit's `TimeoutStopUSec` plus
`TimeoutStartUSec`, with 15 seconds of client-side slack. It reads the target unit
@@ -31,6 +94,35 @@ weeks, months and years. The combined timeout is capped below the native signed
unit limits cannot overflow subprocess polling. Zero/unknown/infinite phase
limits use the bounded fallback. This does not change active-turn drain settings.
## Nous free tier sign-in
Sign-in completion is one function, `settle_after_upgrade`, called by every caller that persists an
account over a free-tier identity (CLI `upgrade_guest`, the desktop poller): it moves a config on the
welcome route to the account's host and the tier's recommended default
(`models.recommended_nous_default_model`, shared with `GET /api/model/recommended-default`).
The shared flow, states, and copy live in `anon_sign_in.py`; CLI rendering lives in
`anon_sign_in_cli.py`. `anon_auth.py` keeps identity, promotion polling, and settlement, and
re-exports the existing sign-in API. The flow resolves identity and persistence collaborators
through `anon_auth` at call time to preserve module-attribute monkeypatch seams.
The sign-in itself is one composition: `anon_auth.run_sign_in()` yields `SignInState`s (`Code`,
`Waiting`, `Completed`, `Declined`, `Superseded`, `TimedOut`, `Retired`, `Failed`,
`AlreadySignedIn`, `Unavailable`). It reads the current state itself, holds one absolute deadline
across both waits, persists only after a completed promotion **and** a token grant, runs
`settle_after_upgrade` exactly once per completion, and never lets a persist or settle failure
escape as an exception — it becomes `Failed`. Every state carries its own `.copy` (the chat form,
which never contains a raw exception, a URL or a `hermes` verb) and `.copy_terminal`, so no caller
maps a reason to a string. `cancelled()` stops an attempt; `cancel_wins_after_promotion` decides
what happens when the server had already completed the transfer — the desktop keeps `True` (a
DELETE means "not on this machine"), the gateway passes `False` (a supersede must not discard a
transfer the user actually approved). `scope` is entered only around the precondition and persist
blocks, never across a `yield` or a network wait, because `run_in_executor` does not carry
contextvars. `upgrade_guest` (`hermes auth upgrade`), the CLI `/login` handler and the desktop
promotion poller are renderers over it; a surface that needs the cancel check and the save to be
atomic passes `persist_guard`. The desktop's plain "connect another Nous account" device-code login
is a separate path (`_nous_plain_poller`) and must stay one.
## Process identity: never infer it from argv substrings
The bug class behind ~10 fleet-update issues (#90778, #87594, #78089, #76129, #91964, ...):

View File

@@ -271,6 +271,70 @@ The gateway runs as a long-lived process, managed via:
**Profile-scoped vs global**: `start_gateway()` uses profile-scoped PID files. Standalone (one gateway per profile), `hermes -p x gateway stop` stops only that profile's gateway. Under multiplexing there is ONE gateway process per host, owned by whichever profile launched it (`gateway/host_rendezvous.py` publishes its PID, home and served set; `gateway/host_attach.py` is the attach/rescan/refuse decision every lifecycle verb goes through): `hermes gateway stop` on the owner takes every served profile down, and `hermes -p x gateway stop` for a served secondary refuses with exit 78 (it has no gateway of its own). A second `gateway run` for a served profile attaches and exits 0 — under a service supervisor it exits 75 (EX_TEMPFAIL) instead, so the redundant unit is RETRIED rather than parked: "someone else serves me right now" is a runtime observation that ends when that process does, and 78 (which systemd, s6 and launchd all treat as permanent) would strand the profile. ATTACH requires a live `identify` answer from the owner; a rendezvous record with nothing answering behind it proves an owner exists but never that it serves you, so it yields a transient refusal (exit 75), never an attach. An owner that answers `multiplex: False` to the rescan is another profile's *standalone* gateway, not a multiplexer that excluded you: the verb starts this profile's own gateway beside it (the one-process-per-profile topology), it does not refuse — refusing there exited 78 and parked every launchd unit but the first to claim the host lock. `hermes gateway stop --all` uses global `ps aux` scanning to kill all gateway processes (used during updates). Liveness is decided by `gateway.status.live_gateway_pid_for_home` (PID + start-time fingerprint), never bare PID existence.
## Background process notifications
`terminal(background=true, notify_on_complete=true)` starts a gateway watcher that detects
completion and triggers a new agent turn. Verbosity: `display.background_process_notifications`
(or `HERMES_BACKGROUND_NOTIFICATIONS`): `concise` (default; one line, failures append an output
tail), `all` (running updates + final raw output), `result` (final raw output only), `error`
(final raw output only on non-zero exit), `off`.
The watcher is armed on the gateway loop at registration time (`terminal_tool_background.py::
_register_completion_watcher` → `run_notifications.py::arm_process_watcher`); `pending_watchers`
is only the fallback for processes registered before the gateway serves (checkpoint recovery) or
while it stops, drained at startup and post-turn. Agent-notify watchers send no user-facing
receipt (the agent's next turn is the report) unless the launching turn is still running at exit
— then the injection only queues a follow-up, so the concise receipt goes out immediately.
The idle completion watcher also drains `watch_match` / `watch_disabled`; no user follow-up is
required. Notify-off drains these without waking. Transport failures are retried; unavailable
durable completion owners/transports do not spend delivery attempts. Profile-namespaced process
events keep their owning adapter, including raw progress/final notices. Adapter acceptance is
at-least-once admission, not proof that a model turn or final outbound reply completed. API-server
async completions remain durable delivery rows, never autonomous new model turns. Require the
`admit_internal_event` receipt for completion/watch injection: a handler returning None is not
acceptance. Refused admission refunds every claimed batch sibling without spending an attempt;
actual delivery errors keep their bounded retry policy. Recognized raw API routes resolve after
persisted messaging origins and defer quietly when unavailable; malformed routes still warn.
Cron execution has its own session. Eligible continuable deliveries may mirror or seed the
reply-facing conversation: origin, origin-less home fallback, user-written bare-platform home,
or opted-in explicit targets. `all` expansions do not gain home mirror eligibility. Mirrored
briefs are labelled user turns appended at a turn boundary, preserving role alternation
(`cron/AGENTS.md`).
## `/login` (off-turn, paired DM only)
`/login` is registered in `hermes_cli/commands.py` with `busy_policy="dispatch"` and
`desktop="settings"`, listed in `run_busy.py::_PLAIN_COMMANDS`, and handled by
`GatewayLoginCommandsMixin` (`gateway/slash_commands_login.py`). It refuses outside a paired DM:
`chat_type in {"dm","private"}`, a truthy `chat_id`, and a platform whose `"dm"` really is a paired
conversation — ntfy, raft and a2a all report `chat_type="dm"` for a broadcast topic, a channel and
an agent peer, so posting a consent link there would publish it.
**It binds the whole install.** The sign-in writes the singleton `providers.nous`, so whoever
approves the code owns this gateway's inference and connectors for every chat it serves. Slash
gating is opt-in (`gateway/slash_access.py`): with no `allow_admin_from` set, every user allowed to
DM the bot can run it. Operators of shared gateways must set it.
The handler returns its ack at once and drains `anon_auth.run_sign_in` on a **private single-worker
executor** (`_login_executor`), never the shared 10-thread gateway pool — a promotion wait can last
the code's full expiry, and a live worker in the shared pool makes shutdown skip the SessionDB
close/checkpoint. One attempt per process, stamped with the identity that started it: the same
identity's second `/login` supersedes (the loser ends with the superseded copy pushed into its own
chat); a different identity is refused. `task.cancel()` cannot interrupt a blocking poll inside a
worker thread, so shutdown and supersede both work through `attempt.cancelled`, which
`wait_for_promotion` now polls on a ≤1 s tick. A shutdown-cancelled attempt pushes nothing — the
task is dying and its adapters may already be gone — but if the server had already completed the
transfer it still persists, because that transfer is irreversible.
On completion the handler **evicts** every cached agent still on `nous/welcome` and clears any
session model override pinned to it. It does not switch agents in place and does not write a model
override: `settle_after_upgrade` already moved `model.default`/`model.base_url` in the config, every
turn re-resolves the config and the credentials, and `_agent_config_signature` already forces a
rebuild when the route changes — so an in-place swap buys no cache warmth and an override would pin
an expiring access token that nothing refreshes.
## Multiplexed profiles
With `gateway.multiplex_profiles: true` one process serves the default profile plus every live directory under `profiles/` (`hermes_cli/profiles.py::profiles_to_serve(multiplex=True)`). `os.environ` and module globals hold the **launch** profile's values, so every activity for a secondary binds its scope explicitly — a profile is home + secret scope + terminal scope together: