diff --git a/apps/desktop/AGENTS.md b/apps/desktop/AGENTS.md index 34f6bab173..014876562f 100644 --- a/apps/desktop/AGENTS.md +++ b/apps/desktop/AGENTS.md @@ -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/` 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 `` (`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 `` 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=/.hermes HERMES_DESKTOP_USER_DATA_DIR=/electron-user-data npm run dev` -(replace `` 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=/.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`. diff --git a/apps/desktop/BUILDING.md b/apps/desktop/BUILDING.md index 013581c951..7fbcd7bdae 100644 --- a/apps/desktop/BUILDING.md +++ b/apps/desktop/BUILDING.md @@ -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=/.hermes HERMES_DESKTOP_USER_DATA_DIR=/electron-user-data npm run dev` +(replace `` 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=/.hermes/shared` +before `npm run dev`. Stop Electron and its dev server after the run. diff --git a/apps/desktop/ENGINEERING.md b/apps/desktop/ENGINEERING.md new file mode 100644 index 0000000000..5a01082a37 --- /dev/null +++ b/apps/desktop/ENGINEERING.md @@ -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/` 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 `` (`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`. diff --git a/gateway/AGENTS.md b/gateway/AGENTS.md index 39836e9425..785a5d8c0d 100644 --- a/gateway/AGENTS.md +++ b/gateway/AGENTS.md @@ -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 diff --git a/hermes_cli/AGENTS.md b/hermes_cli/AGENTS.md index 9ac251e0bf..4fea926e60 100644 --- a/hermes_cli/AGENTS.md +++ b/hermes_cli/AGENTS.md @@ -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/`), 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. diff --git a/website/docs/developer-guide/cli-internals.md b/website/docs/developer-guide/cli-internals.md index f5145a3767..d3610e70e9 100644 --- a/website/docs/developer-guide/cli-internals.md +++ b/website/docs/developer-guide/cli-internals.md @@ -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/`), 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, ...): diff --git a/website/docs/developer-guide/gateway-internals.md b/website/docs/developer-guide/gateway-internals.md index 6c7c4980ce..e1fdf17da5 100644 --- a/website/docs/developer-guide/gateway-internals.md +++ b/website/docs/developer-guide/gateway-internals.md @@ -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: