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:
@@ -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`.
|
||||
|
||||
@@ -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
163
apps/desktop/ENGINEERING.md
Normal 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`.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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, ...):
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user