AGENTS.md@cli · diff
git:20261011.ee62daa to git:20261012.75177da
2425 added, 2393 removed, shown as one replacement because the pair was too large to align, first 3,000 lines shown. Audit C to B.
# agents-cli (the CLI)
`@phnx-labs/agents-cli` — the `agents` / `ag` CLI for managing AI coding-agent
versions, config, sessions, and cloud dispatch (Claude, Codex, Cursor,
OpenCode, OpenClaw, Grok, Droid, …).
> **New agent? Start with [`docs/README.md`](docs/README.md).** It links the
> compact architecture, concepts, execution, and subsystem decision documents.
This is the **internal architecture** map. The user-facing feature tour is
- [README.md](README.md) (pin versions, run, sessions, hosts, teams, workflows,
- plugins, browser, secrets, routines). This file covers the design choices,
- module map, build, and release mechanics the README does not.
-
- > Phoenix Labs · FSL-1.1-Apache-2.0. Repo-wide policy (conventions, code review, security)
- > lives in the root [AGENTS.md](../AGENTS.md).
-
- `agents setup` is the re-runnable onboarding hub. It reports live readiness for
- core, browser, computer, secrets, term, accounts, fleet, watchdog, and preferences, then
- delegates each selected phase to its existing `agents setup <capability>` wizard.
- `agents setup status --json` is the non-interactive view of the same probes.
- `agents setup tools` installs or upgrades the standalone `sessions`, `browser`,
- `secrets`, `computer` and `term` CLIs to the releases pinned in
- [`src/lib/standalone-tools.ts`](src/lib/standalone-tools.ts); see
- [`docs/setup.md`](docs/setup.md#pinned-standalone-releases). The
- `term` phase installs the standalone `term` CLI (`@phnx-labs/term-cli`, the PTY
- engine the setup-token mint behind `agents accounts add`/`login` spawns on demand, PHNX-4091);
- presence on PATH is its whole readiness signal — there is nothing else to configure.
-
- Personal operating reminders live in
- `~/.agents/reminders/reminders.yaml` (each a `short`/`full` pair; there is no
- command for them, PHNX-4267 retired `agents reminders`). They surface
- succinctly in the Claude statusline — one per session, chosen deterministically
- from the session id (`pickReminderForSession` in
- [`src/lib/reminders.ts`](src/lib/reminders.ts)) so concurrent agents each show a
- different one and it stays stable within a session. Presence of the file with at
- least one entry is the opt-in; there is no flag. The render is Claude-only
- ([`renderClaudeStatusLine`](src/lib/claude-statusline.ts) appends the dimmed part
- after the usage windows) because the statusline is a Claude Code surface; other
- harnesses expose no equivalent always-visible line. The full line reads
- `host · account · model · [delegate] · 5h n% · 7d n% · ◆ reminder`: the account is
- the registered account NAME when the running login has one (`work`, `dev` — the
- `agents accounts` row for that identity, via `findNativeAccountByIdentity`), else the
- email it is signed into (plus the org name for a Team/Enterprise seat, via
- `accountDisplayLabel`). The identity is read once per render (`readClaudeIdentity`,
- shared with the usage ingest) from the `.claude.json` Claude is actually running with —
- `$CLAUDE_CONFIG_DIR`'s home under the shim, else `$HOME` — so the 5h/7d figures next
- to it are attributed at a glance. A malformed file is swallowed
- by the statusline (a broken prompt is worse than a missing line). The file syncs
- across the fleet via `agents repo push/pull`.
-
- Artifact sharing is no longer part of agents-cli. The `agents artifacts`
- command group, the managed share Worker, and the `lib/share/` engine were removed
- (PHNX-3992); publishing an HTML artifact now lives entirely in the standalone
- `artifacts` CLI (`@phnx-labs/artifacts-cli`), driven as `artifacts share …` /
- `artifacts auth login`, mirroring the secrets (PHNX-3989) and computer (PHNX-4075)
- extractions. What stays here is `agents sessions share <id>`, which renders the
- transcript locally and shells out to `artifacts share`.
-
- `agents feed watch --json` is the canonical thin-client operator stream: it
- composes the existing session watcher with feed attention, activity, and **tool
- rows** (browser tasks + computer runs), while
- `agents sessions watch --json` remains the compatible session-only stream.
- Answers go through `agents feed answer <attention-key>` so the CLI atomically
- claims the first reply and routes it over the recorded session reply rail.
-
- **It is designed to run for as long as a VS Code window is open, so every tick is
- budgeted (PHNX-3939).** AGI EXT's elected leader spawns exactly ONE `feed watch
- --json` child and fans it out to every window, so a per-tick cost here is paid for
- the whole session, not per render. Three rules keep it flat and truthful, and a
- change that breaks any of them is a regression, not a detail:
-
- - **Activity is read incrementally, never re-scanned.**
- [`ActivityStream`](src/lib/feed/activity-stream.ts) holds a per-file cursor
- (inode, offset, mtime, ctime, a trailing partial line, and the bytes behind the
- cursor — ctime is what catches a same-length in-place rewrite, exactly as
- `activityStamp` keys the one-shot reader's cache)
- and reads only what was appended since the last tick, driven by an `fs.watch` on
- the activity dir with a 5 s stat sweep as the fallback. The opening scan opens
- no files at all — every log is registered past its own bytes, so history is
- never replayed. Do **not** put `readRecentActivity` back on the tick: it tails
- and parses every session log in the directory, which on a real operator box is
- 1,437 files / 64 MB every 500 ms (measured 41.9% of a core over a 5-minute
- steady state; the cursor reader is 0.31%). `readRecentActivity` remains right for a one-shot `agents feed` render.
- - **Attention is reconciled on change, not on the tick.** A reconcile pass reads
- each row's block and resolution and its PR status, so running it twice a second
- was two file reads per row per tick for a value that almost never moved. It runs
- when something announced a change — the feed dir and `feed/resolutions` are
- watched directly, which is how an external `agents feed post --blocked` still
- raises promptly — or when `PR_STATUS_TTL_MS` (45 s) has expired and the cached
- PR verdicts are stale. The only other time-dependent verdict in
- `reconcileAttention` is the 30-minute trust window on a permission block the
- session offers no transcript cursor to verify (`UNVERIFIED_PROMPT_AGE_MS`,
- PHNX-3999), and that 45 s cadence is what re-evaluates it — the reconciler
- takes `nowMs` as an input and owns no clock of its own.
- - **A request is classified from explicit evidence, never from a hook having
- fired or from elapsed time (PHNX-3999, spec SES-40f).** Claude's `idle_prompt`
- is a finished turn, not a request: the feed-publish hook does not publish it,
- and the reconciler yields nothing for one an older hook left on disk. A
- `permission` needs a recorded `permission_prompt` subtype that the transcript
- corroborates via `ActiveSession.lastEventMs` (the harness stamp on the last
- meaningful event — the file mtime moves on every hook firing, so it cannot be
- the cursor). The state engine never infers `permission` from a quiet pending
- tool call, and `computeLiveSignals` memoizes the parsed tail on mtime but
- re-runs `inferSessionState` against the current clock every call, so the
- 30-minute prose-question decay expires on schedule. Anything the CLI cannot
- confirm is `unverified`: no choices, an open-session action only, refused by
- `feed answer`.
-
- The fleet fan-out has its own budget: both `watchFleetFeed` and
- `watchFleetSessions` subscribe to peers through
- [`streamFromPeer`](src/lib/session/remote/peer-stream.ts), which backs off per peer
- (2 s doubling to a 60 s cap, reset on a healthy protocol event), captures the
- child's stderr into the `unavailable` reason instead of discarding it, and parks a
- peer after three consecutive failed spawns until the device registry changes.
- `isDialableDevice` deliberately never excludes an unreachable box
- ([`devices/registry.ts`](src/lib/devices/registry.ts)), so backoff — not the
- dialable set — is what keeps an offline peer from minting an ssh child every few
- seconds. A peer that fails **ten** in a row is RETIRED onto a 15-minute re-dial
- (`PEER_RETIRE_AFTER_FAILURES` / `PEER_RETIRED_RECHECK_MS`, PHNX-3999): the cap
- bounds the delay but not the total work, and a box off for a weekend was otherwise
- dialed ~2,880 times per watcher. It still wakes immediately on a registry change,
- so a returning box is never stranded. Add a third fan-out and it uses this helper;
- do not re-roll the loop.
-
- ### One shared fleet collector, N readers (PHNX-3999)
-
- `watchFleetFeed` is correct per peer and was wrong per CALLER: it opened that ssh
- child **for every consumer**, so the extension's leader child, the menu-bar helper
- and an operator's `agents feed watch --json` each held their own fan-out — three
- readers on a thirteen-device fleet is thirty-nine ssh children carrying
- byte-identical NDJSON, three activity cursors, and three independent backoff
- ladders re-dialing the same offline box.
-
- [`FeedHub`](src/lib/feed/hub.ts) owns exactly ONE `watchFleetFeed` and broadcasts
- to every subscriber; [`FeedHubServer`](src/lib/feed/hub-server.ts) exposes it over
- a unix socket (named pipe on Windows) writing the SAME NDJSON envelopes, and the
- daemon's `feed-stream` service owns it — the fan-out dials peers, so by the
- one-scheduler/one-executor rule it cannot live in a UI surface. Four properties
- are load-bearing:
-
- - **Demand-gated.** The fan-out starts on the FIRST subscriber and stops on the
- LAST, so a box with no reader open holds no peer connections at all.
- - **A late reader costs nothing.** The hub keeps the per-scope rows the stream has
- delivered and synthesizes a reset per scope out of that state — no second
- fan-out, no re-dial, no waiting for peers to re-announce. Each subscriber's
- `streamId`/`sequence` are its own and start at 1, which is exactly what the
- published "order by streamId + sequence" contract allows.
- - **TWO collectors, one socket, and the scope is mandatory.** The `feed-stream`
- service hosts the fleet hub (`watchFleetFeed`) AND the shared local hub
- (`sharedLocalFeedHub()` in [`feed/watch.ts`](src/lib/feed/watch.ts), which wraps
- `watchLocalFeed` and dials no peer), so a local-only reader can never start a
- fan-out. A client selects one with a required handshake line
- (`{"v":1,"scope":"fleet"|"local"}`); a missing, unparseable, unknown, or LATE
- scope is **rejected with a reason**, never defaulted — guessing `fleet` started
- ssh children to every peer for a client that never asked for them.
- - **No client-side fallback.** `feed watch --json` (both scopes) attaches to the
- hub and, if the daemon is down, starts it and WAITS for the bind (`waitForHub`)
- before retrying; it never runs its own collector, because that restores the
- per-caller fan-out this replaced. `--local` no longer runs `watchLocalFeed`
- in-process: each observing box's ssh subscription used to build a separate
- activity cursor set, tool watcher and setup subscription on the same peer.
- - **A large snapshot reaches a healthy reader whole; a stalled reader cannot
- grow the daemon.** Each connection has one ordered writer (`ReaderWriter` in
- [`feed/hub-server.ts`](src/lib/feed/hub-server.ts)): every line — the catch-up
- snapshot, live events, the error envelope before a refusal — goes through its
- queue, is written in `HUB_WRITE_CHUNK_BYTES` chunks, and waits for `'drain'`
- after every write the socket did not accept, so the socket never holds more
- than one chunk past its high-water mark; `pendingBytes` (queued lines, the
- unwritten rest of the frame in flight, the socket buffer) is a gauge of
- unflushed bytes, not of retained heap — a frame's Buffer lives until its last
- chunk is written. Two bounds drop a reader, each on its own counter: live
- bytes queued past `HUB_CLIENT_BACKLOG_LIMIT` for longer than
- `HUB_BACKLOG_GRACE_MS` (`droppedForBacklog`), and a chunk left unaccepted for
- `HUB_DRAIN_STALL_MS` (`droppedForStall`). What a reader can make the daemon
- hold is therefore time- and rate-dependent — budget plus ingress during the
- grace plus the snapshot and frame in flight — not a hard 4 MiB allocation
- cap. The budget is a sustained condition, never a verdict on
- one envelope or one burst — a cold collector hands the first reader every
- peer's reset as a live event inside one tick — and the held-state replay is
- not counted at all. Judging a 5 MB fleet reset against the budget the instant
- it was written is how a healthy reader used to get 8 KiB of it, no newline,
- then EOF. On the client side `streamFeedFromHub` rejects any close the caller
- did not abort — a FIN inside a line as `closed mid-frame`, a refusal or a
- daemon exit as `closed the stream` — and a consumer that throws or a
- non-envelope line rejects the same promise, so `agents feed watch --json`
- never exits 0 on a truncated stream and nothing escapes as an uncaught
- exception. A collector that fails to start is reported to its readers rather
- than leaving them on a stream indistinguishable from an idle fleet.
- - **Reset semantics preserved.** A peer's reset replaces that ONE scope's rows; a
- peer going unavailable forwards the `scope` event and RETAINS its rows, so a
- reader attaching while a box is offline still sees its last-known rows.
-
- `normalizePeerEnvelope` ([`feed/watch.ts`](src/lib/feed/watch.ts)) is the single
- ingress for a foreign envelope: `tools` was added to the `reset` payload *within*
- protocol v1, so a peer on an older CLI is a correct v1 producer that reports no
- tool rows. Normalizing once there is what lets every consumer treat the field as
- present, and is why a mixed-version fleet during a rollout does not take the
- fan-out down.
-
- ### Tool activity on the feed stream (PHNX-3999)
-
- Browser tasks and computer runs are the third row kind on the stream, projected by
- [`feed/tools.ts`](src/lib/feed/tools.ts) from the sources that already own them —
- the standalone `browser sessions --tasks --json` task rows, the standalone
- `computer sessions --json` run rows, and — for a task's tabs — the live
- `tasks.json`. The rows ride the one open stream, so a
- consumer switching its All/Agents/Browser/Computer filter **launches no commands**:
- every row for every tab is already there, and the filter is applied client-side on
- `row.kind`. A surface built on per-tool, per-device polling would be the wrong shape
- for the same reason the activity lane is read incrementally — keep new consumers on
- the stream.
-
- The envelope gains `tool.upsert` / `tool.remove` and `setup.snapshot`, and `reset`
- gains `tools: ToolRow[]` + `setup: ToolSetupRow[]` beside `agents` and
- `attention`. Setup rows are the setup teammate's cache
- ([`setup-tool-status.ts`](src/lib/setup-tool-status.ts) `getCachedToolSetup` /
- `subscribeToolSetup`) — that side owns DETECTION, this stream only publishes, so
- no probe and no secret unlock. `setup` is a **snapshot, not per-row upserts**:
- the cache always answers for every tool, so it is one coherent reading of the box
- and three independent upserts would let a pane render a mix of two readings.
-
- [`feed/tool-activity.ts`](src/lib/feed/tool-activity.ts) is the event-driven
- collector. It watches three roots recursively — the browser runtime tree, the
- event ledger dir, and the standalone computer engine's own action ledger — and
- re-projects ONLY on a reported change, emitting the diff rather than a snapshot,
- so a warm idle does no directory read, no projection, and no subprocess. It does
- NOT watch the tools' history databases (`.history/browser`, `.history/computer`):
- the listings it runs write those (WAL frames, retention, legacy adoption), so
- watching them would make every projection trigger the next.
- `armed()` is a FUNCTION over live watchers, not a flag captured at setup: a
- watcher can die later, and a frozen `armed: true` left the tick short-circuiting
- on one that would never report again, missing changes permanently. Each tick
- re-arms what is missing and sweeps until everything is watched.
-
- **An incomplete read preserves rows; it never removes them, and it is judged per
- kind.** `collectToolRows` returns `{ rows, incomplete, retry }`: `incomplete`
- names the kinds (`browser`, `computer`) whose read failed, and `ToolRowSet.diff`
- keeps that kind's published rows while the other kind keeps updating, so a Linux
- box with `browser` but no `computer` still streams browser changes. `retry` is
- set only for a failed read of an INSTALLED tool; a tool that is not installed is
- a stable state and does not re-spawn the other listing every sweep. The readers
- it depends on THROW rather than
- returning empty for a failure they cannot trust — an absent `tasks.json` is "no
- live browser", but EACCES, EMFILE or malformed JSON (usually a write in progress)
- are failures. An empty list was indistinguishable from "every task closed", so the
- differ published a remove for every live row and the operator watched their tasks
- flicker out.
-
- **The rows come from the standalone tools, never from a copy of their readers
- (PHNX-4227).** `readStandaloneBrowserRows` / `readStandaloneComputerRows` spawn
- `browser sessions --tasks --json --no-interactive` and `computer sessions --json
- --no-interactive --limit 500` (argv array, no shell, stdout capped at 64 MiB,
- 30 s timeout, asynchronous so a slow tool never stalls the hub; `watchLocalFeed` starts with no tool rows and the watcher's first projection arrives as `tool.upsert` events, so sessions and attention never wait on a tool listing) through the
- `browser-client.ts` / `computer-client.ts` resolvers. A missing binary, a non-zero
- exit, unparseable JSON, or a row without its required fields THROWS — the error
- names `agents setup tools` when the binary is absent — so that kind is
- incomplete and its rows already on the stream are kept. The tools record a
- session or launch id but cannot resolve it; `linkToolSessions` joins it against
- agents-cli's own session index (session id first, then the launch-id join over
- recent activity, hook sessions and the pid registry), which is what makes a row
- `linked` rather than `unresolved`. That join runs on the daemon's event loop on every reprojection, so it is
- incremental and yielding: a found session is reused for `TOOL_LINK_TTL_MS` (60 s, each
- entry's expiry staggered so they never refresh together), a miss for
- `TOOL_LINK_MISS_TTL_MS` (10 s), and the launch-id index is rebuilt at most once per
- window by an async scan that re-reads only activity logs whose size or mtime changed,
- then replaces the index wholesale. Precedence is unchanged: across activity logs the
- earliest event for a launch id wins, then hook sessions, then the pid registry
- (PHNX-4225: the uncached synchronous join held the loop for up to 21 s per pass and
- crash-looped the daemon on its tick deadlines). The engine names a run's machine `local` when
- it recorded no hostname; that placeholder is never published as a device.
-
- Live browser tasks come from `tasks.json` via `readLiveBrowserTasks`, which is the
- only authority on a task's tabs and the reason a task with zero captures appears
- at all. It reads the real persisted schema (`Task.createdAt` / `Task.lastActionAt`
- — **not** the `startedAt` field the service DTO uses and `tasks.json` never
- carries). A row's session identity is resolved ONCE, durable history → live record
- → device binding, before the owner link is projected, so one task cannot have an
- owner on one assembly path and not another; nothing is invented when no source
- recorded one.
-
- Two rules are not negotiable in a consumer or a future change:
-
- - **A computer run is history, not a session.** `ComputerToolRow` pins
- `live: false` and carries no close/stop command — the CLI process that performed
- those actions has exited, so an operator control there could not work. Only a
- browser task still bound in the task index gets a `closeCommand`.
- - **No secret values in a row.** Captures carry PATHS and names, never contents,
- and a task URL goes through `redactToolUrl` (userinfo stripped,
- credential-shaped query parameters replaced, fragment dropped) before it leaves
- the producing machine. A URL that does not parse is dropped rather than
- published unexamined.
- - **`ToolCapture.host` is the box that HOLDS the file, which for a computer run is
- the INVOKING machine, not the driven one.** The engine's helper RPC returns the
- image as base64 and the invoking process resolves `--out` and writes it locally,
- so even a remote-desktop screenshot lands on the box that ran the command.
- Naming the driven host sends a consumer looking on a machine that never had the
- file. `closeCommand`/`showCommand` carry `runOn` for the same reason — a later
- browser verb resolves the device from the local binding and REJECTS `--device`,
- so acting on a peer's task means running the plain argv on the host that holds
- the binding. Both commands target the standalone `browser` CLI
- (`command: "browser"`, argv without a `browser` group word); AGI Menu 1.14.6
- is the first build that runs either `agents` or `browser` (the menubar floor is
- now later, in `cli/src/lib/helper-versions.ts`).
-
- ### Session request + timeline on every row (PHNX-3939)
-
- Every session row — `agents sessions --active --json`, `sessions watch --json`,
- `feed watch --json`, history rows, and the fleet session mirror — carries three
- optional fields the AGI EXT sidebar renders directly:
-
- - **`request`** — the session's operative ask: the **latest** genuine user turn,
- tidied but **never rewritten**. `tidyRequest`
- ([`sessions-cli/src/lib/session/prompt.ts`](https://github.com/phnx-labs/sessions-cli/blob/main/src/lib/session/prompt.ts)) joins the user's
- prose verbatim and pulls everything that is not a sentence out beside it —
- screenshot and `host:/path` clip references and `@dir` mentions into
- `attachments`, shell echo and dispatch banners into `pastedLines`, a
- `<command-name>`/`/name <id>` invocation into `command` — so the card shows
- what the agent was actually told. A model paraphrase is the summarizer's
- `goal`, labeled as such; putting one here would be a violation
- ([spec SES-53](docs/specifications.md#sessions)).
- - **`timeline`** — narration-anchored steps: **one step per line the agent
- said**, with the tool calls until the next line folded into per-verb counts,
- `failed` vs `blocked`, and milestones (`worktree created`, `PR opened`,
- `N files changed`). Last 8 in full plus an `earlier` counter. The headline is
- always the harness's own words; only a step where tools ran with nothing said
- is `derived`, and `now` — the label of the call RUNNING — rides only the `live`
- step. Pure fold in
- [`sessions-cli/src/lib/session/timeline.ts`](https://github.com/phnx-labs/sessions-cli/blob/main/src/lib/session/timeline.ts) — **no model**.
- - **`files`** — what the session created / modified / deleted, from the harness's
- own ledger (Codex `FileChange`, OpenCode `patch`, Claude `file-history-delta`)
- where it keeps one, else from Edit/Write calls; `source` says which.
-
- **Zero request-path cost, same shape as the summarizer.** `runTimelinePass`
- ([`src/lib/session/timeline-pass.ts`](src/lib/session/timeline-pass.ts)) runs
- inside the daemon's existing reader-gated `SessionStateService` tick — gather,
- fold, publish, in that order so the row written to the journal carries a current
- timeline — at most 8 sessions per tick, and for the resumable harnesses (Claude,
- Codex) reading only the bytes the transcript grew by. The result is cached in
- the stamp-validated `session_timelines` table (`projection_json` for the display
- merge, `state_json` for the resume state) and merged onto a row by
- `mergeSessionTimeline` beside `mergeSessionSummary`. Only complete
- newline-terminated records are folded, so the 973,963-byte record one live
- transcript on this fleet carries can never be folded half-written. **Folding the
- appended tail onto the prior state equals folding the whole file** — pinned
- against the real transcripts in
- [`timeline.test.ts`](https://github.com/phnx-labs/sessions-cli/blob/main/src/lib/session/timeline.test.ts).
-
- **The tick's byte budget is debited by bytes READ, and a session that does not
- fit says so.** A non-resumable harness (everything but Claude and Codex) has no
- resume offset, so its only option is a whole-file re-parse — charging it the
- transcript's *growth delta* under-counted it by orders of magnitude, and gating
- its eligibility on the 4 MiB per-session allowance meant a transcript in
- (4 MiB, 16 MiB] could never qualify however idle the tick was. Both were the same
- bug seen from two sides: real 5.9 MiB and 4.2 MiB grok transcripts on this fleet
- got **no row at all** — not `ready`, not `partial`, not `unavailable` — while
- being counted `reused`, so the daemon log read healthy forever. Eligibility is now
- `min(remaining tick budget, TIMELINE_PASS_MAX_WHOLE_FILE_BYTES)`, the debit is
- `fileSize` for a whole-file branch and the chunk length for a resumable one, and a
- session that genuinely does not fit gets a `partial` row naming the budget with
- its state left at offset 0 so a quieter tick upgrades it. A per-session fold that
- throws is logged and counted `skipped`, never swallowed as "nothing new".
-
- **Redaction and escape-stripping happen at the projection point, not per
- consumer.** `projectTimeline` is the single place folded transcript text leaves
- the fold, so it scrubs every string it ships — a step's `text`, its `now`, its
- `marks`. Secrets go through `redactSecrets` (on by default; only the local-only
- `sessions trace --no-redact` opts out) and terminal escapes are ALWAYS stripped.
- This is load-bearing rather than defensive: `labelForEvent` falls back to raw
- command text when a call wrote no `description`, and `mirror.ts` publishes the
- resulting row into `~/.agents/devices/<device>/daemon-state.json`, which the
- usage-sync tick **sends to every fleet peer** over SSH — so an unscrubbed label is
- a credential copied onto every box (root `AGENTS.md` §Security). Note `sanitizeEvents` does NOT
- run on the fold's parse path (only `tail.ts` / `stream-render.ts` call it), so
- nothing downstream may assume the events arrived pre-scrubbed.
-
- **The parser keeps the per-tool label every harness writes.** `SessionEvent`
- gains `label` (Claude Bash `input.description`, Kimi `tool.call.description`,
- OpenCode `state.input.description`/`state.title`, Cursor `description` and Droid
- `summary` — both on the shared Anthropic-message parser — and the Codex parsed
- command),
- `verbClass` (Codex `parsed_cmd.type`), `changes` (a harness file ledger), and
- `phase` (Codex `AgentMessage`). Codex's typed `item_completed` stream has its own
- reader, `parseCodexItemsContent` — deliberately NOT merged into
- `parseCodexContent`, because a rollout writes the same turn twice (measured:
- 648 `custom_tool_call` + 60 `function_call` records AND 1,038 `CommandExecution`
- items in one file), so folding both would double-count every call for every
- consumer. `agents sessions trace <id> --steps` prints the same fold as text.
-
- `extractSessionTopic` and `cleanFirstUserMessage` now consult **one** skip list,
- which is what stops a `/model` echo (`<local-command-stdout>`) or a skill body
- ("Base directory for this skill:") from becoming a session's topic and then its
- title. `cleanGeneratedSessionLabel` closes the same hole on the OTHER title rung
- (PHNX-3999 F26/F27): a harness auto-title is derived from the first turn, so a `!`
- shell echo became a row titled `<bash-input>…` and a `/clear` became a row titled
- `/clear`. That cleaner — the one place Claude's `ai-title` and Cursor's
- `chatMeta.title` both pass through (`session/discover.ts`) — now **rejects**
- scaffolding and a bare control token outright rather than stripping the tags off
- (which would promote the shell command itself to the headline), so the row falls to
- the daemon-generated title and then the user's own first prompt. A user `/rename`
- (`customTitle`) is still kept verbatim, and the original turn stays on the row as
- `firstUserMessage`/`request`, so nothing is lost from the details view. The fix is
- paired with a `CONTENT_INDEX_VERSION` bump, since the label is written at index
- time and already-indexed rows would otherwise keep the junk title.
-
- **Every session row also carries `confirmedProject` (PHNX-3999 F08/F09).** It is the
- registered project definition whose root contains the session's cwd
- (`confirmedProjectForCwd`, [`src/lib/projects.ts`](src/lib/projects.ts)), or `null`
- when the association is not confirmed. That is what a UI groups by; the older
- `project` field stays exactly as it was — a bucket KEY derived from the path, which
- always answers something, and grouping by it is what filed loose and unrelated
- directories as projects of their own. Being inside *some* git repository is
- deliberately **not** an association: a repo nobody registered would still invent a
- project out of a folder name. `null` means Uncategorized — the row is still listed,
- it is just not filed under a project nobody bound it to. It is resolved on the
- device that owns the path (a definition root is a local path), and rides the live
- row, the history row, and `sessions --active --json`, so every consumer reads the
- same verdict. `SessionMeta.lastUserMessage` (schema v48) carries the latest genuine turn
- beside `firstUserMessage`, and `deriveSessionRecap` classifies that raw turn —
- with the row's attachments in hand — rather than the already-collapsed `topic`.
-
- Fleet stream ownership is projected by `src/lib/session/projection.ts` for both
- `watchFleetSessions` and `watchFleetFeed`. A session is keyed by execution device,
- harness and exact session id. Worker observations own lifecycle and content;
- `observerTerminals` carry observer-local terminal/viewing/reply metadata, plus the
- dispatching desktop's tab for a `--device` run (`originTerminal`, from the forwarded
- `AGENTS_ORIGIN_DEVICE` + `AGENTS_ORIGIN_TERMINAL_ID`, PHNX-4263). Peer loss
- retains state until the next owner reset; an answered owner's absent row cannot
- be resurrected by a stale launcher or mirrored history.
-
- Linux process-view ownership protects host snapshots and PID records from foreign
- namespaces. Initial-host or kernel-verified daemon authority permits legacy
- migration/reboot recovery. Reusing a private-container HOME across namespaces is
- unsupported even after its former writer exits; refuse with a diagnostic, never
- infer namespace death from process invisibility (`session/process-view.ts`).
-
- ### Per-session summarizer (PHNX-3939)
-
- Each session can carry a daemon-computed **`goal`** (1–2 lines), progress
- **`checkpoints`** (`{text, at}`, newest last), and a detailed **`summaryChecklist`**
- (`{text, done}`), plus a **`summaryState`** (`pending` | `ready` | `skipped`),
- delivered on the `sessions watch` / `feed watch` stream so the AGI extension
- sidebar can render them. The hard rule is **zero request-path cost**: the model
- call runs **only** in the background `session-summarizer` daemon service
- ([`src/lib/daemon/session-summarizer-service.ts`](src/lib/daemon/session-summarizer-service.ts)
- → [`src/lib/summarizer/pass.ts`](src/lib/summarizer/pass.ts)), which is reader-gated
- and bounded per tick, and the result is cached in the stamp-validated
- `session_summaries` table (`db.ts`, keyed on `(session_id, file_mtime_ms,
- file_size)` exactly like `session_preview_cache`) so it never recomputes on
- unchanged transcript bytes. The display path only **reads** that cache — the merge
- onto live rows rides `applyImmutableMemo`
- ([`src/lib/session/session-cache.ts`](src/lib/session/session-cache.ts)); history
- rows project it in `toPreviousSessionWatchRow`
- ([`src/lib/session/remote/watch.ts`](src/lib/session/remote/watch.ts)); and the
- fleet session mirror carries it so a peer's sessions show it too
- ([`src/lib/session/mirror.ts`](src/lib/session/mirror.ts)). It is **off by default**:
- disabled or unconfigured, the service makes **zero** model calls and every row reads
- `summaryState: "skipped"`. Configure it through `summarizer.enabled` /
- `summarizer.baseUrl` / `summarizer.model` (see the Configuration surface below).
- The one model call is a pure boundary over the Anthropic-wire client
- ([`src/lib/summarizer/summarize.ts`](src/lib/summarizer/summarize.ts)) — prompt input
- is the cleaned first user turn (never the tool firehose), progress input is the
- state engine's todos/plan/phase plus, when the timeline pass has folded any, the
- last few narration headlines (§Session request + timeline above) — forcing strict
- JSON and returning `undefined` (→ `skipped`) on any error.
-
- **Owner notifications have one path: the account (PHNX-4267).** Every owner-bound
- event (`agents send --to owner`, a `--blocked` or important `feed post`, an urgent
- feed block, a failed routine) is one `POST /me/notifications` to rush/api through
- [`src/lib/owner-notify.ts`](src/lib/owner-notify.ts) `postOwnerNotification`.
- rush/api owns the preferences (edited in the console Settings page), quiet hours,
- dedup on `(event, dedupKey)` (`postOwnerNotification` prefixes every producer key with `owner:` so it can never collide with a cloud-run key), and delivery: email, Slack and iMessage all go out
- server-side, iMessage from Rush's SendBlue number. Event mapping: a block is
- `needs_you` (the only event that bypasses quiet hours), a failed routine is
- `failed`, everything else is `message`; a green routine stays silent. The bearer is
- this box's Phoenix session, else the `notify`-scoped device token in this worker's
- reserved store `__notify-<device>__`. Neither is an `OwnerNotSignedInError`, never
- a local fallback; `agents doctor` reports it as `owner-not-signed-in` (critical).
- There is no `humans.yaml`, no `notify.owner`, and no SSH forward to a Mac peer any
- more; the one-shot `migrateHumansToAccount` (`installations/migrate.ts`) uploads an
- existing `humans.yaml` to `PUT /me/preferences` on the first signed-in run, moves
- it to trash and commits the removal, and leaves it in place (retrying next run)
- while the box has no session.
-
- - **Worker device tokens.** A worker holds no Phoenix session. The elected
- signed-in headed box (`syncOwnerNotifyTokens` in
- [`src/lib/owner-notify-tokens.ts`](src/lib/owner-notify-tokens.ts), run on the
- `auth-sync` tick) mints one token per `role=worker` peer
- (`POST /api/v1/auth/tokens {device, scopes:["notify"]}`) into that worker's own
- reserved store `__notify-<worker>__`, then pushes that one bundle to that one
- worker. One bundle per worker, because the push is whole-bundle and a shared
- store would hand every worker every other worker's token. It is never pushed to
- a headed peer. Minting replaces a device's previous token server-side, so only
- the first signed-in headed box by name mints (each box publishes
- `ownerNotify.{signedIn, deviceToken}` in its daemon-state envelope; a headed
- peer's report older than `OWNER_NOTIFY_PEER_FRESH_MS` does not count, so a dead
- box is not elected). `deviceToken` is the worker's own verdict: a token is
- present and rush/api has not answered 401 to it, and the next authenticated call
- clears a 401 mark. A worker reporting `false` gets the stored token re-pushed
- only when the minter holds the live one and its last push failed; a `false`
- reported after a successful push gets a revoke and fresh mint, as does a token the
- minter does not hold, so a rejected token, a lost memo, or a replaced minter never
- strands it. Each signed-in headed box revokes only the tokens it minted, for a
- device that is no longer a worker peer, and skips that pass when its device
- registry is empty or any role is unreadable. `agents auth logout` revokes the
- tokens that box minted.
- - **rush/api sends every owner channel, iMessage included.** `POST /me/notifications`
- answers with every channel in `queued` (`delivered` is always empty), and
- rush/api's worker sends them, iMessage from Rush's own SendBlue number to the
- number the owner confirmed in Settings. No daemon service on this side claims
- or sends anything; `describeOwnerResult` reports the result as "Rush is sending
- ...".
- - **Explicit channels are a different feature.** `agents send --channel slack --to
- '#eng'` and feed `channel:` sinks still deliver through the local providers
- (`notify.transports` still remaps a channel name). They no longer fall back to an
- SSH forward when this box cannot deliver.
-
- Feed channel sinks may override their outbound body with a `message:` template
- using the same placeholders as command sinks (`{message}`, `{ticket}`, `{ticket_url}`, `{project}`,
- and the rest in `feed-broadcast.ts`). Placeholder resolution is fail-closed: if
- the post lacks a referenced value, that sink is skipped. This is the semantic
- routing primitive for destinations such as an engineering Slack channel: a
- template that includes `{ticket}` receives ticket-backed posts without leaking
- unrelated owner alerts into the team channel.
- The shared `{message}` is built to be tappable **only where a destination can
- render a link** (PHNX-3698, `composeBroadcastMessage` in `feed-broadcast.ts`).
- `{message}` is one logical post; the *format* is decided **per destination**
- (`sinkMessageFormat` in `sink-format.ts`, keyed on the destination's **resolved
- provider** — the same `notify.transports` remap delivery uses, so an aliased Slack
- sink still turns blue), and the body is re-rendered in that format. This is a
- **per-destination** decision, not a per-sink one: a Slack `channel:` sink gets the
- labeled-link variant and a plain one gets the plain sentence from the **one** post,
- **never a trailing URL line**. The owner sink always sends the plain body with the
- title, url, session and ticket as separate fields; the account renders it per
- channel:
- - **Slack (any destination that resolves to the `slack` provider) → mrkdwn labeled links**
- (`<url|label>`, blue tappable text). The `Sent from claude/6fc1db18 on zion` crumb
- becomes `<https://prix.dev/console/sessions/<full-id>|claude/6fc1db18>` (the 8-char
- crumb is upgraded to the full indexed id via `resolveFullSessionId`, `session/db.ts`,
- since a truncated id would 404), and every `TEAM-N` key the title or body *names*
- — not just the session's own `ticketId` — becomes
- `<https://linear.app/<ws>/issue/<KEY>|<KEY>>` in place
- (`linearIssueKeys`/`LINEAR_KEY_DENYLIST` in `session/linear.ts`, the same
- canonical detector `detectTicket` uses).
- - **iMessage / `command:` / desktop / the owner sink / every other channel →
- plain.** They cannot render a labeled link and a dumped naked URL reads as noise,
- so the message stays the human sentence with **no URLs** (keys and crumb as text).
-
- **`agents send --to owner` routes through this same composer**
- (`ownerMessageNotification`, `owner-message.ts`, over `ownerNotificationFromContext`
- in `feed-broadcast.ts`), so an owner ping carries the same plain body, footer and
- fields as an important feed post. A non-owner `agents send` (explicit
- `--channel`/`--to`) is delivered verbatim.
-
- **`gh` is overloaded so the fleet's trained `gh pr checks` escapes the shared
- GraphQL rate limit (PHNX-3501).** The whole fleet shares one GitHub token, and
- `gh pr checks/view/list` are all GraphQL-backed, so the fleet drains GitHub's
- 5000-**point**/hr GraphQL budget while REST core (5000 req/hr) sits idle — then
- every agent's `gh pr checks --watch` dies with `GraphQL: API rate limit already
- exceeded`. A generated PATH shim (the `browser`-shim pattern,
- `ensureGhOverloadShim` in [`src/lib/installations/shims.ts`](src/lib/installations/shims.ts),
- installed on `agents sync`) intercepts **only** the agent's trained `gh pr checks`
- and routes it to the hidden `agents __gh` verb
- ([`src/lib/github/gh-overload.ts`](src/lib/github/gh-overload.ts)), which answers
- over REST ([`src/lib/github/rest.ts`](src/lib/github/rest.ts): `prHead` +
- `rollupForSha` = `commits/{sha}/check-runs` ∪ `/status`, anchored to the PR's live
- head SHA); every other verb execs the real gh byte-for-byte. `--watch` owns the
- REST poll loop (head-SHA-anchored, so a superseded run's red is never reported —
- closes PHNX-3042); a one-shot runs real gh first and falls back to REST only on the
- exact rate-limit stderr. **Self-healing:** the shim execs real gh whenever
- agents-cli is gone (`[ ! -x "$AGENTS_BIN" ]`) or the `AGENTS_GH_SHIM` sentinel is
- already set (recursion), so a leftover/orphaned shim can never break `gh`, and
- uninstalling restores plain `gh` immediately. It shadows via PATH precedence and
- never touches the real binary. Tier-1 wraps only `pr checks` (clean REST mapping,
- highest failure volume); `pr view`/`pr list` and a proactive `linear-rate-limit.ts`-style
- budget are fast-follow. POSIX-only in v1. `reviewDecision` is deliberately never
- REST-derived (it is a GraphQL-computed branch-protection/CODEOWNERS decision;
- approximating it could let the merge loop bypass review).
-
- The `agents projects prs` write verbs follow the same budget rule
- ([`src/lib/github/project-prs.ts`](src/lib/github/project-prs.ts), documented in
- [`docs/projects.md`](docs/projects.md)): `merge`, `review --approve` and `comment`
- are REST, and `ready` and `automerge` are the exceptions, one GraphQL mutation each
- (`markPullRequestReadyForReview`, `enable`/`disablePullRequestAutoMerge`), because
- GitHub exposes no REST way to leave draft or set auto-merge. `merge`, `review` and
- `automerge` pin to the head SHA the caller saw (`review` reads the live head,
- refuses a moved one, and sends that full SHA as `commit_id`; `automerge` passes it
- as `expectedHeadOid`). `review --approve` on the viewer's own PR answers without a
- call, since GitHub never allows it.
-
- **`prs merge --admin` is for a person's confirm click, never for an agent.**
- Without it, `merge` reads the live `mergeable_state` and refuses anything but a
- mergeable state (fail closed: `blocked`, `behind`, and a not-yet-computed state
- all stop before the PUT). With it, the PUT runs and a repository admin whose protection does
- not enforce on admins merges past pending or red required checks. AGI Menu passes
- it only from its "Confirm admin merge" button. Every agent shares the owner's
- GitHub identity, so the `gh-merge-guard` rule in the system layer denies agents
- `prs merge --admin` and the raw REST/GraphQL merge calls. Agents land PRs with a
- plain `gh pr merge` after CI and a review verdict, or with `prs automerge`.
-
- ## Authentication — Phoenix ID
-
- `agents auth login` signs in with **Phoenix ID** (`id.byphoenix.com`), the one family
- identity shared with the Rush CLI and prix.dev. The first Phoenix-authenticated
- request rush/api sees provisions the matching Rush user row (owner decision 2026-10-07,
- PHNX-4267), which is what lets a CLI sign-in reach the owner's notification
- preferences. It runs the RFC 8628 **device flow**: `startDeviceAuthorization()` →
- `POST /api/v1/auth/device/authorization`, print the short `user_code` + verification URL
- (works on any browser/phone, including SSH/headless hosts), then `pollDeviceToken()` polls
- `POST /api/v1/auth/device/token` until approval (`src/lib/identity/index.ts`).
-
- On approval the CLI **stores the opaque Phoenix bearer verbatim** — there is no exchange
- step. `writeSession({ access_token, email, userId, name?, avatarUrl? })` writes
- `phoenix-session.json` in the runtime state dir (`src/lib/identity/client.ts`) as a
- temp file renamed over the old one, then `chmod 0600`, so a crash never leaves a
- truncated bearer and a pre-existing looser file is tightened.
- `refreshSessionProfile` (run by `whoami`) merges `/auth/me`'s name and picture into
- the file only if it still holds the same token and user after the fetch. Every backend call reuses
- it as `Authorization: Bearer <access_token>` against Phoenix ID; `agents auth whoami`
- resolves it at `GET /api/v1/auth/me`; `agents auth logout` revokes the worker owner-notify tokens this box minted, then deletes the file (local-only —
- signs out nothing else).
-
- - `PHOENIX_ID_BASE` (default `https://id.byphoenix.com`) points the CLI at a different
- account service for local/private backends.
- - The CLI never reads another product's credentials (e.g. `~/.rush/user.yaml`); each surface
- holds its own copy of the same Phoenix bearer. The surfaces that use it are the managed
- `agents sessions export/backup` target, which verifies the bearer at
- `${PHOENIX_ID_BASE}/api/v1/auth/me`, and owner notifications, which send it to rush/api
- (`RUSH_PROXY_BASE`, default `https://api.prix.dev`, `src/lib/rush-api.ts`). A worker never
- receives this bearer; it gets a `notify`-scoped device token instead (see owner
- notifications above).
-
- ## Core design choices (read this first)
-
- Break these and downstream code drifts silently.
-
- ### 1. Three DotAgents repos, resolution is project > user > system
-
- Resources AND `agents.yaml` resolve in that order. Same-name overrides, everything
- else unions.
-
- | Path | Role | Edited by |
- |---|---|---|
- | `<repo>/.agents/` | **Project repo** — project-pinned commands / skills / hooks / rules. | Project maintainers |
- | `~/.agents/` | **User repo** — user resources + ALL operational state (versions, shims, sessions, `agents.yaml`, browser). | You / CLI |
- | `~/.agents/.system/` | **System repo** — npm-shipped defaults ONLY. | Maintainers (`gh:phnx-labs/.agents-system`, GitHub rename target `phnx-labs/.agents`) |
-
- The system repo was renamed `phnx-labs/.agents-system` → `phnx-labs/.agents` on
- GitHub (PHNX-3394): the `-system` suffix was redundant once the layering itself
- is the role. `DEFAULT_SYSTEM_REPO` still clones the pre-rename slug — GitHub's
- own redirect makes that resolve fine, so there is no forced cutover — and both
- names are recognized as the system origin everywhere a remote is compared, via
- `isSystemRepoRemote` in [`src/lib/git.ts`](src/lib/git.ts).
-
- **The system repo ships hooks that run as shell, so its auto-pull is
- origin-verified (PHNX-2957).** Both fast-forward paths — `agents use`
- (foreground) and the opt-in `AGENTS_AUTO_PULL=1` background worker — route
- through `tryAutoPullSystemRepo`, which pulls **only** when `origin` passes
- `isExpectedSystemRepoRemote`: the canonical `isSystemRepoRemote`, or the exact
- `AGENTS_SYSTEM_REPO` the operator pointed at. A repointed/unexpected origin is
- **refused** (never fast-forwarded), since a pull from an attacker's fork would be
- remote code execution on the next command that loads a system hook. Never widen a
- system-repo pull to bypass this guard; a legitimate alternate upstream is
- expressed through `AGENTS_SYSTEM_REPO`, not an unverified pull.
-
- Extra repos register via `agents repo add <source>` → clone into `~/.agents-<alias>/`
- and participate after the user repo. The companion extras repo
- `phnx-labs/.agents-extras` keeps its name but is now **private and opt-in**: it is
- not cloned by default and is added explicitly with `agents repo add`.
-
- ### 2. `AGENTS.md` is the canonical memory file
-
- `CLAUDE.md` and legacy `GEMINI.md` are symlinks. **Edit `AGENTS.md` only** —
- editing a symlink target directly gets stomped on the next sync. The sync writes
- the right file name per supported agent (`OPENCODE.md`, `.cursorrules`, etc.).
-
- **"Memory" names three distinct, unrelated mechanisms — don't conflate them.**
- Besides the rules file above, `agents memory` ([`src/lib/memory.ts`](src/lib/memory.ts))
- is a layered resource — `~/.agents/memory/*.md` facts fanned out into each
- capable version home (`.claude/memory/`, `.codex/memories/`, …) by
- `syncMemoryToVersionHome`. Separately, Claude Code's own **native** per-project
- auto-memory — `.claude/projects/<project-key>/memory/*.md`, freeform notes
- Claude writes into itself during a session — is neither a `ResourceKind` nor
- populated by agents-cli at all. Because `getVersionHomePath` gives every
- installed version its own isolated HOME, that native dir used to be a THIRD,
- unmanaged, per-version copy: a note written under one Claude version was
- invisible under another (PHNX-2817). `syncClaudeProjectMemoryDir`
- (`src/lib/memory.ts`, wired into `syncResourcesToVersion`,
- [`src/lib/installations/versions.ts`](src/lib/installations/versions.ts))
- fixes this the same way project-level rules are version-independent: every
- version home's copy is a symlink into one canonical dir
- (`~/.agents/.cache/state/claude-project-memory/<project-key>/`, keyed by the
- same cwd-to-dash-name encoding Claude Code itself uses,
- `claudeProjectDirName` in [`src/lib/project-key.ts`](src/lib/project-key.ts)) —
- no merge/copy step, no drift, a pre-existing real directory's content is
- migrated into the canonical dir once rather than discarded.
-
- ### 3. Capability table gates per-agent writes
-
- `supports(agent, cap, version?)` in [`src/lib/capabilities.ts`](src/lib/capabilities.ts)
- is the only place that decides whether an agent+version can receive a resource.
- Out-of-range versions are **skipped silently** — do not add per-call agent checks
- elsewhere; route through `supports()`.
-
- ### 4. No fallback logic for legacy layouts
-
- [`src/lib/installations/migrate.ts`](src/lib/installations/migrate.ts) folds legacy paths ONCE at install time.
- The bootstrap gate that invokes `runMigration()` then writes the `.migrated` sentinel
- (`MIGRATED_SENTINEL_FILE`, [`src/lib/state.ts`](src/lib/state.ts)), keyed to the
- migration SCHEMA version, so the scan short-circuits next run — `runMigration()` itself
- only relocates a legacy sentinel via `moveFileOnce`, never writes one. Downstream code
- assumes the post-fold layout. "Just-in-case" branches re-introduce drift bugs; the
- migrator is the single source of truth for legacy handling.
-
- ### 5. Hooks live in a single layered `hooks.yaml`
-
- System + user `hooks.yaml` merged, user wins on same name. Per-entry `matches:`
- predicates (`prompt_contains`, `prompt_matches`, `tool_name`, `tool_args_match`,
- `cwd_includes`, `project_has`, `git_dirty`, `permission_mode`,
- `permission_mode_not`) AND together at fire time — the two mode predicates are
- fail-open on absence, since only some harnesses report the live mode in hook
- input. Use `permission_mode_not` to gate a hook **off** in one mode:
- `permission_mode` is an allowlist, so expressing "everywhere except plan" through
- it means enumerating every other mode, and that enumeration silently stops
- matching when a harness adds or renames one — which for a guard means it quietly
- stops guarding. Per-entry
- `enabled: false` disables a system-shipped hook from the user side. The `agents:`
- field in `ManifestHook` is `@deprecated` — the capability table decides which
- agents register a hook.
-
- ### 6. Multi-agent work → `agents teams`
-
- DAG-style, boundary contracts, `--watch` supervisor, `--worktree` isolation, optional
- `--cloud` dispatch. The old `mcp__Swarm__*` surface was folded into teams
- (`migrateLegacySwarmToTeams()` in `src/lib/installations/migrate.ts`). Don't reach for Swarm — gone.
-
- **A teammate opens PRs but MUST NOT merge its OWN PR without a posted non-author
- verdict (PHNX-3236).** A write-capable teammate has `gh pr merge` and authenticates
- as the repo owner, so nothing about being a teammate stops it from merging its own
- PR straight past the required non-author review — which is exactly what happened in
- the RUSH-2988 wave-1 dispatch (PRs #1817, #1820 self-merged with `reviews: []`). The
- boundary is enforced in two layers, and neither is per-brief prompt wording (the
- failure mode: only one teammate in that batch was *told* not to merge, and its
- siblings weren't):
-
- - **Hard enforcement — `merge-guard.sh`.** The PreToolUse Bash guard the teammate
- inherits from the shared version home blocks `gh pr merge` on a PR that has no
- non-author verdict on it. Its verdict check (`pr-verdict.py`) **excludes any
- review/comment authored by the PR's own author** (landed in the same ticket,
- `phnx-labs/.agents` #395), so a teammate posting `VERDICT: APPROVE` on its own PR
- — every fleet agent shares one GitHub identity — no longer clears the gate.
- - **Dispatch default — `TEAMMATE_PR_POLICY`.** One helper,
- `withTeammatePrPolicy(prompt, mode)`
- ([`src/lib/teams/agents.ts`](src/lib/teams/agents.ts)), appends the boundary to the
- prompt of every **write-capable** (non-plan) teammate, fresh or resumed, applied at
- **all three** dispatch surfaces so they can't drift: the local launch and the
- `--device` remote launch (both through `buildRunArgv`), and the `--cloud` launch
- (`cloudDispatchOptions` in [`src/commands/teams.ts`](src/commands/teams.ts)). It is
- the harness-independent layer — it reaches every entry in `TEAM_AGENT_TYPES`, not
- just the hook-capable ones — rather than depending on each dispatch remembering to
- say it. Pinned by
- [`agents.pr-policy.test.ts`](src/lib/teams/agents.pr-policy.test.ts).
-
- **Coverage, and the residual gap (stated per §Surface parity).** The two layers do
- not overlap everywhere, so name where each reaches:
-
- - **Hook-capable local / `--device` remote teammates** get **both** layers — the
- hard `merge-guard.sh` block plus the prompt. This is the common case.
- - **Cloud teammates (`--cloud`)** get **only** the prompt: a cloud teammate runs in
- the provider's sandbox, not the shared local version home, so it never inherits
- `merge-guard.sh`. Wiring the prompt into `cloudDispatchOptions` is what keeps this
- surface covered at all.
- - **Hook-incapable harnesses** (e.g. Warp/`oz` — `hooks: OFF` and no allowlist in the
- capability table above, yet present in `TEAM_AGENT_TYPES`) get **only** the prompt,
- local or remote, because there is no hook surface for `merge-guard.sh` to occupy
- and no allowlist to express a `Bash(gh pr merge:*)` deny.
-
- So for the cloud and hook-incapable cases the prompt is a **soft** control an agent
- can ignore under pressure — the exact reliability limit the ticket names. That
- residual is accepted deliberately: there is no code-level merge interception point on
- those surfaces short of provider-side branch protection (cloud) or a per-harness
- enforcement mechanism those harnesses don't expose. **Enable server-side GitHub branch
- protection requiring a review** to close it uniformly regardless of harness or
- dispatch surface — that is the only guard that does not depend on the client.
-
- agents-cli itself never merges a teammate's PR (`pr-watch` only opens follow-up
- fixers or escalates to a human — there is no merge action); the teammate's own
- `gh pr merge` is the only merge path, which is why both layers target that call.
-
- ### 7. Every agent conversation is a session; execution ledgers link to it
-
- **An agent conversation from a session-capable harness MUST remain a session,
- whether launched interactively, headlessly, as a teammate, or by a routine.** A
- caller MUST preserve the harness transcript and make it discoverable by `agents
- sessions`; it MUST NOT replace or hide that conversation record with its own
- execution metadata. This applies to `SESSION_AGENTS`, not a harness such as Warp
- that exposes no local transcript. The indexed session row carries the relationship
- when the harness and launch path provide one:
-
- | Launch surface | Session relationship | Separate execution state |
- |---|---|---|
- | `agents run`, including headless and `--device` | Ordinary indexed session for a session-capable harness; when its SessionStart hook records an id, the launch id joins a remotely coined session back to the dispatch. A hookless run remains unmapped rather than receiving a fabricated id. | Dispatch/audit events |
- | `agents teams` teammate | Its **own** session id plus `teamOrigin`; `parentSessionId` links the orchestrator when the teammate was spawned inside an identified agent session | Team registry + teammate `meta.json` own DAG/task/process state |
- | Agent/workflow routine | The transcript is archived under the run; supported archive readers index it with `origin: routine`, `routineName`, and `routineRunId` | `.history/runs/<routine>/<run>/meta.json` owns the attempt outcome |
- | Command-only, `missed`, `blocked`, or `skipped` routine | No session is synthesized because no agent conversation occurred | The routine run record is the complete canonical record |
-
- Source of truth: `buildExecEnv` / `emitResolvedSessionId` in
- [`src/lib/exec.ts`](src/lib/exec.ts), `listTeamsActive` in
- [`src/lib/session/active.ts`](src/lib/session/active.ts),
- `archiveRoutineTranscripts` in [`src/lib/daemon/runner.ts`](src/lib/daemon/runner.ts), and
- `decorateRoutineSession` in [`src/lib/session/discover.ts`](src/lib/session/discover.ts).
- Enforced by [`sessions-cli/src/lib/session/team-filter.test.ts`](https://github.com/phnx-labs/sessions-cli/blob/main/src/lib/session/team-filter.test.ts)
- (`--teams includes team sessions with teamOrigin populated`),
- [`src/lib/daemon/runner.test.ts`](src/lib/daemon/runner.test.ts) (routine transcript archival), and
- [`src/commands/sessions.test.ts`](src/commands/sessions.test.ts) +
- [`src/commands/sessions.cli-live.test.ts`](src/commands/sessions.cli-live.test.ts)
- (routine run history plus linked sessions, with no fake session for command-only runs).
-
- Team-origin sessions are durable session rows but are excluded from the ordinary
- historical listing by default to keep an orchestrator's fan-out from flooding it;
- `agents sessions --teams` includes them, while live teammates appear in
- `agents sessions --active` with `context: teams`. That is a presentation filter,
- never permission to omit the transcript from the session index.
-
- **Current routine-archive gap:** `readRoutineArchiveMeta` indexes Claude, Codex,
- and Cursor routine archives. Kimi's `state.json` + `wire.jsonl` are archived by
- `archiveRoutineTranscripts` but do not yet have a routine-archive reader, so they
- are not session rows today. Treat that as named drift from the invariant, never as
- precedent for a second run-only conversation model; adding another routine harness
- requires both archival and a parser in `readRoutineArchiveMeta` plus an indexing
- test.
-
- ### 8. Routine definitions and device activation are separate
-
- Routine YAML under project, user, or system `routines/` describes what runs and
- when. Whether it runs on one host is membership in that host's top-level
- `devices/<hostname>/agents.yaml` `routines:` list. Pause/resume and setup MUST
- write only the target host's device file; they MUST NOT rewrite a definition with
- `enabled`, `devices`, or runtime metadata. Run history belongs in
- `.history/runs/<routine>/<run>/`, and that run history — not the session index — is
- the canonical record of an attempt; sessions/logs/reports are optional children of a
- run (so a `missed`/`blocked`/`skipped` attempt is visible with no session).
-
- **`projects` (plural) is grouping metadata only; the singular `project` anchor is a
- separate concept.** The plural list organises a routine in `list`/the menu bar and
- MUST NOT affect execution; the (planned) singular `project`/`--project-anchor` plus a
- routine-level `cwd` is the execution anchor, resolved on the *execution target*. A
- routine's `repo` is an external Git/cloud/webhook identity, never a local cwd. The
- reliability contract — context resolution, readiness/pause-on-blocker, single-fire
- `(routine, scheduledFor)` claim distinct from the active-run claim, and the
- `blocked`/`skipped` run statuses — is normative in
- [`docs/specifications.md` §Routine execution & readiness](docs/specifications.md#routine-execution--readiness)
- (RT-1..RT-11) and §Scheduling & execution singularity (SING-13, SING-15, SING-16); much of it
- is `[Intended]` (RUSH-2290), and each requirement marks landed vs intended.
-
- Routine execution context is separate from grouping and repository identity.
- Plural `projects` is metadata-only; singular `project` selects one `ProjectDef`
- execution base; `cwd` is resolved on the eventual execution device. A rootless
- Linear project may still use a relative `cwd`, which anchors at that target user's
- home. `repo` remains GitHub/cloud/webhook identity and MUST NOT be used to infer a
- local checkout. Readiness failures save valid definitions paused through device
- activation; they never write mutable activation into routine YAML.
-
- ### 9. Self-updating agents are ONE binary, not fictional version-homes
-
- Some harnesses (droid, grok, antigravity, cursor, hermes, muse, goose) install
- via an official `curl … | sh` / `brew install` script that carries no version token —
- the installer only ever fetches the *current* release and the binary self-updates in
- place. `isSelfUpdatingAgent()` ([`src/lib/agents.ts`](src/lib/agents.ts)) is the single
- predicate for "no pinnable semver"; route every such decision through it, never a
- scattered `=== 'droid'`. Its narrower cousin `isGlobalBinaryAgent()`
- ([`src/lib/installations/store.ts`](src/lib/installations/store.ts)) — computed by probing whether
- `getBinaryPath` ignores the version arg — is true only when the agent resolves to ONE
- global binary (droid). For those, `listInstalledVersions` collapses the phantom
- per-version dirs to a single canonical entry, `reconcileStaleLatestForAgent` folds the
- stale dirs into the survivor, `agents view` shows the live `--version`, and
- `agents add droid@1.2.3` gracefully installs the current release instead of erroring.
- grok is self-updating but stores a real per-version binary under each version-home, so
- it is NOT a global-binary agent and is left uncollapsed. (RUSH-1321)
-
- For a concrete self-updating spec, the requested token is the stable installation
- label/account slot even when the vendor installer can only fetch today's release.
- `installation.json.releaseVersion` records the probed vendor release separately.
- This identity/release split is load-bearing: two Cursor or Grok account slots
- may share the same managed release while retaining different native credentials,
- and removing an isolated installation must never target the managed install merely
- because their releases match. `@latest` keeps the probed release as its convenient label; concrete labels stay
- exactly addressable.
-
- ### 10. Diagnostic command taxonomy — `doctor` is the umbrella (RUSH-2027)
-
- Three diagnostics, distinct scopes. Don't blur them — each answers a different
- question, and a new health check goes in the one whose scope it matches.
-
- | Command | Scope | Answers |
- |---|---|---|
- | `agents fleet status` | Coarse **device** health across the fleet | Are devices online, do they have the agent CLIs installed, are they signed in, what is the agents-cli **version skew**, how many agents are running on each box. NOT fine-grained resource divergence. Publish-own/read-union: each daemon publishes only its own row (no N² ssh probe, RUSH-2061); the reader unions peers on demand (`--local --json` is the per-host publish endpoint). |
- | `agents inspect <agent>[@version]` | Deep **single-harness** diagnosis | Per-resource diff between one version home and its resolved sources; manifest staleness; orphans. One harness, one machine. |
- | `agents doctor` | **Umbrella** — overall fleet + harness health | Local diagnostics (CLI presence, per-version sign-in, per-version sync, orphans) **and** cross-device divergence, rendered as the prioritized critical-at-top + per-computer hybrid below. The single command a user runs to discover problems before runtime. |
-
- **The per-version resource diff is content-aware for EVERY kind `syncResourcesToVersion` writes (PHNX-3504).** `diffVersionResources` ([`src/lib/doctor-diff.ts`](src/lib/doctor-diff.ts)) — the engine behind `agents doctor` and `agents inspect` — no longer name-compares mcp/permissions/subagents or skip workflows/memory. `DOCTOR_ALL_KINDS` = commands, skills, hooks, rules, mcp, permissions, subagents, plugins, **workflows**, **memory** (`promptcuts` is dropped — it is a single version-unscoped file, not per-home). A byte change under an unchanged name is `diff`, never a false `ok`: mcp structurally compares the parsed home server def vs the resolved source (no `claude mcp add` shell-out); subagents re-render the source through the `SUBAGENT_TARGETS` transform and byte-compare; workflows compare layout-aware per harness (Claude's copied WORKFLOW.md tree vs the transformed Kimi/Antigravity/OpenClaw/Grok file vs the Goose recipe; antigravity's is the shared HOME-global dir); memory diffs the knowledge facts (`~/.agents/memory/*.md`, bounded by the `.agents-cli-memory.json` manifest — NOT the rules-preset list that overloads `AvailableResources.memory`); permissions compare per-rule in the harness's native vocabulary for the **representable** harnesses (claude/opencode/cursor/droid/openclaw) and stay presence-only with `detail: 'format cannot verify content'` for the lossy TOML/flag harnesses (codex/grok/kimi/antigravity/hermes/copilot) rather than faking `ok`. The shared byte compare (`filesContentMatch`/`dirsContentMatch`, [`src/lib/resource-content-diff.ts`](src/lib/resource-content-diff.ts)) is reused by skills and the plugin-drift describer. A completeness test in `doctor-diff.test.ts` binds `DOCTOR_ALL_KINDS` to the writer set so a future synced kind cannot silently become a doctor blind spot, and `agents sync` (`src/lib/heal.ts`) reaches the newly-covered kinds.
-
- **`agents doctor <agent>[@qualifier]` accepts symbolic qualifiers** — `@latest`, `@oldest`, `@default`/`@pinned`, `@all`, or an exact version — resolved through the shared agent-spec engine (`lib/agent-spec/index.ts`, `resolveAgentTargets`). Bare `agents doctor <agent>` (no qualifier) sweeps every installed version without setting `versionExplicit`. Any explicit qualifier sets `versionExplicit: true`, scoping the report to the resolved version set (including isolated copies for `@all`). `AgentSpecError` from the engine is surfaced as a user-facing error. Routing flags (`--device`/`--remote-cwd`) are stripped via `stripRoutingFlags` before target parsing, so `agents doctor claude@latest --device remotebox` resolves correctly on the remote. (issue #2058, `src/commands/doctor.ts:parseTargetArg`)
-
- **`agents doctor` is a prioritized, comprehensive-by-default hybrid (RUSH-2069).**
- There is no `--verbose`. A top `✗ CRITICAL — needs you now (N)` section lists every
- critical across the whole fleet worst-first; a `─── by computer ───` section then
- gives each device its warnings plus a compact accounts/versions line (every
- installed version + its account, provable ✓ / ✗). Single-machine `agents doctor`
- collapses to the CRITICAL section plus one `▸ <machine>` block. Severity:
- **critical** is `logged-out` (provable), `missing-hook`, `missing-plugin`,
- `unwired-hook`, `hook-runtime-broken`, `cli-missing`, `ssh-key-enrollment` and `owner-not-signed-in` (no Phoenix
- session or worker device token, so this box cannot reach the owner; remediation `agents auth login`, PHNX-4267); **warning**
- is `logout-unprovable`,
- `missing-resource`, `content-drift`, `never-synced`, `stale`, `repo-behind`,
- `repo-drift`, `version-skew`, `fleet-resource-gap`, `hook-runtime-visibility-unavailable`, `orphan`, `duplicate-hook`,
- `duplicate-hook-drift`, `host-cli-missing`, `host-cli-invalid`,
- `rc-secret-export`, `env-secret-export`, `auth-bundle-wrong-backend`, `exec-policy`, `stale-cli` and `binary-shadow`,
- plus `leaked-daemon` (a `__daemon-run` no unit main PID or daemon.pid owns, W4/PHNX-3736)
- and `menubar` (an AGI Menu duplicate, stale or replaced helper, or ad-hoc signature; macOS only, PHNX-4227). (RUSH-2162 moved
- `never-synced` and `duplicate-hook-drift` to warning — both are stale-sync states
- one `agents sync` resolves.)
-
- `FINDING_SEVERITY` in
- [`src/lib/devices/doctor-findings.ts`](src/lib/devices/doctor-findings.ts) is the
- single source of truth: the builders read their severity from it, and a test
- asserts this list and the module docblock assign every kind to the **same bucket**
- it does. Change a severity there and the test names the docs to move with it. The findings model,
- builders, `remediationFor`, and the pure `renderFindings` live in
- [`src/lib/devices/doctor-findings.ts`](src/lib/devices/doctor-findings.ts).
-
- **One root cause is one line.** A readout the user cannot scan is as useless as no
- readout, so the builders de-duplicate before rendering — on a real machine this
- takes ~57 rows down to ~16, and the rules are unit-pinned in
- `doctor-findings.test.ts`:
-
- - **Per version, per kind, one row.** `emitGroup` names a lone resource in full
- (`hook 'git-guard' missing`) and otherwise emits a count plus two examples
- (`32 hooks missing (incl. 'a', 'b')`). Never one row per resource.
- - **Per agent, one row across versions.** `collapseAcrossVersions` folds findings
- with the same `(device, agent, kind, severity, account, message)` into a single
- row carrying `versions`, rendered `claude (5 versions)`, and widens the
- remediation to the agent-wide sweep. Three exclusions, each because the widened
- remediation would be wrong: **isolated copies** (`runFix` skips them, so the
- sweep would leave one broken — the caller passes `isolatedVersions` from
- `isVersionIsolated`); **findings with no agent** (their `version` is a repo
- alias); and **logouts** (`NEVER_COLLAPSED`) — a login is inherently per-version,
- there is no `@all` for it, and dropping the version falls back to the bare
- native hint, which the shim points at the *default* version.
- - **Orphans are one line per machine.** They are cleanup-only and
- `agents prune cleanup --all` fixes every version at once — **`--all` is load
- bearing**: without it cleanup sweeps only each agent's default version
- (`commands/prune.ts:351`).
- - **Duplicate version-home hooks are one line per (agent, severity).**
- `agents sync <agent>@all --yes` reconciles every copy at once, and a
- machine with five installed claudes otherwise emits two dozen identical rows.
- - **No vaguer restatement.** A version that just listed its drifted/missing
- resources gets no `sources changed since last sync` row on top, and a
- never-synced version reports one warning (`agents sync <agent>@<version>
- --yes`) instead of one row per absent resource.
-
- **Every check the old overview printed is a finding now.** `renderOverviewText`
- was the ONLY text renderer for several independent checks, so deleting it dropped
- each of them from the command — the top defect this redesign had to answer for, and
- it recurred three times during review. They all enter `buildLocalFindings` as
- plain **inputs** (never probes, so the module stays pure and every branch is
- testable without a shell, PowerShell, or an installed CLI):
-
- | Check | Input | Finding kind |
- |---|---|---|
- | Credential-shaped shell-rc exports (RUSH-1968) | `rcSecrets` | `rc-secret-export` |
- | The file-store master key live in the process env (RUSH-1968) | `masterPassphraseInEnv` | `env-secret-export` |
- | Windows exec policy blocking `agents.ps1` | `execPolicy` | `exec-policy` |
- | Windows OpenSSH key path/content/ACL invalid | `windowsSshEnrollment` | `ssh-key-enrollment` |
- | Hooks duplicated across version homes | `duplicateHooks` | `duplicate-hook{,-drift}` |
- | Declared host CLIs not on PATH | `hostClis.statuses` | `host-cli-missing` |
- | Host-CLI manifests the loader rejected | `hostClis.errors` | `host-cli-invalid` |
-
- **Before deleting any renderer here, enumerate what it called.**
-
- **A remediation must fix EVERY version in its row, and must be a command that
- exists.** Three separate rounds of review here found remediations naming a command
- form that does not do what the row claims — `agents sync <agent>` (default version
- only), `agents repo pull` (skips the system repo), `agents prune cleanup` (default
- versions only), `agents clis install <a> <b>` (takes one name), and
- `agents run <agent>@<v>, then <cli> login` (the second command resolves through the
- shim to the *default* version, not the one that is logged out). **A named account
- logs back in with `agents accounts login <harness>#<name>` on a headed device**
- (never a token fallback; never a worker-side OAuth). The version-targeted
- `agents run <agent>@<v> -- login` form is only for leftover pre-migration
- installations that still isolate a login per home — `--` forwards verbatim into
- that home. **Open the command definition and check arity, flags, and scope before
- writing a remediation string.** `agents sync <agent>` targets
- only the default/sole installed version (`commands/sync.ts:8`), so a row collapsed
- across versions uses the `@all` selector — `agents sync <agent>@all --yes`. A fleet
- resource gap is absent from that box's *central repos*, so the central-to-home
- `agents sync` cannot close it — and neither `agents repo pull` nor the sync
- umbrella touches the **system** repo (`commands/repo.ts:1186`,
- `lib/sync-umbrella.ts:104`), which moves with the npm package instead, so that row
- names both paths rather than one command that quietly covers half the cases. A
- `repo-drift` row carries the repo alias (`user` / `system`) rather than hardcoding
- one.
-
- **Sign-in is per ACCOUNT (a slot), and a logged-out claim must be provable.**
- A slot's STATE is derived from the slot itself, never from a version home its
- label happens to match: `loadAccountCatalog` reads the slot's own credential live
- (`getAccountInfo(agent, slot.slotDir)`) and the daemon's `probeLocalFleetAuth`
- enumerates every registered slot on the box (`enumerateSlotInstalls`, rows keyed
- `slot:<accountId>`) alongside `listInstalledVersions`. The slot record's
- `verdict: unconfigured` is `ensureSlot`'s DEFAULT, not a probe result — the daemon
- never publishes `unconfigured` — so it maps to `unverified` when the slot is signed
- in and `missing` only when it is not. Before this, a slot re-materialized as
- `unconfigured` while the device doc was unreadable stayed MISSING forever while
- `agents run` used its login without complaint.
- [`credentialPresence(agent, versionHome)`](src/lib/agents.ts) splits a credential's
- existence into the per-version home and the active/global HOME; a logged-out
- critical is emitted only when BOTH are absent (`provable = !perVersion && !active`).
- A version sharing the global login is signed in, not out; an agent with no
- inspectable identity (`!supportsAccountInspection`) never yields a logout finding,
- not even the hedged warning. **Do not enumerate that set here or in tests** —
- `ACCOUNT_INSPECTION_AGENT_IDS` and `CREDENTIAL_FILE_SEGMENTS`
- ([`src/lib/agents.ts`](src/lib/agents.ts)) are the source of truth and agents move
- between them (antigravity and cursor both did, mid-review, each time turning a
- hardcoded list into a false doc claim or a red test). Derive it:
- `ALL_AGENT_IDS.filter(supportsAccountInspection)`. Login remediation for a
- **named account** is `agents accounts login <harness>#<name>` on a headed
- device. The leftover per-version form (`agents run <agent>@<version>` +
- `loginHint`) remains the repair for pre-migration homes until they fold into
- slots — and ONLY for the per-version-isolated set (claude/codex/grok/kimi/opencode/copilot);
- gemini/antigravity/droid/cursor share their login, so the fix says so instead of
- faking a per-version repair. Per-version sign-in rides the device inventory
- (`FleetInventory.signIn`, populated by `collectLocalFleetSignIn` in
- [`src/lib/devices/fleet-inventory.ts`](src/lib/devices/fleet-inventory.ts)); an
- older remote CLI that omits it degrades to an "older agents-cli — upgrade" warning.
-
- **Cross-device divergence lives in `agents doctor --devices`.** It compares each
- device's self-reported harness inventory against the local baseline and flags a
- resource / agent-version / config-repo present on one box but missing on another
- (e.g. the `swarm` plugin on `zion` but not `yosemite-s0`). The data path:
-
- - Every device's **top-level `agents doctor --json`** emits a `fleet` inventory
- field ([`src/lib/devices/fleet-inventory.ts`](src/lib/devices/fleet-inventory.ts) →
- `collectLocalFleetInventory`): installed resource names per kind, installed
- version ids per agent, and `.agents`/`.system` repo state (`readRepoState` in
- [`src/lib/git.ts`](src/lib/git.ts)).
- - `runDevicesDoctor` ([`src/commands/doctor.ts`](src/commands/doctor.ts)) fans that
- payload out per device and runs the **pure comparator**
- [`compareFleetInventories`](src/lib/devices/fleet-divergence.ts) — SSH-free, so
- it's unit-tested against fixtures with no live fleet — then maps the divergences
- and each box's per-version sign-in into the hybrid via `fleetDivergenceToFindings`
- / `signInToFindings` / `renderFindings`
- ([`src/lib/devices/doctor-findings.ts`](src/lib/devices/doctor-findings.ts)).
- - `agents fleet status` reuses the same comparator inside `buildFleetHealthReport`
- ([`src/lib/devices/health-report.ts`](src/lib/devices/health-report.ts)) to add a
- per-device `divergence` warning to its rollup.
-
- Read-only by default — divergence detection never installs or syncs. `--json`
- carries a stable `fleet` divergence block for the VS Code extension / Agency.
-
- **Per-device harness/account readiness lives in `agents devices harnesses` /
- `agents devices accounts` (RUSH-2003).** A fourth fleet lens, distinct from the
- three diagnostics above: not "is the fleet healthy?" (`fleet status`) or "is a
- token live?" (`fleet ping`), but "what can each box actually *run* right now?" —
- per installed `agent@version`, its account, signed-in, quota, and a single `ready`
- verdict (signed in AND not rate-limited). `harnesses` is the per-install view;
- `accounts` collapses installs that share one account. The collector
- ([`collectLocalHarnessInventory`](src/lib/devices/harness-inventory.ts)) reuses
- `getAccountInfo` (identity), the daemon-warmed usage cache via
- `getUsageInfoByIdentity({ readOnly })` (quota — never blocks on a per-account
- network fetch unless `--refresh`), and `deriveUsageStatusFromSnapshot` (throttle
- state). Claude's managed status-line command also writes the native five-hour
- and seven-day rate limits from normal interactive responses into that same
- identity-keyed cache; it never reads or refreshes OAuth credentials. Two
- invariants ride on that writer (PHNX-3392, spec GWT-E5c): a run that hits its
- weekly limit persists a `rate_limited` `week` window
- ([`claude-statusline.ts`](src/lib/claude-statusline.ts) →
- `mergeClaudeUsageCacheWindows`), so the next `collectRunCandidates` excludes
- the account; and a MISSING snapshot is treated as unknown, never as headroom —
- `capacityWeight(null, …)` draws `UNVERIFIED_WEIGHT` (1), not full capacity, so
- a blind account on a worker box (usage 403s, RUSH-2392) can't outrank a
- verified-healthy one, while an all-*blind* pool (no snapshots at all) still
- draws a pick. An all-*stale* pool does NOT (PHNX-2526): when every account
- carries a snapshot older than its freshness bar (`USAGE_DECISION_MAX_AGE_MS` for
- a local capture, `USAGE_SYNC_TRUST_MS` — the 15-minute sync cadence — for a
- row that arrived via sync from the account's poller) and none is verified,
- `balanced`/`available` refuse to auto-pick on a plausible-but-wrong number —
- `resolveRunVersion` returns `noVerifiedUsage`, an interactive run shows the
- account picker and an unattended one fails loud with `NO_VERIFIED_USAGE`
- (`formatNoVerifiedUsageError`); the stale candidates survive only in
- `rotation.healthy` for bounded post-rejection failover. The distinction is
- `hasStaleUsage` (a present-but-old number) vs blind (no number to be misled by).
- The fan-out mirrors `fleet ping` (probe self in process, SSH each peer's
- `devices harnesses --local --json` worker, same per-device + overall deadlines).
- Everything but the collector is pure and unit-tested
- ([`harness-inventory.test.ts`](src/lib/devices/harness-inventory.test.ts)). Agent
- coverage is `ALL_AGENT_IDS`-driven, so a new harness is included automatically.
-
- **Usage is a per-account fact, fleet-synced from headed boxes (PHNX-3392).** A
- Claude rate limit is metered per ACCOUNT, so the 5h/weekly numbers are identical
- on every box — but only a headed device (`personal`/`desktop`) can READ them: the
- usage endpoint needs the `user:profile` scope only the interactive login carries
- (RUSH-2392), and the status-line writer above only fires on a box that runs Claude
- interactively. A headless `worker` has just the `user:inference` setup-token,
- which the endpoint 403s, so its `claude-usage.json` stays blank and `agents view`
- shows no S:/W: bars. The `usage-sync` daemon service closes the gap over SSH
- (PHNX-4116): each headed daemon publishes one identity-keyed snapshot to its own
- UNTRACKED `~/.agents/devices/<device>/daemon-state.json`, then dials every
- dialable peer in parallel with `agents __usage-ingest --reply`
- (`exchangeFleetStateWithPeers`, [`src/lib/accounting/usage-sync.ts`](src/lib/accounting/usage-sync.ts)),
- sending that envelope on stdin under a 20 s per-peer deadline. The peer stores it
- as `devices/<headed>/daemon-state.json`, merges the rows into its cache
- NEWEST-WINS (`ingestPeerClaudeUsageRows`, `src/lib/accounting/usage.ts`), and
- prints its own envelope back, which the headed box stores as
- `devices/<peer>/daemon-state.json` stamped `receivedAt`. Workers never initiate;
- a timed-out peer is skipped for that tick and never blocks another; two headed
- publishers converge per identity regardless of order. The placement probe
- (`buildReadyProbeCommand(os, { ingestUsage: true })`) runs the same silent
- ingest ahead of `agents view --json`, so the worker a dispatch lands on holds the
- dispatcher's numbers at dispatch time. **Nothing rides the user repo any more:**
- the previous transport committed every tick's envelope, which grew the shared
- store to 1.1 GiB / 18,358 commits (100% of the last 2,000 `chore(devices):
- publish <device> daemon state`) and left one worker's clone 10k commits behind
- with `git fetch timed out` every tick — a box holding a valid setup-token was
- unschedulable because a repo was bloated. `agents repo sync user` is a plain
- fetch-and-fast-forward of human-authored resources again. The reserved-auth
- readiness verdict (`ready`/`missing`/`invalid` metadata only; credentials never
- enter the envelope) rides this **same** envelope, published by the **same** tick
- (PHNX-4051 — see below). **The credential push is per KEY and per ROLE, not bundle-coarse
- (PHNX-3940 T6).** Each portable account resolves to one reserved-store key
- `<ENV>_<accountId>` in `__<harness>__` (a claude row predating T1 falls back to the
- legacy `auth` alias keyed by email); the elected single publisher (`syncReservedStores`,
- [`secrets-policy.ts`](src/lib/secrets-policy.ts)) pushes a reserved store to a
- peer whenever that peer is missing **any** of its keys — so a newly-added account
- propagates within one tick instead of hiding behind a coarse "already has the bundle"
- verdict. **The publisher is a ready HEADED device** (`electPublisher`: `personal`/
- `desktop` first — where tokens are minted — then by name to break ties); a worker is
- elected only when no headed box is ready. Pushes target `role=worker` peers only: a
- headed (`personal`/`desktop`) peer
- receives the account **row** through the normal repo sync but **never a durable key**
- (`isHeadedDeviceRole`, invariant 7). On a worker, `reconcileLocalWorkerSlots` →
- `provisionWorkerSlot` materializes a slot for **every** registered account whose key
- is on the box — a v2 row through its reserved key, a pre-v2 claude row through the
- email-keyed `auth` token (both via `reservedSyncTargets`, so the push plan and the
- materialization can never disagree) — and it runs **first** in the tick so nothing
- a peer does on the exchange can postpone it;
- a native OAuth/session file is never transported (`fleet/auth-sync.ts`
- `isCredentialSafeToPropagate` stays `false`). Each exceptional push is async with a
- hard deadline that kills the direct SSH client and remote connection. The store path
- and newest-wins flow have real-file tests
- ([`fleet-shared-state.test.ts`](src/lib/fleet-shared-state.test.ts),
- [`usage-sync.test.ts`](src/lib/accounting/usage-sync.test.ts)); the exchange is
- proved end to end by spawning the real CLI as the peer, with only the ssh dial
- injected ([`usage-ingest.e2e.test.ts`](src/lib/accounting/usage-ingest.e2e.test.ts)).
- The hidden `__usage-ingest` verb still accepts the legacy v1 bare-rows envelope
- from an older headed peer, and `__usage-export` remains. Its stdin is bounded at
- `REMOTE_STDOUT_MAX_BYTES` (16 MiB, `src/lib/ssh-exec.ts`) — the same ceiling the
- dialer puts on a peer's reply — so an oversized push exits 2 with one
- `UsageIngestInputTooLargeError` line on stderr and writes nothing. A synced row reads as `last_seen`
- (cached), never a live fetch, so a worker's bar is honest about being propagated.
-
- **One exchange per tick — usage-sync (PHNX-3792 session mirror, PHNX-4051 auth
- verdict, PHNX-4116 SSH transport).** The `usage-sync` service refreshes every
- owned field in its `devices/<device>/daemon-state.json` (`publishOwnFleetState`)
- — the usage snapshot, EVERY box's lightweight per-session preview/metadata (the
- `sessions` field), and the reserved-auth readiness verdict — BEFORE the fan-out,
- so all three ride one envelope. `auth-sync` runs no transport of its own: it
- keeps the worker-slot reconcile + the credential SSH pushes, under its own
- deadline and circuit breaker, and acts on the peer verdicts the last exchange
- stored. There is no fleet-wide freshness gate (PHNX-4116 PR 5, which retired the
- old `readLastSuccessfulExchangeMs` marker): the pushes plan per peer off that
- peer's OWN daemon-state reply (`devices/<peer>/daemon-state.json`, first-hand and
- `receivedAt`-stamped), so a peer that has replied is planned off its per-account
- verdict at any age (the push is idempotent — a stale "has key" is harmless, a
- stale "missing" costs one redundant push), and a peer that has never replied — or
- whose reply carries no `accounts.rows` field at all (an older CLI — fail closed) —
- is skipped this tick and logged at INFO. Reserved-key presence is
- `verdict ∧ fingerprint`: the peer's own per-account verdict row (`accounts.rows`)
- must be non-`missing` AND the fingerprint (`workerCredential.mintedAt`) the
- publisher last delivered to that peer must match the account's current one. The
- verdict half is first-hand — a key removed on a worker flips its next verdict to
- `missing` and the push resumes. The fingerprint half catches a re-mint (`agents
- accounts login` bumps `mintedAt` while the old token still authenticates, so the
- verdict stays non-`missing`): the publisher keeps a minimal LOCAL per-`(peer,
- bundle, key)` delivered-fingerprint cursor (`reserved-sync-delivered.json`, never
- synced), so a re-mint re-pushes within one tick (worker-slot reconcile is NOT gated
- either — it reads only local durable keys). Every box except a
- marked `worker` folds peers' digests into its local `sessions` index as mirror
- rows. Only topic/label,
- a first-user-message snippet, last-activity, agent+version, cwd, ticket, and PR
- ride — never a transcript — and the mirror is bounded (200 recent sessions per
- box) and pruned by age (14 days). This is what lets the picker / `agents sessions`
- / `focus` render a remote-host row's topic and preview INLINE (no per-row SSH),
- and keep showing the last-synced preview when the peer is offline. Publish/consume
- + prune live in [`src/lib/session/mirror.ts`](src/lib/session/mirror.ts), the DB
- mirror writer/pruner in [`src/lib/session/db.ts`](src/lib/session/db.ts)
- (`upsertMirrorSession` / `pruneMirrorSessions`, guarded so a mirror never
- overwrites a genuine local transcript row), and the inline render in
- `buildPreview` ([`src/commands/sessions-picker.ts`](src/commands/sessions-picker.ts)),
- which still falls back to the live SSH digest fetch for a never-synced session.
-
- ### 11. Session recovery is one decision on the origin device
-
- `resolveSessionRecovery` in [`src/lib/session/recovery.ts`](src/lib/session/recovery.ts)
- chooses native resume versus an explicitly approved context replay. The account and
- native context are resolved before new-run defaults, then materialized through
- [`accounting/account-launch.ts`](src/lib/accounting/account-launch.ts). The selected
- executable is independent of the account home; never replace that choice with the
- transcript's recorded vendor version.
-
- Bare `run <harness> --resume` delegates to the same picker as `sessions resume
- --agent <harness>`. `#work` carries the account filter into selection and execution.
- The selected child keeps the original run arguments; concrete IDs stay below the
- picker to avoid recursion. Explicit terminal backends are consumed once, while
- account, model, mode, cwd and lifecycle constraints survive the child command.
-
- Native resume requires a readable transcript owned by the selected context home.
- Claude restores the earliest existing launch cwd from that transcript. Cold
- metadata is hydrated through `hydrateSessionTranscript` before refusal; a mirror
- stub is not native context. A verified missing conversation must not recreate its
- UUID as an empty launch. Replay asks explicitly and creates a new native ID.
-
- The SessionStart hook records the first originating `accountId` in the existing
- actor sidecar and session index, independently of binary release. Alias writes and
- rescans preserve it. Matching may also use proved canonical account-home ownership;
- organization quota keys and current credentials in a reused old home are not
- historical login proof. Unknown ownership stays unknown. Model refusal accounting
- uses the actual executed account and requested model, without disabling unrelated
- models or accounts.
-
- **Unreachable origin.** Resume checks the owning device first. If it cannot be
- reached, local replay requires readable archived conversation content and an
- explicit interactive choice. A mirror digest alone cannot satisfy that check.
- An unreachable device does not prove its process has stopped. Headless callers
- receive an error rather than starting an empty conversation.
-
- ## Configuration surface
-
- All persistent configuration that affects how agents run — default model, mode,
- effort, tier overrides, interactive host, browser profile, and per-device limits —
- lives under one command barrel:
-
- ```bash
- agents config list
- agents config get <key>
- agents config set <key> <value>
- agents config unset <key>
- ```
-
- Keys use `agent@version` as the canonical harness identifier. Examples:
-
- ```bash
- agents config set run.claude@*.model best
- agents config set run.claude@*.tier.best claude-opus-4-8
- agents config set run.claude@2.1.45.model claude-opus-4-8
- agents config set run.claude@*.mode auto
- agents config set run.claude@*.effort high
- agents config set interactive.host zion
- agents browser use work
- agents config set auto.pool workers
- agents config set devices.mac-mini.role worker
- agents config set devices.mac-mini.max-agents 4
- agents config set devices.mac-mini.scheduler off
- agents config set devices.mac-mini.tmux off
- agents config set devices.linux-desk.computer.host vnc://10.0.0.5:5901
- agents config set summarizer.enabled on
- agents config set summarizer.baseUrl http://localhost:11434
- agents config set summarizer.model qwen2.5:3b
- ```
-
- The new command is a **facade over the existing YAML storage**
- (`run.defaults`, `model.tiers`, `config.interactiveHost`,
- `defaultBrowserProfile`, and `deviceConfig`). Fleet sync behavior is unchanged.
-
- `summarizer.*` (user-scope, central `agents.yaml`, syncs fleet-wide) gates the
- **per-session summarizer** (PHNX-3939): `summarizer.enabled` (default **false**),
- `summarizer.baseUrl` (an Anthropic-wire endpoint — Ollama/vLLM/LiteLLM), and
- `summarizer.model`, each overridable per-process by `AGENTS_SUMMARIZER_ENABLED` /
- `AGENTS_SUMMARIZER_BASEURL` / `AGENTS_SUMMARIZER_MODEL`. See
- [§Per-session summarizer](#per-session-summarizer-phnx-3939).
-
- `devices.<name>.computer.host` (stored as `computerHost`, next to
- `defaultBrowserProfile`) is the `--host` address `agents computer --device
- <name>` forwards to the standalone `computer` engine (PHNX-4090): `ssh://[user@]host`
- (still resolved against the fleet for its ssh identity), `vnc://host[:port]`, or
- `tcp://host:port` — the same grammar shared with `secrets` (ssh-only) and
- `browser`. Unset falls back to the historical Windows-only ssh tunnel built from
- the fleet's own ssh identity. `agents secrets --device <name>` and `agents
- computer --device <name>` both rewrite `--device` to `--host` before exec —
- neither engine has a fleet registry of its own — while `agents view --device
- <name>` (not in `OWN_HOST_COMMANDS`) keeps SSHing the whole command, unchanged.
-
- `menubar.menu.*` (user-scope, central `agents.yaml`, syncs fleet-wide) are the
- **AGI Menu preferences** (PHNX-3999) — `defaultProject`, `workingRowsShown` (2/3/4),
- `showPreviews`, `projectPriorityFilter` (all/urgent/high/medium),
- `hideCompletedMilestones`, `bannerWhenNeedsYou`, `includeOtherDeviceRequests`,
- `groupBy` (none/project/agent/device), `thenBy` (none/project/agent/device),
- `projectScope` (cycle/all), `projectSort` (updated/priority/name),
- `ticketSort` (priority/updated/title), `showPullRequests`, `sessionUpdates` (the
- Home session-updates section), `deviceSort` (name/role/load/memory/disk), and the
- **Headless runs** defaults — `headlessAgent` and `headlessFallbackAgent` (each a
- registered agent id, validated at write time, unset = the menu's own default and no
- `--fallback`) plus `headlessPlacement` (`auto` | `local` | `interactive` | a device
- name; `auto` is `agents run --device auto`, which already refuses a `personal` or
- `desktop` box). Four keys shape the Projects tab and the tab bar:
- `pinnedProjects` (project names pinned to the top, in pin order; default `[]`),
- `tabOrder` (every one of `home`, `goals`, `secrets`, `projects`, `sessions`, `inbox`
- exactly once; default in that order — an order saved before Goals or Secrets existed may
- omit them, and every read resolves the stored list the way the menu's `TabLayout.resolve`
- does: unknown and repeated ids dropped, a missing tab appended in built-in order),
- `hiddenTabs` (any of those six but not all; default `["secrets"]`, so the Secrets tab
- starts hidden), and
- `groupTicketsByMilestone` (default `false`, a flat ticket list). Two keys drive Goals
- (PHNX-4291): `homeGoals` (the goal levels Home shows — any of `company`, `week`,
- `myWeek`, `myDay`, no duplicates; default `["company"]`) and
- `menubar.statusbar.goalCountdown` (default `false`; the one key outside the
- `menubar.menu.` prefix, parsed as section `statusbar`, listed in
- `MENUBAR_CONFIG_KEYS`). `prGroupOpen` and
- `prGroupMerged` (`none` | `type` | `day`; defaults `none` and `day`) group the PR board's
- open filters and its Merged filter. Settings is never in
- either tab list: it is always shown. The four list keys are `string-list` keys stored
- as YAML lists and emitted as JSON arrays; `agents config set` replaces the whole list
- from a JSON array (`'["Rush","Ops, west"]'`, what the menu writes) or comma-separated
- items (what a person types), and the order, membership, and duplicate rules are
- validated at write time. They are registered
- `agents config` keys, so the native menu reads them via `agents config list --json`
- (`{key,value,hint}` rows) and writes one per action; an **unset** key is omitted from
- the list so the menu falls back to its own baked-in default (the defaults in
- `config-keys.ts`/`device-config.ts` match those). No credential ever lives here.
- `agents menubar snapshot --json` also emits a `menuPreferences` map (each
- `menubar.menu.*` and `menubar.statusbar.*` key → its effective value, i.e. the stored value or the registered
- default; `defaultProject` omitted when unset) so the menu consumes preferences from
- the snapshot it already polls rather than a second read path. That map is scalar-only:
- the list keys (`pinnedProjects`, `tabOrder`, `hiddenTabs`, `homeGoals`) ride a separate
- `menuListPreferences` map, because a shipped menu decodes `menuPreferences` values as
- string/number/bool and a single array would fail its whole snapshot decode. On macOS, a one-shot
- sentinel-gated migration (`menubar/migrate-prefs.ts`, run from the snapshot compute)
- lifts legacy `com.phnx-labs.agents-menubar` UserDefaults into these keys — known keys
- only, never overriding an already-set value, never the `.dev` bundle, and never
- re-run after the sentinel is written (so a later `unset` cannot resurrect a legacy
- value).
-
- The snapshot's top-level `me` names who is signed in on this machine, for the menu's
- avatar: `{name, email, github, avatarUrl, avatarSource}`, or `null` when nobody is
- known (`resolveMenubarMe`, `menubar/snapshot.ts`). It is one person, never a blend of
- two: with a Phoenix ID session the session supplies `name` (saved at `agents auth
- login` and refreshed by `agents auth whoami`), `email` and the https picture, and the `gh`
- account contributes `github` (and a fallback name and picture) only when its public
- profile email equals the session email, so a shared box whose `gh` is someone else's
- lends no face. Without a session the `gh` account supplies everything but `email`.
- No picture means `avatarUrl: null` and the menu draws initials; `avatarSource` says
- which source won. GitHub facts come from `cachedViewer`
- ([`src/lib/github/viewer.ts`](src/lib/github/viewer.ts)), a `gh api user --cache 24h`
- read recorded at `~/.agents/.cache/github-viewer.json`: a snapshot spawns `gh` only
- when the record is stale (a day after a success, an hour after a failure, which keeps
- the last good viewer), capped at 5 s, so the common poll spawns nothing and a slow one
- stays inside the menu's 30 s deadline. The record holds no email, only a SHA-256 of
- the public one; `email` exists only in the local snapshot JSON.
-
- `devices.<name>.formFactor` (stored as `formFactor`, shared device-scope) is a
- factual hardware fact — `laptop`/`desktop`/`server`/`unknown` — the menu bar renders
- as an icon. Set it explicitly per device (`agents devices config <name> formFactor
- laptop`); it is **never** inferred from the OS platform, and unset reads as
- `unknown`. It rides `agents menubar snapshot --json` in each `devices[]` row.
-
- Each `devices[]` row also carries the **role and spec facts** Settings shows
- (PHNX-3999 F25): `role` (`worker`/`personal`/`desktop`, else `unknown`),
- `autoEligible` (the verdict of the one canonical `filterAutoPool`, so the menu shows
- the fact that actually governs `--device auto`), and `stats` — `reachable`,
- `observedAt`, `stale`, `cpus`, `memTotalBytes`, `memFreeBytes`, `memPercent`,
- `diskTotalBytes`, `diskFreeBytes`, `diskUsedPercent`, `loadPercent`,
- `specsObservedAt`. `stats` is read from the existing fleet-stats CACHE
- (`readStatsCache`, `~/.agents/.cache/.fleet-stats.json`) — **never a probe**, so
- opening Settings costs nothing — and is `null` for a device never measured. Every
- number is `null` when unobserved rather than `0`, which would render as a real
- reading of an idle box with an empty disk; `stale` carries the CLI's own
- `STATS_STALE_MS` verdict so an old reading is labelled instead of shown as live.
-
- `devices.<name>.tmux` (stored as `tmux.enabled`) defaults off, so a LOCAL
- interactive `agents run` launch spawns the agent directly. Turn it on for a
- device to wrap eligible local launches in the shared-socket tmux session and give
- each agent an exact `%pane` address for `agents send --channel session`, injection, and
- `agents focus`. The setting is machine-local and cannot be set for a peer.
-
- **It does not govern a run dispatched here over `--device` (RUSH-3125).** That
- run's stdio is an ssh link, so without the wrap a blink SIGHUPs the agent and the
- in-flight turn is lost — and `lib/hosts/reconnect.ts` re-attaches on the premise
- that it survived. Durability is not the local operator's mouse/clipboard/
- scrollback preference, so the two are separate concerns: the interactive
- dispatcher exports `AGENTS_REMOTE_INTERACTIVE=1` (`REMOTE_INTERACTIVE_ENV`,
- [`src/lib/types.ts`](src/lib/types.ts)) and the peer wraps on it regardless of
- `tmux.enabled`. A remote interactive run on a box with **no tmux installed** is
- refused (`undurable`) rather than started as something a blink would kill.
-
- `--no-tmux`, `--raw`, and `AGENTS_NO_TMUX=1` remain per-run opt-outs and beat the
- durability rule too, so the escape hatch keeps working over `--device`. The gate
- is `resolveTmuxWrap` ([`src/lib/exec.ts`](src/lib/exec.ts)) — three outcomes
- (`wrap` / `bare` / `undurable`), reading `isTmuxEnabled()` and the marker.
-
- `devices.<name>.role` (stored as `role`) says what a device is for fleet-wide —
- `worker` (agents run here) or `personal` (a machine you sit at).
- `agents devices role <name> <role>` is the task-shaped spelling. Marking any
- device `worker` turns automatic placement into an allowlist: `--device auto` then
- picks only from the marked workers, in every caller (`run`, `teams`, `ssh auto`,
- the generic `--device auto` passthrough, and the AGI EXT launch commands, which
- resolve placement through the CLI rather than scoring devices themselves); a
- `personal` or `desktop` box is never picked automatically. The rule is one function —
- `filterAutoPool` in [`src/lib/devices/pool.ts`](src/lib/devices/pool.ts) — read by
- `listOnlineDeviceNames`, and `auto.pool` (`workers` by default, or `all`) turns
- the allowlist off. When roles leave the pool empty, **both** resolvers throw
- (`formatEmptyAutoPoolError`): a `null` host means "run locally", which on a
- `personal`/`desktop` box is the outcome the mark exists to prevent. Unlike the machine-local
- keys, `role` is **shared**: it lives in that device's tracked
- `devices/<name>/agents.yaml` `config.role` and syncs with `agents repo
- push/pull`, because every box has to agree on where agents may land. The
- vocabulary is `worker | personal | desktop`. `desktop` is a headed always-on box
- — the release/credential home, e.g. a Mac mini: it is NOT headless fan-out
- capacity, so like `personal` it is excluded from `--device auto` (both are in
- `NEVER_AUTO`), but it IS a headed box with a real interactive login, so for the
- Claude auth strategy it sits in the SAME "headed" bucket as `personal`
- (`isHeadedDeviceRole`, `device-config.ts`) — it authenticates from its own
- per-version login, never the worker setup-token. **Which credential a run injects
- is keyed on DEVICE ROLE, not run mode** (`isHeadedDeviceRole`, not `ctx.interactive`,
- in `applyExecConfigEnv`): a worker authenticates EVERY run — interactive dispatched
- TUI or headless — from its `user:inference` setup-token (the non-interactive worker
- credential, minted via `agents accounts add claude <name>`), while a headed box uses its native
- `user:profile` login for both. Keying on run mode instead is the PHNX-3502 (worker
- `--interactive --device` → login screen, the setup-token sitting unused) / RUSH-2395
- (headless laptop run → hijacked the human's login) bug pair. Full model:
- [`cli/docs/credential-management.md` §Which credential a run injects](docs/credential-management.md).
-
- **Owner rule — never regress (credential-management.md invariant 7).** A headed
- box (`personal`/`desktop`, e.g. `zion`) authenticates ONLY with its native
- interactive OAuth login, never the long-term setup-token — the token is
- identity-blind, and the headed box is where the token is minted. A dead/expired
- native login on a headed box is fixed by re-running the native OAuth flow
- (`claude` → `/login`, or `agents accounts login claude#<name>`), NOT by falling back to
- the injected setup-token. Do not add a "native login expired, use the token
- instead" fallback on a headed device — that inverts the rule.
- `agents accounts add` is headed-only: on a worker it refuses before allocating
- a slot, installing, or opening a browser. Add the account on a personal/desktop
- device; workers are provisioned from the durable credential the add mints
- (claude: a `setup-token` driven in the account's slot; api-key harnesses:
- `--api-key` or a prompt; a token-less harness logs in per box — run it on that
- device and complete its native login). `accounts
- login <harness>#<name>` re-auths into the same slot and re-mints; `accounts
- default <harness> [name]` is the one default write path.
-
- The one display consequence:
- because a `personal` box is by definition the interactive seat, `agents devices
- list` folds the `★ interactive` star INTO the `personal` role rather than
- printing both (the star still shows for a non-personal box pinned as
- `interactive.host`).
-
- `auto-launch.enabled` / `auto-launch.preferred` are the per-device switches that
- modulate that SAME `filterAutoPool` pool — one placement rule, not a second one.
- `agents devices disable <name>` sets `auto-launch.enabled` = false and drops the
- box from the pool the same way a `personal`/`desktop` role does, so it leaves
- EVERY automatic-placement path (`run`, `teams`, `ssh auto`, the AGI EXT launch
- commands) at once; `agents devices enable <name>` (the default) restores it.
- `agents devices config <name> auto-launch.preferred on` sets `auto-launch.preferred` = true, which does NOT
- narrow the pool — it BOOSTS the box in the ranker: `autoLaunchPreferredSet`
- (`pool.ts`) feeds `pickBestDevice` (`teams/scheduler.ts`), which ranks a preferred
- device ahead of its load-equal peers, after the signed-in tier and before load, so
- the boost overrides load-based ordering without overriding hard health;
- `agents devices config <name> auto-launch.preferred --unset` removes it. Both flags are **shared** device-scope
- keys living in `devices/<name>/agents.yaml` `config.autoLaunch{Enabled,Preferred}`
- (a fleet-wide default rides `fleet.defaults.config`, set with `--fleet`), so they
- sync with `agents repo push/pull`. `agents devices enable|disable <name>` are
- the task-shaped spellings for `auto-launch.enabled`; `auto-launch.preferred` has
- no verb and is set through `agents devices config <name>` — the one canonical
- per-device settings surface. The **menu bar**
- surfaces `auto-launch.preferred` as a per-device **★ Favorite / Unfavorite**
- toggle (writing that same canonical `devices config … auto-launch.preferred
- on|off`, no separate store): a favorited device shows a ★ and sorts directly below
- the current machine, above the rest, in the DEVICES section (PHNX-2376).
-
- `devices.<name>.description` (stored as `description`) is the free-text sibling
- of `role`: one line saying what the box is FOR — "gpu box — cuda 12.4", "release
- runner" — where `role` is the two-value placement switch. Like `role` it is
- **shared**: it lives in the device's tracked `devices/<name>/agents.yaml`
- `config.description` and syncs with `agents repo push/pull`, and any box may set
- it for any device. `agents devices describe <name> <text>` is the task-shaped
- spelling (thin sugar over `agents devices config <name> description` — one
- store, two names), and the default `agents devices list` renders it as the tail
- column next to a `spec` cell (cores / total RAM / total disk) and `load`, `mem`,
- `disk` used columns (RUSH-3062). It is validated as a single
- line capped at 80 characters — a newline or an over-long value is rejected
- loudly, never truncated. It is NOT `notes`: `notes` stays an appended list of
- long-form operator scratch that is never shown in device listings.
-
- `agents devices ignored` lists the tailnet nodes dismissed from auto-discovery —
- each with when it was dismissed and which machine dismissed it, read from the
- tracked `fleet.ignored` block that `agents devices ignore` writes and
- `agents repo push/pull` syncs. The `agents devices list` footer names how many
- are hidden so a dismissed box is never silently absent; `agents devices unignore
- <name>` puts one back (RUSH-3062).
-
- Native-account names (set at `agents accounts add <harness> <name>`) are the same kind of fleet-wide
- fact: they bind to a stable `(agent, identityKey)` (email / org key), not a
- device or a version. They live on the central `accounts.native` rows in
- `~/.agents/agents.yaml` — already classified `central` and synced by
- `agents repo push/pull` — so `codex#personal` selects the same login on every
- box. A git merge of two independently labeled boxes can union two UUID rows for
- one identity; `accounts remove` / `rename` operate on every matching
- row so a sibling cannot silently survive.
-
- **A label name is unique per HARNESS, not globally (PHNX-3887).** One human
- identity is routinely signed into several harnesses — the same email is a claude
- login AND a codex login AND a grok login — so a global namespace let whichever
- harness was labelled first squat the good name and forced prefixed junk
- (`cxicloud`, `gkicloud`) on the rest. `assertUniqueUnifiedName`
- ([`src/lib/account-registry.ts`](src/lib/account-registry.ts)) scopes the check by
- `agent`, so `claude#icloud` / `codex#icloud` / `grok#icloud` each resolve to that
- harness's own row. Nothing is ambiguous at the point of use: the selector already
- names the harness, and `findUnifiedAccount`'s `preferAgent` already disambiguated
- on the read side. A duplicate label WITHIN one harness is still refused, and
- **provider** (non-native) accounts stay globally unique — they are selected by bare
- name via `--account`, with no harness to scope them by. `accounts rename` /
- `remove` / `view` accept `<harness>#<name>` and refuse a bare name that spans several
- harnesses rather than guessing (PHNX-3988).
-
- **Writing a label writes `agents.yaml` and commits it on the spot**
- (`commitCentralConfigAfterWrite`, [`src/lib/state.ts`](src/lib/state.ts), from a
- CLI command and the daemon alike). No daemon tick rebases or rewrites the user
- repo any more (PHNX-4116) — daemon state moves over SSH — so the PHNX-3887
- failure (the shared-state tick autostashing over a dirty central file and losing
- every box's account labels) has no path to recur.
-
- `interactive.host` is a **user-level** preference: it lives in central
- `~/.agents/agents.yaml` under `config.interactiveHost`, syncs fleet-wide via
- `agents repo push/pull`, and answers "which device shows me artifacts?" It is
- intentionally not a per-device key. To see it in the per-device view, use
- `agents devices config <name> --inherited`.
-
- Two older spellings still work but are deprecated and print a warning pointing
- at the replacement:
-
- - `agents models tier` → `agents config set run.<agent@version>.tier.<tier>`
- - `agents browser profiles set-default` → `agents browser use <name>`
-
- `agents devices set-interactive` and `agents devices configure` are gone:
- `agents config set interactive.host <name>` and `agents config set
- devices.<name>.<key>` are the only spellings.
-
- Implementation: [`src/commands/config.ts`](src/commands/config.ts) with key
- parsing in [`src/lib/config-keys.ts`](src/lib/config-keys.ts). Per-device config
- helpers live in [`src/lib/device-config.ts`](src/lib/device-config.ts).
-
- ## Supported harnesses
-
- The supported harnesses are the entries in the `AGENTS` registry
- ([`src/lib/agents.ts`](src/lib/agents.ts)) — the canonical list, gated through
- `supports()`; the full id union is `AgentId` ([`src/lib/types.ts`](src/lib/types.ts)).
- The table below is a snapshot of their per-harness capabilities — keep it in sync
- with the registry. **Prioritized (first-class):** Claude Code, Codex CLI, Kimi CLI,
- Antigravity CLI, Grok CLI, OpenCode — features target these six first.
-
- | Harness | `id` | hooks | mcp | allowlist | skills | commands | plugins | subagents | workflows |
- |---|---|:--:|:--:|:--:|:--:|:--:|:--:|:--:|:--:|
- | ★ Claude Code | `claude` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
- | ★ Codex CLI | `codex` | ≥0.116 | ✓ | — | ✓ | <0.117 | ≥0.128 | ≥0.117 | — |
- | ★ Kimi CLI | `kimi` | ✓ | ✓ | ✓ | ✓ | — | ✓ | ≥0.29.0 | ✓ |
- | ★ Antigravity CLI | `antigravity` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ≥1.0.16 | ≥1.0.6 |
- | ★ Grok CLI | `grok` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ≥0.2.111 |
- | ★ OpenCode | `opencode` | ≥0.3.130 | ✓ | ≥1.1.1 | ✓ | ✓ | ✓ | — | — |
- | Cursor | `cursor` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ≥2026.1.22 | — |
- | OpenClaw | `openclaw` | — | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ |
- | Copilot | `copilot` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ≥0.0.353 | — |
- | Amp | `amp` | — | ✓ | — | ✓ | ✓ | — | — | — |
- | Goose | `goose` | ≥1.34 | ✓ | — | ≥1.25 | — | ✓ | — | ✓ |
- | Droid | `droid` | ✓ | ✓ | ≥0.57.5 | ≥0.26 | ✓ | ✓ | ✓ | — |
- | Hermes | `hermes` | ≥0.11 | ✓ | — | ✓ | — | — | — | — |
- | Muse Code | `muse` | ✓ | ✓ | — | ✓ | — | ✓ | — | — |
- | Warp Agent CLI | `warp` | — | ✓ | — | ✓ | — | — | — | — |
-
- ✓ = supported · — = not · version cell = only within that range (out-of-range =
- skipped silently). [`src/lib/agents.ts`](src/lib/agents.ts) is canonical — keep this
- snapshot in sync. `workflows` is `claude`/`kimi`/`goose`/`antigravity` (≥1.0.6, written to the
- shared HOME-global `~/.gemini/config/global_workflows/`, not a per-version home), `openclaw` (Lobster `.lobster` files under `.openclaw/workflows/`), and `grok` (≥0.2.111, native Rhai under `.grok/workflows/`); `mcp` is universal; `allowlist` is
- `claude`/`cursor`/`opencode`/`antigravity`/`grok`/`kimi`/`droid`/`openclaw`/`copilot` (Copilot writes per-location approvals to `~/.copilot/permissions-config.json`; **Goose is deliberately NOT allowlist-capable** — its `permission.yaml` gates whole tools (`developer__shell`, `developer__text_editor`), so a canonical rule set could not be expressed or read back faithfully; OpenClaw is tool-level only —
- blanket rules map to `~/.openclaw/openclaw.json` `tools.alsoAllow`/`tools.deny`, sub-command patterns skipped). Ask rules (`ask:` in a permission group) are written where the native config has a per-rule prompt list: Claude `permissions.ask`, OpenCode `permission.bash` value `ask`, Codex `prefix_rule` decision `prompt` in `agents-ask.rules`, Grok `action = "ask"`, Kimi `decision = "ask"`, Droid `permissionRules` decision `ask`, and Antigravity `permissions.ask`. Cursor, Copilot, OpenClaw, and Hermes have no per-rule ask list; allow and deny still sync, and the ask rules are skipped with the reason on `PERMISSION_TARGETS`. A preset include that names a missing group throws, naming the preset and the group. `00-local` is the one optional per-machine group and is skipped when its file is absent; `subagents` is `claude`/`codex`/`kimi` (≥0.29.0, Claude-shaped `<name>.md` in `~/.kimi-code/agents/`; older kimi-code compiles its agent profiles into the bundle with no filesystem loader)/`grok`/`openclaw`/`droid`/`copilot`/`antigravity`/`cursor` (≥2026.1.22). **Warp Agent CLI (`oz`)** is the coding-agent CLI on Warp's Oz platform (the shared Warp binary invoked via the `oz` symlink). Install is self-updating via `brew install --cask oz` (macOS) / the `oz-stable` apt|yum|pacman package (Linux); config lives under `~/.warp/`, the rules/context file is `AGENTS.md`, and auth is `oz login` (browser OAuth) or a `WARP_API_KEY` token for headless/CI (`oz api-key create`). Headless run is `oz agent run --prompt "<task>" [--model <id>]`; autonomy is governed by the selected agent profile (`--profile`), not a per-run permission flag, so the single `edit` mode maps to no flags (mirrors Hermes). `mcp` covers stdio + http + headers via the Claude `.mcp.json` schema at `~/.warp/.mcp.json` (project `<root>/.warp/.mcp.json`); `skills` come from `--skill` + `oz agent skills`. `hooks`/`allowlist`/`commands`/`plugins`/`subagents`/`workflows`/`memory` are OFF: Oz has no event→shell hook registration, its permissions are profile-based (not a Claude tool allow/deny list), slash-commands are native/server-managed, and cloud agents/profiles are server-side (no installable subagent dir). Warp is intentionally **absent from `SESSION_AGENTS`** — Oz stores conversations server-side (retrieved with auth via `oz run conversation get <id>`), so there is no local transcript for `agents sessions` to index — and it exposes no usage/limits endpoint, so `agents view` shows no usage bar for it.
- **Gemini is hard-deprecated.** Keep the legacy `gemini` id only for parsing old
- sessions/config; `agents add gemini`, `agents import gemini`, and
- `agents sync gemini` fail and point users to Antigravity.
-
- **Account capabilities are a separate axis (PHNX-3940).** The table above gates
- *resource* writes; whether a harness's native login can be **named/attached** and
- what **durable worker credential** it can mint are governed by two other canonical
- tables, not this one — do not fold them in. Native naming lives in
- [`account-capabilities.ts`](src/lib/account-capabilities.ts)
- (`NATIVE_ACCOUNT_CAPABILITIES`): claude/codex/grok/cursor/kimi are **supported**,
- muse is **conditional** (email-only), the device-scoped opaque harnesses
- (antigravity/droid/opencode) are **unsupported** until a device-id discriminator
- exists, and the rest are discovery-only/unsupported. The worker credential kind lives in
- [`harness-auth-capabilities.ts`](src/lib/harness-auth-capabilities.ts)
- (`HARNESS_AUTH`); the reserved store that holds it is
- [`reserved-stores.ts`](src/lib/reserved-stores.ts) (see
- [`docs/credential-management.md` §Slots and reserved stores](docs/credential-management.md#slots-and-reserved-stores-phnx-3940)):
- claude mints a `setup-token`; codex/grok/cursor/opencode/droid carry a provider
- API key; kimi and antigravity have no portable credential and log in per box
- (run the harness on that box and complete its native login). `agents accounts add
- <harness> [name]` is the one
- onboarding verb across all of them — headed devices only; workers are provisioned
- automatically from the minted credential.
-
- ### Codex's Linux sandbox needs unprivileged user namespaces (PHNX-3285)
-
- Codex ≥0.146 is the **only** harness that sandboxes its own tool calls on Linux
- with a bundled **bubblewrap** — it extracts `codex-linux-sandbox` + `bwrap` per run
- and sets up mounts inside a fresh unprivileged **user namespace** (`--unshare-user`,
- then a `/proc/self/uid_map` write) for both `read-only` and `workspace-write` modes
- (only `skip` = `--dangerously-bypass-approvals-and-sandbox` uses no bwrap). The
- legacy Landlock backend is gone (`use_linux_sandbox_bwrap` is `removed`,
- `use_legacy_landlock` panics under the permission-profile model), so bwrap is the
- only Linux sandbox path. grok/kimi/cursor/etc. do **not** sandbox this way, so this
- is codex-specific.
-
- Ubuntu 23.10+ ships `kernel.apparmor_restrict_unprivileged_userns=1`, which denies
- that userns to an unconfined binary — bwrap then dies with `bwrap: setting up uid
- map: Permission denied`, and a **headless** codex run (an `agents teams` codex
- teammate, or `agents run codex` — always headless + sandboxed) lands **zero tools**
- while still reporting a completed turn. `spawnAgent` preflights this before the
- spawn (`codexSandboxPreflight` in [`src/lib/exec.ts`](src/lib/exec.ts), probing
- [`src/lib/linux-userns.ts`](src/lib/linux-userns.ts)) and **fails loud** with the
- one-time fix instead of silently under-delivering. Scope is deliberate: codex +
- Linux + headless + a sandboxed (non-`skip`) mode only — an interactive TUI surfaces
- the bwrap error itself, `--mode skip` uses no sandbox, and macOS/Windows never hit
- it. The intended `auto` = workspace-write + `approval_policy=never` config is never
- weakened.
-
- The one-time fix keeps the sandbox intact — re-enable unprivileged userns per box:
- [`scripts/enable-codex-sandbox.sh`](scripts/enable-codex-sandbox.sh) writes the
- `kernel.apparmor_restrict_unprivileged_userns=0` sysctl drop-in, applies it, and
- re-probes to confirm it took. Run it once per fleet worker
- (`sudo bash cli/scripts/enable-codex-sandbox.sh`, or fan out with
- `agents ssh <box> 'sudo bash -s' < cli/scripts/enable-codex-sandbox.sh`).
-
- ## Source layout
-
- ```
- src/
- index.ts # CLI entry (commander.js)
- commands/ # User-facing subcommands (one file — or a `<cmd>-*.ts` family, e.g. the `sessions*.ts` family — per `agents <cmd>`)
- # ps.ts + ps-roster.ts: `agents ps`, the live roster (gatherActiveSessions/renderActiveSessions)
- # and the stop/focus/detach/migrate verbs; `sessions --active` calls the same runLiveRoster (PHNX-4227)
- lib/
- state.ts # Path constants; agents.yaml read/write (serializeCentral preserves comments)
- manifest.ts # Project/user agents.yaml Manifest read/write (comment-preserving Document round-trip; used by mcp add, etc.)
- resources.ts # resolveResource() / listResources() — layered resolution
- capabilities.ts # supports() — the per-agent write gate
- agents.ts # re-exports agent-spec/agents.ts (per-agent capability table)
- agent-spec/ # version-resolution engine + AGENTS table
- subagents-registry.ts # SUBAGENT_TARGETS — declarative per-agent subagent shape (dir/layout/transform); generic install/list/remove engine
- workflows-registry.ts # WORKFLOW_TARGETS — declarative per-agent workflow shape (dir/layout/transform/marker); generic sync/list/remove/drift engine the staleness detector also reads
- installations/ # versions.ts (install, remove, syncResourcesToVersion), migrate.ts (one-shot idempotent migrations), store/resolve/strategies, shims.ts (shim generation, config symlink switching)
- hooks/ # hooks.yaml parser + per-agent registrar (install.ts), `matches:` evaluator (match.ts), cache/profile adapters
- browser/ # `agents browser` CONSUMER of the standalone `browser` engine (@phnx-labs/browser-cli, PHNX-4101):
- # context.ts (the fd-3 JSON handed to the engine — the --device target resolved against the fleet,
- # session identity, remote-control consent). The engine keeps its own action history, so agents-cli
- # opens no events pipe. `agents browser sessions` is the engine's own picker, and the feed reads its `--json` (PHNX-4227).
- # The CDP/BiDi/Arc drivers, the IPC service, the chrome-data/profile
- # store, the task index AND the whole remote SSH path live in the engine. The subprocess client is
- # `lib/browser-client.ts` (fd-3 context only, no fallback), mirroring `computer-client.ts`.
- projects.ts # named multi-repo definitions and status projection; domain model in docs/concepts.md
- project-pull.ts # fleet pull with fast-forward, clean-tree, branch, and repository-identity guards
- session/ # `agents sessions` READER — discovery/parse/render of agent transcripts; also `migrate-targets.ts` (the `sessions migrate` target scorer); `db.ts` `queryResourceUsageStats`/`backfillResourceUsage` back `agents insights resources` (also `agents sessions stats`) + `sessions backfill resources` (skill/command usage rollup, session_resource_usage + resource_scan_ledger); `claude-accounts.ts` attributes each Claude transcript to the account that produced it (account_key) and `insights.ts` extracts the cached multi-harness friction/correction/automation facets behind `agents sessions insights` (`agents insights` alias) — including a shell-command-by-binary breakdown (`bashCommands`/`bashCommandFailures`, keyed by `bash-command.ts`'s `bucketKey`) that splits the flat `Bash` tool count into `git commit`/`gh pr`/`agents ssh`/… so the tool mix and failed-tool loops name the actual command, not just the harness tool
- terminal/ # Terminal launch engine — tab/split in iTerm/Ghostty/tmux/Terminal.app, local or --device;
- # preferred.ts resolves WHICH terminal for a GUI caller (from live sessions' host app)
- cloud/ # Provider registry (Rush / Codex / Factory / Antigravity)
- teams/ # `agents teams` orchestration
- computer/ # `agents computer` CONSUMER of the standalone `computer` engine (PHNX-4075):
- # policy.ts (permissions → the allow-list file), context.ts (the fd-3 JSON handed to
- # the engine, incl. the --device target resolved against the fleet), record.ts (fd-4
- # action events → `computer.action` feed events). `agents computer sessions` is the engine's own picker. The
- # daemons, RPC, RFB/VNC, the model loop AND the whole remote path — Windows
- # provisioning, the helper token, the ssh -L tunnel — live in the engine.
- menubar/ # AGI Menu installer/downloader/snapshot (the helper's SOURCE is phnx-labs/agi-menu, PHNX-4036)
- profiles.ts # Host CLI + endpoint + model bundles
- ```
-
- Note: the pure transcript **reader** — parse/render/state/trajectory/timeline/
- insights/tool-calls/digest/highlights/prompt/tail/share-html and the `SessionEvent`/
- `SessionMeta`/`SessionStep` types — no longer lives here. It is imported from
- [`@phnx-labs/sessions-cli/reader`](https://www.npmjs.com/package/@phnx-labs/sessions-cli)
- (PHNX-4118), the same parse→normalize→render pipeline the `sessions` bin runs,
- imported **in-process** (a normal node_modules import — never a subprocess) so the
- indexer warm-tick (`db.ts`) and live-state
- (`active.ts`) parse without shelling out. What remains under `src/lib/session/` is the
- CLI-owned half: the writer/indexer (`db.ts`, `tool-index.ts` — which only WRITES the
- tool-call index; searching it is the standalone `sessions --include tools`, PHNX-4227 —
- `tool-store.ts`, `timeline-pass.ts`, `title.ts`), lifecycle/live-identity (`active.ts`,
- `discover.ts`, `pid-registry.ts`, `recovery.ts`, `mirror.ts`, `presence.ts`, `watch.ts`,
- `remote*`, `projection.ts`, `session-cache.ts`), CLI/fleet selector resolution
- (`selection.ts`), and `migrate-targets.ts`. `sync/`
- (the off-box backup client and the managed Worker template) is still a copy of
- sessions-cli's `./backup` module and goes once that export is published.
- Read queries (`agents sessions <query> [--json]`) exec the `sessions` bin from the
- `@phnx-labs/sessions-cli` dependency (`src/index.ts`, `lib/sessions-client.ts`);
- when no bin resolves they fail loud, with no in-process read. The
- live-session **writer** is a separate package,
- [`packages/session-tracker`](../../packages/session-tracker) — different data,
- different consumer; see its AGENTS.md.
-
- ### `agents sessions` preview architecture (map before you touch it)
-
- The interactive UI is three picker variants in `src/lib/picker.ts`: `itemPicker`
- (single-select, `space` toggles preview), `dynamicPicker` (async data source, used
- by the session browser, `tab` toggles preview), and a multi-select variant. All
- render a right/bottom **preview pane** built by `buildPreview(session)` in
- `src/commands/sessions-picker.ts` — a header (title/agent/model/cwd/ticket/PR) plus
- `formatCompactPreview`'s verb-led rows (RUSH-2757): `Asked` (originating prompt),
- `Doing` (checklist/team/sub-agents), `Made` (file deltas, artifacts, plan, PR),
- `Health` (errors + test verdict), `Cost` (msgs/tokens + tool mix), `Latest` (the
- full last message, wrapped), and one width-capped `Details ▸` fold (session id,
- skills, plugins, hooks, links, dirs, repos).
- The card leads with the session's **headline**, and that headline is one ladder
- everywhere (PHNX-3797): `/rename` label → the daemon-generated `generatedTitle` →
- the first-prompt topic — **never** the agent's latest transcript line, which is a
- rolling monologue and belongs to the live preview/`lastAgentLine` slot instead.
- Live rows resolve it in `deriveSessionRecap` (`src/lib/session/active.ts`),
- indexed rows in `sessionHeadline` (`src/lib/session/title.ts`); no surface
- re-derives its own ordering. `generatedTitle` is produced ONCE per session by the
- daemon's `session-title` service with a cheap model and persisted in
- `sessions.generated_title` beside the hash of the user text it came from, so a
- titled session never costs another model call and re-titles only when that first
- user message changes (or on `agents sessions backfill titles --refresh`). It
- rides the existing streams — the watch row spread and the PHNX-3792 session
- mirror — so remote rows show the same title with no per-row SSH and no client
- generates one itself.
-
- `agents ps preview <uuid-or-prefix>` uses the same card without the picker; the
- renderer and its registration live in `commands/ps.ts` and the detail block in
- `lib/session/presentation.ts`. `agents sessions preview` and `agents sessions
- --preview` are the same command until the old group is removed, and both outbound
- peer calls still send the old `sessions preview` argv so an older receiver answers
- (PHNX-4227 R1).
- ID-shaped selectors go through the indexed fleet resolver, remote cards render on
- their owning peer, and the normalized digest is cached in SQLite against the
- transcript's actual mtime + size. Live status is deliberately outside that durable
- digest and expires after 15 seconds through `session-cache.ts`. A remote-host row
- whose lightweight digest was fleet-synced (a `mirrorSyncedAt` row — see §10 the
- session mirror, PHNX-3792) renders its topic + first-user preview INLINE from that
- local mirror with no per-row SSH; only a never-synced remote row still triggers the
- live `sessions preview <id> --local --json` fetch over SSH.
-
- Indexing is lazy — only `discoverSessions` writes the index — so a session THIS
- box just started is "running" in `--active` before it is indexed. The id resolver
- (`computeLocalMetadataMatches` in `lib/session/selection.ts`) therefore unions the indexed
- rows with the LIVE registry on a cold id miss, so `preview`/`resume`/`focus`
- resolve a running session with no transcript row yet (the fan-out peer answers
- from the same union, so it works cross-device too — SES-9b). The daemon keeps the
- index current within seconds via `runSessionIndexWarmTick`, and the cold-miss
- repair waits for a concurrent scan rather than returning a stale read
- (`discoverSessions({ waitForScan })` — SES-9c). This is why a running session no
- longer reads "No session matching" during the index-lag window (RUSH-2682).
-
- **A unioned live row is not automatically a LOCAL answer (PHNX-3890).** The box
- that *launched* a session running on a peer holds a live row for it with no
- transcript on disk and a `machine` that defaulted to itself — the launch process is
- here, the conversation is not. So the full-UUID short-circuit only skips the fleet
- fan-out for a row this box can actually answer for (`isLocallyDefinitiveMatch`: a
- real `filePath`, or a `machine` that is genuinely attributed), and the live bridge
- first re-attributes such a row against the fleet-active snapshot
- (`reconcileLiveMetaMachine`). When the fan-out does run, the owning peer's answer
- displaces the shim (`preferOwnerAttribution`) — candidates are grouped per machine
- and consumers read the first hit, so fanning out alone would still let the launcher
- box win. Read the launcher-shim rule in
- [`docs/specifications.md` §Active sessions](docs/specifications.md#sessions) before
- touching any of it.
-
- Routing lives in `src/commands/sessions.ts`: `isBareBrowserListing`
- (+`hasNoBrowserDisqualifyingFlags`) gates the bare fleet-wide listing to the rich
- `runSessionBrowser` ([`src/commands/sessions-browser.ts`](src/commands/sessions-browser.ts));
- a query/filter falls through to `pickSessionInteractive` → `sessionPicker`
- (both in [`src/commands/sessions-picker.ts`](src/commands/sessions-picker.ts)).
- `--flat`/`--tree`/`--json`/`--no-interactive` print non-interactive views with no
- preview. `PICKER_RECENT_COUNT = 15` caps the picker's list rows.
-
- Gotcha: the preview pane has **no guaranteed height** — `availablePreviewRows =
- terminalRows() - fixedRows` (`picker.ts`), and `limitPreviewHeight` returns `''` when
- that collapses, so the preview can silently vanish on a full/short terminal (the
- RUSH-2198 bug). See the [§Contracts §Sessions spec](docs/specifications.md#sessions)
- for the non-empty-preview invariant (SES-8).
-
- ### Resume is machine-bound — check the owner before you start a harness
-
- **Reading and resuming follow different machines, and conflating them is the bug.**
- Reading follows the FILE — a synced mirror with a readable `filePath` is on this
- disk and previews locally, even when `machine` names its owner. Otherwise, a row
- whose `machine` names another box must be read on that peer. `_remote` is one
- explicit signal, not the gate: host-dispatch index rows and live-registry rows can
- name a peer without carrying it. `transcriptOnPeerOf` in `sessions-picker.ts` is
- the one predicate used by picker previews, direct `preview <id>`, and `sessions
- <id>`. Resuming follows the HARNESS STATE, which is on the owning machine whatever
- the transcript's location. A mirror is therefore readable and NOT resumable, and
- that is the trap: nothing fails until the agent is asked to continue a conversation
- it has never seen, and `sessions-resume.ts`'s `fs.existsSync(cwd)` fallback then
- quietly resumed in `process.cwd()` (RUSH-2022, PHNX-3481).
-
- `sessionOwnerDevice`
- ([`src/lib/session/resume-owner.ts`](src/lib/session/resume-owner.ts)) is the one
- answer to "may this resume run here?". Every path that starts a harness from a picked
- row consults it first: `agents sessions resume` and the `agents sessions` picker hop to the
- owner. The batch
- `sessions resume` mostly inherits it for free: every TAB it opens runs the
- canonical `agents sessions resume <id>` (`lib/session/resume-command.ts`), whose docblock
- already promised source-device routing — this is what makes that true. Its
- no-tab-backend path (`inplace`, which any Linux box in a plain ssh shell lands on)
- never runs that command, so it routes explicitly via `resumeOnOwnerIfRemote`.
- `resumeSessionInPlace` ([`src/lib/session/resume-command.ts`](src/lib/session/resume-command.ts)) is the LOCAL takeover and **fails loud** if it is handed a
- peer-owned session, since reaching it with one means a caller skipped its routing
- step.
-
- `buildSelectedResumeSurface` prepares the batch picker's terminal command and
- working directory. Like `buildRunCommand`, it quotes shell words once before
- passing them to a terminal backend; `spawnCliInPlace` still receives raw argv.
- For `--device`, the selected origin validates its own filesystem. The surface
- uses `--cwd` when supplied, otherwise the recorded directory, and refuses a
- remote launch with neither rather than sending this machine's path. Run pickers
- keep the normal `--remote-cwd` precedence and make local home paths portable.
- The terminal engine resolves home-relative directories on the target before
- building the backend command; terminal APIs do not expand shell home syntax.
-
- The hop uses `runOnPeer` ([`src/lib/session/remote/remote-list.ts`](src/lib/session/remote/remote-list.ts)),
- not the `--device` passthrough. Two reasons: the passthrough re-discovers locally and
- dead-ends for a session that exists only on the peer, and it marks the run
- `AGENTS_FLEET_REMOTE` — a one-shot command may carry that consent marker, but a
- resumed session would inherit it for its whole life and `agents browser start` inside
- it would be refused as a cross-machine drive.
-
- The signal is only as good as what wrote it: `machine` on a host-dispatched run is
- stamped by [`src/lib/hosts/session-index.ts`](src/lib/hosts/session-index.ts) from
- the dispatch host. Any new writer of an `agents run --device`-shaped row must set it,
- or the index will claim the dispatching box.
-
- ## Native helpers (downloaded on demand, never bundled)
-
- **No native binary ships inside this package's npm tarball** (RUSH-3100). Every helper
- is a signed + notarized GitHub release asset on its OWN tag, downloaded and verified on
- demand — see `src/lib/helper-versions.ts` for the per-helper version floors. That is what
- lets an ordinary CLI release be produced without a signing Mac.
-
- A third helper used to live in this table — the keychain broker
- (`Agents CLI.app`) behind the old in-repo secrets engine. It moved out of this
- repo entirely with the standalone `secrets` engine (PHNX-3989): the standalone
- downloads, verifies, and manages its own signed helper release now, off
- `helper-versions.ts` and off this CLI's tarball/release path completely.
-
- | Helper | Source | Ships in tarball? | Resolver |
- |---|---|---|---|
- | Menu-bar helper (AGI Menu) | [phnx-labs/agi-menu](https://github.com/phnx-labs/agi-menu) (own repo, PHNX-4036) — never built here; `scripts/stage-menubar-helper.sh` stages the published build at `bin/MenubarHelper.app` | **No** (RUSH-3100) — signed + notarized `MenubarHelper.app.zip` GitHub **release asset** on the helper's own `menubar/v<x.y.z>` tag, downloaded on demand | `src/lib/menubar/install-menubar.ts`, `src/lib/menubar/download-menubar.ts` (shared machinery in `src/lib/helper-download.ts`) |
- | Standalone CLI binary | `src/` → `bun build --compile` → `bin/agents-macos` | **No** — dropped from the tarball (RUSH-3026); macOS installs fall back to the JS entrypoint until it returns as a per-release GitHub asset | `scripts/postinstall.js` |
-
- The computer helpers (computer-mac, computer-win) are **no longer helpers of this
- CLI**. They left with the standalone `computer` engine (PHNX-4075), which resolves,
- downloads and verifies its own helper releases. agents-cli neither downloads nor
- version-pins them, and `src/lib/helper-versions.ts` deliberately has no entry for
- them — see [`docs/computer.md`](docs/computer.md).
-
- Path math: compiled resolvers run from `cli/dist/lib/…`. The menu-bar installer
- reaches the staged `cli/bin/MenubarHelper.app` in **3 hops** (`../../../bin/…`).
- Recompute depth if you move files — don't blind-replace. (There is no longer a
- repo-root `native/` tree to reach: it left with the computer engine, PHNX-4075.)
-
- ## Build, test, dev
-
- **Command reference:** `scripts/generate-reference.sh` writes the HTML tree plus
- JSON and Markdown indexes from the real Commander definitions via the shared
- `@phnx-labs/cli-docs` renderer. `--check` compares without writing; `--out-dir`
- creates a separate preview. The impact gate checks all three formats on command,
- help, generator, generated-output, and package/lock changes, including release
- version bumps. `release.sh` regenerates and stages all three before constructing
- the release commit. See [the reference workflow](docs/README.md#generated-command-reference).
-
- ```bash
- bun install && bun run build && bun test
- ```
-
- Tests are `*.test.ts` next to source; integration in `tests/`. Every PR to `main`
- runs the real suite cheaply on Linux — `test`
- ([`../../.github/workflows/tests.yml`](../../.github/workflows/tests.yml)) plus
- `gitleaks`; those two are the required checks. The full cross-platform matrix
- (ubuntu + macOS + Windows × Node 22/24, `ci.yml`) runs **nightly** plus manual
- `workflow_dispatch` — deliberately **off** the release path. It used to fire on
- `release/**` branches, but that was 16-53 min of macOS/Windows-billed work on
- every release while gating nothing (it is not a required check, and `release.sh`
- gates on the exact-tree attestation, never this matrix). Cross-platform
- regressions are caught on the nightly lane instead; a risky release can still run
- it on demand via `workflow_dispatch`. CI runs from `cli` via
- `defaults.run.working-directory`.
-
- **Browser `--device` e2e moved with the engine (PHNX-4101).** The live remote
- browser-drive suite (tunnel + remote launch/stop) left with the CDP/BiDi/Arc
- drivers into the standalone `@phnx-labs/browser-cli` repo, the same way the
- computer half of it moved to the `computer` engine's repo (PHNX-4075). agents-cli
- now tests only its consumer seam — `browser-client.ts` fd resolution/framing,
- the fd-3 context shape, and the verb
- catalog — none of which needs a live browser or a Windows runner.
-
- **Local dev build:** `scripts/install.sh --skip-tests` builds the working tree,
- installs it at `$HOME/.local/agents-cli-dev/`, and exposes it as
- `$HOME/.local/bin/agents-dev` (plus `ag-dev`). Drive it by name — `agents-dev
- sessions --active`. Version stamps as `0.0.0-dev.<sha>[-dirty]`.
-
- The production command is never created or overwritten: the script must not write
- `$HOME/.local/bin/{agents,ag,browser}`, and it deletes any such link an older
- revision of itself left pointing into the dev prefix (including a dangling one,
- which is what a cleaned dev prefix leaves behind). A dev build that answered to
- `agents` made PATH order decide which code ran — see the root
- [AGENTS.md](../../AGENTS.md) §Never install a dev build over the user's `agents`.
-
- The routines daemon is **shared** (scheduler, feed stream, session/usage sync,
- and more), so the install leaves it on production code. `--bounce-daemon`
- restarts it onto the dev build when you need that, and says plainly that it
- changes what the user's everyday `agents` talks to. (The secrets broker is a
- separate process the standalone `secrets` CLI owns, PHNX-3989, and the browser
- IPC service the standalone `browser` CLI owns, PHNX-4101 — this daemon hosts
- neither.)
-
- **Bin entrypoints need `chmod 755`.** [`scripts/build.sh`](scripts/build.sh) chmods
- every `package.json#bin` entry after `tsc` emits. Newer npm preserves tarball file
- mode and does NOT auto-chmod — 644 surfaces as `zsh: permission denied: agents`.
-
- The `files` allowlist in [`package.json`](package.json) is a **whitelist** — only
- `dist/**` (JS/d.ts/JSON/sh) and the postinstall scripts + README/LICENSE ship. No native
- binary is in it (RUSH-3100).
- Nothing from `apps/`, `native/`, or sibling `packages/` can leak into the tarball.
-
- ## Releasing
-
- **One trigger, one publisher.** Run the operator entry point from any checkout.
- It creates a detached worktree at fresh `origin/main`, folds the changelog, bumps
- the version, regenerates the command reference, and pushes the release branch:
-
- ```bash
- scripts/release.sh 1.23.0 # dry-run the metadata-only release commit
- scripts/release.sh 1.23.0 --apply # push release/1.23.0 and open its PR
- scripts/release.sh 1.23.0-pre.1 --apply
- ```
-
- The branch name is the version. [`.github/workflows/release.yml`](../.github/workflows/release.yml)
- accepts only `release/x.y.z` or `release/x.y.z-pre.n`; it has one GitHub-hosted
- job, no `pull_request` or `workflow_run` trigger, and a per-version concurrency
- group with cancellation disabled.
-
- | Work | Owner | Proof |
- |---|---|---|
- | Prepare version/changelog/PR | `scripts/release.sh` in a release-owned worktree | the caller checkout stays untouched; only release metadata enters the synthesized commit |
- | Re-bind tests and build | `scripts/release-ci.sh` on GitHub Actions | the newest compatible proof in the `main` parent's ancestry is inherited only when CLI executable inputs, the packaged session-tracker, and impact policy are unchanged before the metadata-only release diff; the producer still builds and packs the exact release tree |
- | Tag, GitHub release, install smoke, npm publish | the same Actions job | `v<version>` points at the pushed branch head; the release holds `release-attestation.json` plus the bound tarball; `release.sh --ci-publish` downloads, digest-verifies, installs, executes, and publishes those same bytes |
-
- **npm authentication is OIDC only.** Only the job protected by GitHub environment
- `npm-publish` grants `id-token: write`; that environment limits deployment branches to
- `release/**`; it has no required reviewer, so a release publishes without a manual
- approval (owner decision 2026-10-10: release latency R2 comes first). The workflow requires
- npm 11.5.1 or newer (installing 12.2 when the runner is older), and supplies no
- `NODE_AUTH_TOKEN`, `NPM_TOKEN`, repository secret, or local secrets bundle.
- `release.sh --ci-publish` refuses a live publish outside Actions, without the OIDC
- request environment, or with either token variable present. The one-time owner
- configuration is the npm package's Trusted Publisher record for canonical repository
- `phnx-labs/agi-cli`, workflow file `release.yml`, and environment `npm-publish` (the former
- `phnx-labs/agents-cli` URL redirects, but npm requires the repository to match
- `package.json#repository.url`).
-
- Stable branches publish on npm's `latest` tag. `-pre.n` branches publish with
- `--tag next`; the installed CLI's `latest`-based self-update therefore never
- adopts a pre-release.
-
- **The caller checkout is never mutated or gated.** `release.sh` immediately
- fetches origin and re-enters the release from a detached, release-owned worktree
- at fresh `origin/<default>`. Version bumps, changelog folding, release-branch
- construction, and PR creation happen there; GitHub Actions alone tags and publishes. The worktree
- is removed on every exit path, so a dirty shared `main`, an agent feature branch,
- or another branch already checking out `main` cannot block or contaminate a
- release. The isolated tree installs dependencies from its pinned lockfile; it
- does not borrow `node_modules` or staged files from the caller.
-
- **One releaser at a time — the lease.** Because the script runs from any box, two
- agents on two machines could enter it at once; they then clobber the same release
- branch, tag, and publish, and the collision only surfaces after one of them has
- already created irreversible release state.
- [`scripts/release-lease.sh`](scripts/release-lease.sh) holds exclusivity on
- `origin` — the only thing every box can agree on — by pushing an **orphan commit**
- to `refs/release-lock/held` with `--force-with-lease=<ref>:` (expected absent).
- That explicit compare-and-swap is load-bearing: custom refs allow ordinary
- non-fast-forward pushes, so orphan ancestry alone is not a mutex. Stale takeover
- replaces the inspected sha with the new lease in one expected-sha push. Every
- lease commit carries a per-invocation claim ID, so shared Git identity plus a
- same-second claim cannot make two commits identical and turn the loser into an
- "up to date" success.
-
- ```bash
- scripts/release-lease.sh status # unheld | held version=… holder=… age=…min holder-alive=yes|no|unknown
- scripts/release-lease.sh claim <v> # 0 = acquired, 1 = someone else is releasing
- scripts/release-lease.sh renew # prove this run is still alive
- scripts/release-lease.sh verify # 0 = still ours; fails CLOSED on any doubt
- scripts/release-lease.sh release # drop the lease this checkout claimed
- scripts/release-lease.sh clear # drop a lease with no live holder (any checkout)
- ```
-
- `release.sh` claims the lease after confirmation, immediately before its one
- mutation: pushing `release/<version>`. While it holds the lease, it re-reads npm
- `latest` and target presence, release tags and exact-shape `release/x.y.z[-pre.n]`
- branches, and canonical open PRs before the push. This second live guard prevents a
- different version that finished or started during local build preparation from
- publishing an older stable version onto `latest`; legacy `release/v*` branches do
- not trigger this workflow and are ignored. It drops the lease from its exit trap
- after the branch and PR exist. The Actions publisher does not share this process
- lease; GitHub's non-cancelling per-version concurrency group is the publication lock.
- Ownership is the lease **commit sha**, recorded in `.git/release-lease.token`, so
- a third agent can never drop a lease it did not claim.
-
- **An externally killed run is detected, not just waited out (RUSH-2274).** The TTL
- alone made a killed release indistinguishable from a healthy long one: for up to 30
- minutes `status` read `held` while nothing was releasing. So the lease also records
- **which process** holds it — `host`, `pid`, and that pid's start time — and
- `claim`/`clear`/`status` probe it, reporting `holder-alive=yes|no|unknown`:
-
- | Probe | When | What it licenses |
- |---|---|---|
- | `dead` | we are on the holder's box and that process is gone | reclaim **immediately**, no TTL wait |
- | `alive` | the recorded pid runs here with the recorded start time | **never** taken, at any age — stop that release instead |
- | `unknown` | the holder is another box, or the lease predates these fields | the TTL, exactly as before |
-
- `release.sh` exports `RELEASE_LEASE_HOLDER_PID=$$` so the recorded pid is the
- orchestrating release.
- A lease with no recorded pid stays `unknown`, so a missing export degrades to the
- old TTL behaviour rather than to "instantly reclaimable". The start time is what
- makes `dead` safe to act on: a recycled pid would otherwise read as a live release
- forever. A **zombie** counts as dead — a SIGKILLed release whose parent never
- reaped it is still listed by `ps`, which is precisely the case this detects.
-
- `scripts/release-lease.sh clear` drops such a lease without starting a release —
- the operator's unwedge path, since `release` only drops a lease *this checkout*
- claimed. It shares one predicate with `claim`, so it can never take a live holder's
- lease either.
-
- **Finish a stuck release before cutting a new one.** `release.sh` refuses a later
- stable version while an older `v*` tag is missing from npm. Re-running the matching
- release through `scripts/release.sh <version> --apply` reruns the existing workflow
- at the immutable tagged commit: the tag and release branch must still point at that
- same SHA, and a different target fails closed before either ref moves.
-
- **A stuck EARLIER bump branch or PR blocks the changelog fold, not just a stuck tag
- (PHNX-3084).** The stuck-*tag* guard above is registry-vs-tag; this is its
- branch/PR-side twin. A bump PR that remains open leaves that version's
- `.changelog/next/*` fragments still queued on `main` — the drain only landed
- inside the unmerged branch commit. A *later* version releasing then re-reads those
- fragments and folds an earlier version's notes under the new version. The
- same-target retry uses `release/<current-target>`, so it never sees another
- version's PR. Before folding and again under the lease, `release.sh` detects any
- other canonical release PR; the locked guard also detects an exact-shape release
- branch before its PR opens or after that PR closes. The PR classifier
- (`scripts/release-other-bump-prs.sh`) and the live state guard are executable-tested;
- they **fail loud** with the blocking refs rather than silently
- re-attributing the earlier version's release notes.
-
- **Tests are inherited only across a byte-identical CLI seam.** `attest-main.yml`
- runs the suite for CLI-affecting `main` trees and publishes each proof on
- `main-attestations`. Because that workflow intentionally ignores unrelated
- monorepo-only commits, the branch publisher searches the full ancestry (there is no
- arbitrary commit-count cutoff) and selects the newest attested ancestor
- whose `cli/**` executable inputs, `packages/session-tracker/**`, and
- `scripts/ci-scope.ts` policy still match the release
- commit's parent. It keeps the release branch on fresh `origin/main`, then asks
- `release-attestation-produce.sh --inherit-suite-from` to re-bind that proof. The
- publisher first rejects every change from the selected attested commit through the
- release head that falls outside `cli/**`, `apps/cli/**`,
- `packages/session-tracker/**`, or `scripts/ci-scope.ts`; this includes `.github/**`
- and the publishing workflow itself. The derive command then fails closed if any CLI
- source, dependency, packaged session-tracker, policy, or other non-release metadata
- differs. When there is no byte-identical proof—for
- example, session-tracker or impact policy changed without triggering `attest-main`—
- the publisher selects the newest retained attested ancestor and runs the current
- impact plan across the complete diff with `--test-impact-from` before minting the
- exact release-tree proof. A missing producer never becomes an operator errand. The release PR
- still runs the ordinary required Tests check;
- that check is evidence for merging the bookkeeping PR, while the attestation is
- the evidence npm publication consumes. Cross-platform coverage remains nightly.
-
- **Release attestations package the complete CLI build.** The producer calls
- `scripts/build.sh --clean --skip-tests` after its suite gate. This includes the
- session-tracker installer and hook under `dist/session-tracker/dist/`; plain
- `bun run build` only compiles the CLI and omits those installed resources.
- Account resource projection also invokes that installer for each account home,
- so both slot creation and later sync replace registrations from older CLI installs.
- Both tracker bridges refresh Codex hook trust after registration using the same
- config writer as ordinary hooks; invalid TOML is reported and preserved.
-
- **The attestation producer shards by default.** `release-attestation-produce.sh`
- (the suite run that mints the attestation) now fans the ~13k-test suite across the
- fleet via `test.sh --shard N` instead of pinning one box — the suite is
- throughput-bound, so this is ~1/N the wall time (~269s on one box → ~31s on nine).
- N is resolved from the eligible workers `agents devices pick` reports, capped, and
- falls back to a single auto-picked box when fewer than two are eligible (`test.sh
- --shard` has no silent fallback, so the count is resolved before the call rather
- than demanded of a possibly-thin fleet). Each shard still runs vitest at
- `--maxWorkers=2 --retry=2`, so the RUSH-3015 per-box flake mitigation is unchanged —
- sharding adds machines, not per-box concurrency. Override with `--test-shard <n>` /
- `--test-devices a,b,c`, or pin one box with `--test-device <box>` / `--test-here`.
-
- **A release-tree attestation can inherit the suite instead of re-running it
- (PHNX-3237).** A release runs the full suite twice — once for the default-branch
- tree, once for the `chore(release)` commit tree — even though the second differs
- from the first only by the version bump, the folded changelog, and the
- regenerated command-index. `release-attestation-produce.sh --inherit-suite-from
- <base-attestation.json>` mints the release-tree record from an already-green base
- **without re-running the suite**: it still runs `scripts/build.sh --clean --skip-tests`
- and `npm pack` (so the
- recorded tarball is the real release tree's, carrying the new version), but the
- expensive suite run is inherited. The soundness gate is
- `release-attestation.sh derive` — it fails **closed** unless the tree diff between
- base and release touches only `package.json`, `.changelog/**`, `CHANGELOG.md`,
- `docs/command-index.{md,json}`, and `docs/command-reference.html` within `cli/**`
- (the exact set `release.sh` stages). `release-ci.sh` separately rejects every path
- outside the CLI release-input allowlist between the selected attested commit and
- the release head, so an untested workflow or unrelated repository change cannot
- ride a stale pass. The selector also requires the impact policy to remain
- byte-identical before suite inheritance, so a CLI code change cannot ride one either.
- The derived record inherits the base's
- lockfile/policy/toolchain/suite identity, which the allowlist proves are byte-
- identical to the release tree's, so `release.sh`'s `require()` still keys to it
- exactly. Inherit mode is incompatible with any `--test-*` flag (there is no suite
- to route). **`release-ci.sh` is the producer and consumer in one job**: it fetches
- the newest compatible proof in the parent ancestry and invokes inherit mode, or
- runs bounded impacted checks from the newest retained proof when relevant inputs
- changed. It stages the resulting attestation and
- tarball on `v<version>`, then calls `release.sh --ci-publish` to download and
- promote them. No retained proof, a failing impact plan, a missing tarball, or a
- digest mismatch ends the run before npm.
-
- **Idempotent re-runs preserve the tagged commit.** Re-run the canonical operator
- command for the same unpublished version. The release branch becomes immutable on
- its first push: before a tag exists, the command resumes the workflow for that exact
- branch SHA; after tagging, it also verifies that the branch still names the peeled
- tag commit. It asks GitHub over REST to rerun that exact SHA instead of synthesizing
- or force-pushing a new commit.
- The workflow replaces release assets with the newly verified proof and retries npm.
- It also rechecks that remote `release/<version>` still equals the event commit before
- tag creation and again immediately before npm, so a later same-version push cannot
- publish a superseded checkout.
- If npm already exposes the immutable version, its registry `dist.integrity` must equal
- the attested tarball's sha512 SRI before the run reports success.
-
- **`scripts/remote-sign-mac.sh` is not on the release path.** The script remains only
- for the narrow case of producing + pulling back JUST the macOS artifacts from
- another Mac (no publish): it signs the standalone CLI binary there and **stages the
- published menu-bar helper** (`scripts/stage-menubar-helper.sh`, no build — see
- below); its `--device <name>` selects the signing Mac independently of the npm
- release workflow.
-
- **Provisioning the `apple.com` bundle on a headless sign host.** A Linux-driven
- release offloads macOS signing to a sign host over SSH, which needs the `apple.com`
- secrets bundle *on that host*. Push it with the **file backend** —
- `agents secrets export apple.com --device <signer> --remote-backend file` (**no
- passphrase required** — the remote keys it under a machine-local key it
- auto-provisions and reads it headlessly; `AGENTS_SECRETS_PASSPHRASE` is never
- forwarded) — **not** the default
- keychain backend: a
- macOS login keychain is locked under headless SSH, so a keychain-backed push lands
- the bundle metadata but no readable secret items (`secrets export --device` now
- read-back-verifies a keychain push and fails loudly if it didn't persist, pointing
- at this fix). `--device` / `-D` is the fleet routing flag (legacy `--host` is stripped but not registered) on the secrets remote
- commands. See [`docs/secrets.md`](docs/secrets.md) → *Pushing to a headless sign host*.
-
- **Why the tarball no longer needs a Mac (RUSH-3100).** It used to bundle
- `dist/lib/secrets/Agents CLI.app` — a `swiftc`-compiled keychain helper, Developer-ID
- codesigned and notarized — and `prepack` (`scripts/verify-keychain-helper.sh`,
- since deleted with the engine — PHNX-3989) refused to pack unless that signed
- binary matched a pinned sha. Since CI runners are Linux and cannot produce it,
- **that gate, not the code, is what chained an ordinary release to a signing
- Mac.** The bundle is gone from the tarball, and the keychain helper itself
- moved out of this repo entirely with the standalone `secrets` engine — it is
- no longer built, signed, or verified anywhere here.
- [`scripts/verify-menubar-helper.sh`](scripts/verify-menubar-helper.sh) remains
- the gate for a staged `bin/MenubarHelper.app` (`stage-menubar-helper.sh` runs it
- after extraction); it never blocked the CLI tarball. A helper is re-recorded only
- when its own input changes; the input digest in
- [`scripts/release-manifest.sh`](scripts/release-manifest.sh) is what decides that.
-
- **The helper manifest covers `menubar` and nothing else.** `computer-mac` used to be
- recorded here from its published release (PHNX-2943), with a source-digest sidecar
- proving the published binary was built from this tree's Swift source. That whole arm
- is gone: the computer helpers left the repo with the standalone `computer` engine
- (PHNX-4075), so there is no source here to digest and no helper for this CLI's
- release to attest. `KNOWN_HELPERS` in
- [`scripts/release-manifest.sh`](scripts/release-manifest.sh) is the list — adding a
- helper back means adding its input-digest arm in the same change.
-
- **Menu-bar helper (AGI Menu) — consumed from its published release, never built
- here (PHNX-4036).** The helper's source moved to
- [phnx-labs/agi-menu](https://github.com/phnx-labs/agi-menu) (private,
- history-preserving split of the old `cli/menubar/`), the same shape as the AGI EXT
- split (RUSH-3189). The cross-repo contract is short and lives in
- [`docs/menubar.md`](docs/menubar.md):
-
- - **Asset + address.** agi-menu's `scripts/release.sh <x.y.z>` builds, Developer-ID
- signs, notarizes + staples the bundle and uploads `MenubarHelper.app.zip`,
- `MenubarHelper.app.zip.sha256`, and a `menubar-source.txt` provenance sidecar
- (`repo=`/`commit=`/`tag=`/`version=`) to the release tag **`menubar/v<x.y.z>`
- on THIS public repo** — exactly the URL `src/lib/helper-download.ts`
- (`HELPER_RELEASE_REPO`) + `src/lib/menubar/download-menubar.ts` resolve. That
- address, the bundle folder name `MenubarHelper.app`, the executable name
- `"AGI Menu"`, the bundle id `com.phnx-labs.agents-menubar`, and the Team
- `2HTP252L87` are the contract; changing any of them on either side breaks
- every installed CLI or revokes every user's Accessibility grant.
- - **The floor is the pin.** `menubar` in
- [`src/lib/helper-versions.ts`](src/lib/helper-versions.ts) names the build this
- CLI was tested against; bump it after agi-menu publishes. It is also the
- helper's manifest **input** (`release-manifest.sh` hashes that file for
- `menubar`), so a floor bump is what makes `release-attestation-produce.sh
- --with-helpers` re-record the row — from the published asset, sha256-verified,
- with the sidecar's provenance as `source` — and a missing or corrupt release
- fails closed naming the agi-menu publish step. Nothing rebuilds.
- - **Staging.** [`scripts/stage-menubar-helper.sh`](scripts/stage-menubar-helper.sh)
- downloads `menubar/v<floor>`, verifies the sha256, and on macOS extracts it to
- `bin/MenubarHelper.app` behind `codesign --verify --deep --strict`,
- `spctl --assess --type execute`, and
- [`scripts/verify-menubar-helper.sh`](scripts/verify-menubar-helper.sh)
- (**designated-requirement pin** + universal binary + stapled ticket).
- `--fetch-only` downloads + sha-verifies on any OS (what the producer uses);
- `--json` reports `{floor, tag, assetUrl, zip, sha256, source, app}`.
- `remote-sign-mac.sh` runs it on its selected signing Mac. `bin/MenubarHelper.app` is the
- installer's working-tree source (`sourceAppPath()` in `install-menubar.ts`);
- an agi-menu developer copies a local build there to test it with this CLI.
- - **The DR pin is what keeps the Accessibility grant alive across upgrades.**
- macOS re-validates each new version against the requirement stored with the
- grant (`identifier "com.phnx-labs.agents-menubar" … certificate
- leaf[subject.OU] = "2HTP252L87"`), not the CDHash — so every re-signed release
- still satisfies it. The gate hard-fails a bundle whose DR drops the pinned
- bundle id or Team ID (a wrong/absent team, an ad-hoc signature, a CDHash-pinned
- DR), because that would silently revoke every user's grant and re-prompt them
- on the next paste. Gatekeeper on macOS 26+ rejects an un-notarized `.app` as
- "damaged", so the launch guards verify Gatekeeper acceptance and fail loud
- (RUSH-2134) — there is no per-machine re-sign.
- - **What stays here** is the CLI side only: install/heal/status/setup
- (`src/lib/menubar/install-menubar.ts`), download + verification
- (`download-menubar.ts`, `helper-download.ts`), the read-only snapshot the helper
- polls (`agents menubar snapshot --json`, `src/lib/menubar/snapshot.ts`), and
- desktop notification delivery (`notify-desktop.ts`). Helper behavior — the
- single-instance `flock`, the bounded `ChildProcess` spawner, the self-tests
- that gate its build — is documented in agi-menu's `docs/menubar.md`.
-
- **Exactly one status item is an invariant, enforced in the helper.** The bundle
- can be started from more than one place — launchd's `KeepAlive` service, a
- LaunchServices/`open` launch, a second startup install — so the helper
- takes an `flock` on `~/.agents/.cache/state/menubar.lock` at launch (agi-menu's
- `SingleInstance.swift`) and holds the descriptor for its lifetime; a loser
- surfaces the incumbent's menu and exits 0. Do NOT re-derive liveness from a pid
- file or a `ps` scan — the kernel releases an `flock` however the holder dies,
- which a pid cannot express, and a process list cannot say which copy launchd will
- keep alive. On the CLI side, `classifyMenubarProcesses` returns live copies of
- the installed bundle as a LIST (`own`), never a boolean: collapsing them is what
- let a duplicate icon read as a healthy `running: yes`. `agents doctor` reports
- every copy past the first as a `menubar` warning with the `kill <pid>` that
- leaves launchd's copy.
-
- **Only the install that OWNS the helper may reinstall it.** The startup self-heal
- (`installMenubarLaunchAgentOnUpgrade`, every darwin invocation) reinstalls when the
- version stamp drifted or the plist's baked entry names another install. Both of
- those record *whichever copy acted last*, so on a box with several agents-cli
- copies each one read the others' marks as drift and recopied the bundle over them —
- and recopying replaces the executable under the running helper, killing it, after
- which `KeepAlive` restarts it and the next copy repeats it. Observed: a new pid
- every 5-15s, 578 launches in one helper log, a status item that never stayed
- visible, and the status read still saying `running: yes` because a pid
- always existed (#2109). `mayInstallMenubarHelper` gates it: the plist's
- `AGENTS_ENTRY` names the owner, and only the owner reinstalls freely. A same-install
- upgrade keeps its entry path, so `npm update` still lands normally.
-
- Four escapes keep the gate from becoming a **stuck state**, which is how the first
- version of it regressed: (1) **repairs are never gated** — a missing helper
- executable or a Developer-ID heal proceeds from any install, since a bundle that
- isn't there cannot be contested and blocking it leaves the menu bar dead with no
- automatic recovery; (2) a non-owner takes over once the recorded owner is **gone
- from disk** — but, like escape (3), only a **Developer-ID** source may seize a
- *healthy* helper this way (an ad-hoc/dev build is refused, see caveat (a)); (3)
- otherwise a non-owner may still take over **once per
- `MENUBAR_TAKEOVER_COOLDOWN_MS`** (1h, stamped in `.menubar-last-heal`). Without (3)
- a stale-but-present copy — an old nvm node dir nobody runs — owns the plist forever
- while the user's actual daily driver upgrades and never heals again. The cooldown
- turns an every-invocation storm into at most one restart per hour while leaving
- every install able to make progress. (4) **A newer Developer-ID release upgrades
- from any install** (`updateMenubarHelperIfNewer`): the update downloads and verifies
- the release first, then gates on THAT bundle (`menubarGateVersion`), and a newer
- signed build passes the version branch regardless of the plist owner. It converges:
- once the helper is on the newest build, every install reads "current". Before this,
- the update gated on the cached bundle for a release not yet downloaded, read "not
- Developer ID", and refused every new release (R5).
-
- Two caveats worth knowing before you tune any of this. **(a)** Escapes (2) AND (3)
- are both refused to a source bundle that is not Developer-ID signed:
- `scripts/install.sh` puts an ad-hoc dev build beside the npm global, and letting it
- recopy an un-notarized bundle over a good one both makes Gatekeeper reject the
- result as "damaged" (RUSH-2134) AND poisons the shipped helper's Accessibility
- grant — an ad-hoc signature carrying the production bundle id fails the code
- requirement macOS stored with the grant, so it revokes the grant and re-prompts on
- the next paste. This is why a dev build ALSO signs under a distinct
- `com.phnx-labs.agents-menubar.dev` id (agi-menu's `scripts/build.sh`), so even a
- running dev helper registers its own TCC entry rather than the production one.
- Refusing an ad-hoc healthy-helper takeover strands nothing: escape (1) still heals
- a genuinely broken (missing / ad-hoc-installed) helper from any source, which is
- the only real deadlock. **(b)** The cooldown bounds the loop but does not converge it: two
- installs that are *both* invoked regularly trade ownership every cooldown, so the
- helper restarts roughly hourly until one is removed. That is deliberate — the
- alternative is stranding one of them — and the real fix is a single install
- (#2147 expanded the multi-install banner beyond `PATH` to NVM, fnm, Volta, Bun,
- common npm prefixes, and the npm `_npx` cache). The banner also checks each
- copy for `dist/lib/app-bundle-install.js`; a copy without it is labelled an
- unsafe legacy helper installer and must be removed, because current code cannot
- make an older executable use the atomic installer it predates.
-
- **Do NOT "improve" this by comparing bundle content.** It looks like the obvious
- gate and it does not work: the helper is rebuilt, re-signed and re-notarized on
- every helper release (agi-menu's `scripts/release.sh`), so consecutive releases
- ship byte-different bundles from identical Swift source. Measured on
- 1.22.20/21/22 — same 2876288-byte executable, three different sha256s, and three
- different **CDHashes** (so stripping the CMS/timestamp blob doesn't rescue it
- either). Any digest gate reports "changed" for precisely the skew case it was
- meant to exempt. Ownership is the only signal here that is stable across
- independently-signed builds. Related: the secrets broker hit the same
- multi-install failure and answered it differently, by keeping a *hot* broker alive
- across version skew (`shouldTeardownVersionSkewedBroker`, the standalone `secrets` engine's own agent.ts;
- #435, PR #909) — same disease, and a third `KeepAlive` helper will need one of
- these two answers rather than a fresh rediscovery.
-
- **Standalone `agents` binary (#315) — no longer in the tarball (RUSH-3026).**
- The signed arm64 Mach-O (`bun build --compile` → Developer ID + hardened runtime +
- the JIT entitlement in `scripts/bun-jit-entitlements.plist`, notarized via
- [`scripts/sign-cli-binary.sh`](scripts/sign-cli-binary.sh)) embeds the release
- version, so shipping it inside the npm tarball forced a Mac rebuild + sign on
- **every** release — the single artifact that chained publishing to a provisioned
- Mac even when nothing native changed. It is dropped from `package.json` `files`
- and from the `prepack` gates; `postinstall`'s existing run-probe falls back to
- the JS entrypoint when `dist/bin/agents` is absent, so macOS installs keep
- working (the pre-#315 status quo, at the cost of the EDR mitigation until the
- binary returns as a per-release GitHub asset — tracked in RUSH-3026).
- [`scripts/verify-cli-binary.sh`](scripts/verify-cli-binary.sh) and the signing
- machinery are retained for that asset path; a Mac producer still builds + signs
- the binary locally, it just no longer ships in the tarball.
-
- **The `@swarmify/agents-cli` shim is frozen at 1.19.x — do NOT "catch it up."** It's a
- legacy re-export not published since v1.20.0; `release.sh` publishes only `@phnx-labs`.
- Bumping it would un-deprecate a retired package.
-
- ## Conventions
-
- - Real services only — no mocking. Tests exercise the actual critical path.
- - `agents repo push` / `pull` operates on `~/.agents/` only. System updates ride
- `npm update -g @phnx-labs/agents-cli`.
- - No sensitive data in any DotAgents repo — use `agents secrets` (Keychain-backed).
-
- ## Contracts (source-of-truth spec — read before touching sessions/secrets)
-
- The major subsystems carry a **normative contract** in
- [`docs/specifications.md`](docs/specifications.md) — what a human, an agent, or a
- downstream tool may rely on, written because features have regressed by quietly
- deviating from an unwritten contract. When code and the spec disagree, one is a
- bug; fix the drift. It uses RFC-2119 MUST/SHOULD language, cites the implementing
- `file:line`, and carries Given/When/Then scenarios that map to tests. Sections:
-
- - **[`docs/specifications.md` §Sessions](docs/specifications.md#sessions)** — the `agents sessions`
- contract. Load-bearing invariants: discovery MUST parse **every** harness in
- `SESSION_AGENTS` (all 12) and a malformed line MUST be skipped, never thrown
- (SES-1, SES-3); every list row MUST show a **non-empty preview** — live turn →
- `label` → first-prompt `topic` → `'-'` (SES-8; `--flat` and the interactive
- picker share the one unguarded renderer, SES-GAP-1); "where a session started"
- spans three fields (`cwd` + `provenance` + `context`), not one `origin`
- (SES-13); the `--json` shapes and `SessionEvent` union are a stability contract
- (SES-IF-1, SES-IF-4); tool-call evidence is always redacted/bounded, repeated
- clauses match distinct calls, tool queries never parse transcripts, and exact
- static program counts retain repeated sites with wrapper/effective roles;
- versioned tool envelopes do not replace the list/detail JSON contracts
- (SES-31..SES-37, SES-IF-4a); `agents sessions insights` emits aggregate-only
- actions and keeps `agents insights` as its top-level alias (SES-IF-4c); `agents insights resources` (older spelling `agents sessions stats`) emits its own versioned
- `sessions-stats` rollup of skill/command usage and never the list/detail shape
- (SES-IF-4b); `agents sessions
- export --encrypt` seals every transcript
- body client-side with AES-256-GCM under the shared `r2.backups` bundle key, or
- an ephemeral one when unconfigured (SES-24, SES-25); the off-box backup target
- (`export --to-r2` / `import --from-r2`) is **managed-first** — a signed-in user
- backs up to the managed `sessions.agents-cli.sh` Worker with **no `r2.backups`
- bucket to set up**, every body sealed under a mandatory per-account escrowed DEK
- (never plaintext), while `--byo` keeps the zero-knowledge own-bucket path
- (SES-50, SES-51, SES-52).
- - **[`docs/specifications.md` §Secrets](docs/specifications.md#secrets)** — the `agents secrets`
- contract. Load-bearing invariants: **inject into the child, never materialize
- to the agent** — every command is on one side of the boundary by construction
- (SEC-6, SEC-7); the master passphrase MUST be stripped from the child env
- (SEC-8); the "no-noise" rules — silent value-free `list`, batched single-prompt
- reads, silent broker miss, no `console.*` in the lib layer, no shell-rc
- pollution (SEC-11..SEC-17); all three desktop platforms are supported and the
- parity matrix names where guarantees are weaker (SEC-CROSS-1, SEC-CROSS-3).
-
- Requirement ids are section-namespaced — `SES-*` / `SEC-*` / `EXEC-*`, with the
- `-IF-` (interface), `-CROSS-` (platform parity), `-COMPAT-` (stability) and
- `-GAP-` (known gap) families — and a requirement the code does not yet fully meet
- carries a trailing `Status: [Intended]` or `[Drift]` line naming its `-GAP-`.
-
- Beyond the two above, the document also specifies **§Agent execution**,
- **§Scheduling & execution singularity**, and **§Watchdog**. It does **not** cover
- every command group — `hosts`, `teams`, and `cloud` have design docs but zero
- RFC-2119 requirements, and surfaces like `wallet` and `sync`/`apply`
+ [README.md](../README.md) (pin versions, run, sessions, devices, teams, workflows,
+ plugins, browser, secrets, routines). This file covers the design choices,
+ module map, build, and release mechanics the README does not.
+
+ > Phoenix Labs · FSL-1.1-Apache-2.0. Repo-wide policy (conventions, code review, security)
+ > lives in the root [AGENTS.md](../AGENTS.md).
+
+ `agents setup` is the re-runnable onboarding hub. It reports live readiness for
+ core, browser, computer, secrets, term, accounts, fleet, watchdog, and preferences, then
+ delegates each selected phase to its existing `agents setup <capability>` wizard.
+ `agents setup status --json` is the non-interactive view of the same probes.
+ `agents setup tools` installs or upgrades the standalone `sessions`, `browser`,
+ `secrets`, `computer` and `term` CLIs to the releases pinned in
+ [`src/lib/standalone-tools.ts`](src/lib/standalone-tools.ts); see
+ [`docs/setup.md`](docs/setup.md#pinned-standalone-releases). The
+ `term` phase installs the standalone `term` CLI (`@phnx-labs/term-cli`, the PTY
+ engine the setup-token mint behind `agents accounts add`/`login` spawns on demand, PHNX-4091);
+ presence on PATH is its whole readiness signal — there is nothing else to configure.
+
+ Personal operating reminders live in
+ `~/.agents/reminders/reminders.yaml` (each a `short`/`full` pair; there is no
+ command for them, PHNX-4267 retired `agents reminders`). They surface
+ succinctly in the Claude statusline — one per session, chosen deterministically
+ from the session id (`pickReminderForSession` in
+ [`src/lib/reminders.ts`](src/lib/reminders.ts)) so concurrent agents each show a
+ different one and it stays stable within a session. Presence of the file with at
+ least one entry is the opt-in; there is no flag. The render is Claude-only
+ ([`renderClaudeStatusLine`](src/lib/claude-statusline.ts) appends the dimmed part
+ after the usage windows) because the statusline is a Claude Code surface; other
+ harnesses expose no equivalent always-visible line. The full line reads
+ `host · account · model · [delegate] · 5h n% · 7d n% · ◆ reminder`: the account is
+ the registered account NAME when the running login has one (`work`, `dev` — the
+ `agents accounts` row for that identity, via `findNativeAccountByIdentity`), else the
+ email it is signed into (plus the org name for a Team/Enterprise seat, via
+ `accountDisplayLabel`). The identity is read once per render (`readClaudeIdentity`,
+ shared with the usage ingest) from the `.claude.json` Claude is actually running with —
+ `$CLAUDE_CONFIG_DIR`'s home under the shim, else `$HOME` — so the 5h/7d figures next
+ to it are attributed at a glance. A malformed file is swallowed
+ by the statusline (a broken prompt is worse than a missing line). The file syncs
+ across the fleet via `agents repo push/pull`.
+
+ Artifact sharing is no longer part of agents-cli. The `agents artifacts`
+ command group, the managed share Worker, and the `lib/share/` engine were removed
+ (PHNX-3992); publishing an HTML artifact now lives entirely in the standalone
+ `artifacts` CLI (`@phnx-labs/artifacts-cli`), driven as `artifacts share …` /
+ `artifacts auth login`, mirroring the secrets (PHNX-3989) and computer (PHNX-4075)
+ extractions. The standalone `sessions share <id>` publishes a transcript the same
+ way.
+
+ `agents browser`, `agents computer`, `agents secrets` and `agents sessions` were
+ removed (PHNX-4227) with no alias or forwarder: each exits 1 with one line naming
+ the standalone CLI, or the agents home of a moved `sessions` verb
+ (`removedToolGroupMessage`, [`src/lib/startup/command-registry.ts`](src/lib/startup/command-registry.ts)).
+ The agents-owned verbs moved: live roster and lifecycle to `agents ps`
+ (`stop`/`focus`/`detach`/`migrate`/`preview`/`watch`), resume to `agents run auto
+ --resume <id>`, inject to `agents send --channel session`, fork to `agents fork`,
+ index maintenance to `agents daemon index`, usage stats to `agents insights
+ resources`, and the operator backup provisioning to `agents setup session-backup`.
+
+ `agents feed watch --json` is the canonical thin-client operator stream: it
+ composes the existing session watcher with feed attention, activity, and **tool
+ rows** (browser tasks + computer runs), while
+ `agents ps watch --json` is the session-only stream (it was `agents sessions
+ watch` before PHNX-4227).
+ Answers go through `agents feed answer <attention-key>` so the CLI atomically
+ claims the first reply and routes it over the recorded session reply rail.
+
+ **It is designed to run for as long as a VS Code window is open, so every tick is
+ budgeted (PHNX-3939).** AGI EXT's elected leader spawns exactly ONE `feed watch
+ --json` child and fans it out to every window, so a per-tick cost here is paid for
+ the whole session, not per render. Three rules keep it flat and truthful, and a
+ change that breaks any of them is a regression, not a detail:
+
+ - **Activity is read incrementally, never re-scanned.**
+ [`ActivityStream`](src/lib/feed/activity-stream.ts) holds a per-file cursor
+ (inode, offset, mtime, ctime, a trailing partial line, and the bytes behind the
+ cursor — ctime is what catches a same-length in-place rewrite, exactly as
+ `activityStamp` keys the one-shot reader's cache)
+ and reads only what was appended since the last tick, driven by an `fs.watch` on
+ the activity dir with a 5 s stat sweep as the fallback. The opening scan opens
+ no files at all — every log is registered past its own bytes, so history is
+ never replayed. Do **not** put `readRecentActivity` back on the tick: it tails
+ and parses every session log in the directory, which on a real operator box is
+ 1,437 files / 64 MB every 500 ms (measured 41.9% of a core over a 5-minute
+ steady state; the cursor reader is 0.31%). `readRecentActivity` remains right for a one-shot `agents feed` render.
+ - **Attention is reconciled on change, not on the tick.** A reconcile pass reads
+ each row's block and resolution and its PR status, so running it twice a second
+ was two file reads per row per tick for a value that almost never moved. It runs
+ when something announced a change — the feed dir and `feed/resolutions` are
+ watched directly, which is how an external `agents feed post --blocked` still
+ raises promptly — or when `PR_STATUS_TTL_MS` (45 s) has expired and the cached
+ PR verdicts are stale. The only other time-dependent verdict in
+ `reconcileAttention` is the 30-minute trust window on a permission block the
+ session offers no transcript cursor to verify (`UNVERIFIED_PROMPT_AGE_MS`,
+ PHNX-3999), and that 45 s cadence is what re-evaluates it — the reconciler
+ takes `nowMs` as an input and owns no clock of its own.
+ - **A request is classified from explicit evidence, never from a hook having
+ fired or from elapsed time (PHNX-3999, spec SES-40f).** Claude's `idle_prompt`
+ is a finished turn, not a request: the feed-publish hook does not publish it,
+ and the reconciler yields nothing for one an older hook left on disk. A
+ `permission` needs a recorded `permission_prompt` subtype that the transcript
+ corroborates via `ActiveSession.lastEventMs` (the harness stamp on the last
+ meaningful event — the file mtime moves on every hook firing, so it cannot be
+ the cursor). The state engine never infers `permission` from a quiet pending
+ tool call, and `computeLiveSignals` memoizes the parsed tail on mtime but
+ re-runs `inferSessionState` against the current clock every call, so the
+ 30-minute prose-question decay expires on schedule. Anything the CLI cannot
+ confirm is `unverified`: no choices, an open-session action only, refused by
+ `feed answer`.
+
+ The fleet fan-out has its own budget: both `watchFleetFeed` and
+ `watchFleetSessions` subscribe to peers through
+ [`streamFromPeer`](src/lib/session/remote/peer-stream.ts), which backs off per peer
+ (2 s doubling to a 60 s cap, reset on a healthy protocol event), captures the
+ child's stderr into the `unavailable` reason instead of discarding it, and parks a
+ peer after three consecutive failed spawns until the device registry changes.
+ `isDialableDevice` deliberately never excludes an unreachable box
+ ([`devices/registry.ts`](src/lib/devices/registry.ts)), so backoff — not the
+ dialable set — is what keeps an offline peer from minting an ssh child every few
+ seconds. A peer that fails **ten** in a row is RETIRED onto a 15-minute re-dial
+ (`PEER_RETIRE_AFTER_FAILURES` / `PEER_RETIRED_RECHECK_MS`, PHNX-3999): the cap
+ bounds the delay but not the total work, and a box off for a weekend was otherwise
+ dialed ~2,880 times per watcher. It still wakes immediately on a registry change,
+ so a returning box is never stranded. Add a third fan-out and it uses this helper;
+ do not re-roll the loop.
+
+ ### One shared fleet collector, N readers (PHNX-3999)
+
+ `watchFleetFeed` is correct per peer and was wrong per CALLER: it opened that ssh
+ child **for every consumer**, so the extension's leader child, the menu-bar helper
+ and an operator's `agents feed watch --json` each held their own fan-out — three
+ readers on a thirteen-device fleet is thirty-nine ssh children carrying
+ byte-identical NDJSON, three activity cursors, and three independent backoff
+ ladders re-dialing the same offline box.
+
+ [`FeedHub`](src/lib/feed/hub.ts) owns exactly ONE `watchFleetFeed` and broadcasts
+ to every subscriber; [`FeedHubServer`](src/lib/feed/hub-server.ts) exposes it over
+ a unix socket (named pipe on Windows) writing the SAME NDJSON envelopes, and the
+ daemon's `feed-stream` service owns it — the fan-out dials peers, so by the
+ one-scheduler/one-executor rule it cannot live in a UI surface. Four properties
+ are load-bearing:
+
+ - **Demand-gated.** The fan-out starts on the FIRST subscriber and stops on the
+ LAST, so a box with no reader open holds no peer connections at all.
+ - **A late reader costs nothing.** The hub keeps the per-scope rows the stream has
+ delivered and synthesizes a reset per scope out of that state — no second
+ fan-out, no re-dial, no waiting for peers to re-announce. Each subscriber's
+ `streamId`/`sequence` are its own and start at 1, which is exactly what the
+ published "order by streamId + sequence" contract allows.
+ - **TWO collectors, one socket, and the scope is mandatory.** The `feed-stream`
+ service hosts the fleet hub (`watchFleetFeed`) AND the shared local hub
+ (`sharedLocalFeedHub()` in [`feed/watch.ts`](src/lib/feed/watch.ts), which wraps
+ `watchLocalFeed` and dials no peer), so a local-only reader can never start a
+ fan-out. A client selects one with a required handshake line
+ (`{"v":1,"scope":"fleet"|"local"}`); a missing, unparseable, unknown, or LATE
+ scope is **rejected with a reason**, never defaulted — guessing `fleet` started
+ ssh children to every peer for a client that never asked for them.
+ - **No client-side fallback.** `feed watch --json` (both scopes) attaches to the
+ hub and, if the daemon is down, starts it and WAITS for the bind (`waitForHub`)
+ before retrying; it never runs its own collector, because that restores the
+ per-caller fan-out this replaced. `--local` no longer runs `watchLocalFeed`
+ in-process: each observing box's ssh subscription used to build a separate
+ activity cursor set, tool watcher and setup subscription on the same peer.
+ - **A large snapshot reaches a healthy reader whole; a stalled reader cannot
+ grow the daemon.** Each connection has one ordered writer (`ReaderWriter` in
+ [`feed/hub-server.ts`](src/lib/feed/hub-server.ts)): every line — the catch-up
+ snapshot, live events, the error envelope before a refusal — goes through its
+ queue, is written in `HUB_WRITE_CHUNK_BYTES` chunks, and waits for `'drain'`
+ after every write the socket did not accept, so the socket never holds more
+ than one chunk past its high-water mark; `pendingBytes` (queued lines, the
+ unwritten rest of the frame in flight, the socket buffer) is a gauge of
+ unflushed bytes, not of retained heap — a frame's Buffer lives until its last
+ chunk is written. Two bounds drop a reader, each on its own counter: live
+ bytes queued past `HUB_CLIENT_BACKLOG_LIMIT` for longer than
+ `HUB_BACKLOG_GRACE_MS` (`droppedForBacklog`), and a chunk left unaccepted for
+ `HUB_DRAIN_STALL_MS` (`droppedForStall`). What a reader can make the daemon
+ hold is therefore time- and rate-dependent — budget plus ingress during the
+ grace plus the snapshot and frame in flight — not a hard 4 MiB allocation
+ cap. The budget is a sustained condition, never a verdict on
+ one envelope or one burst — a cold collector hands the first reader every
+ peer's reset as a live event inside one tick — and the held-state replay is
+ not counted at all. Judging a 5 MB fleet reset against the budget the instant
+ it was written is how a healthy reader used to get 8 KiB of it, no newline,
+ then EOF. On the client side `streamFeedFromHub` rejects any close the caller
+ did not abort — a FIN inside a line as `closed mid-frame`, a refusal or a
+ daemon exit as `closed the stream` — and a consumer that throws or a
+ non-envelope line rejects the same promise, so `agents feed watch --json`
+ never exits 0 on a truncated stream and nothing escapes as an uncaught
+ exception. A collector that fails to start is reported to its readers rather
+ than leaving them on a stream indistinguishable from an idle fleet.
+ - **Reset semantics preserved.** A peer's reset replaces that ONE scope's rows; a
+ peer going unavailable forwards the `scope` event and RETAINS its rows, so a
+ reader attaching while a box is offline still sees its last-known rows.
+
+ `normalizePeerEnvelope` ([`feed/watch.ts`](src/lib/feed/watch.ts)) is the single
+ ingress for a foreign envelope: `tools` was added to the `reset` payload *within*
+ protocol v1, so a peer on an older CLI is a correct v1 producer that reports no
+ tool rows. Normalizing once there is what lets every consumer treat the field as
+ present, and is why a mixed-version fleet during a rollout does not take the
+ fan-out down.
+
+ ### Tool activity on the feed stream (PHNX-3999)
+
+ Browser tasks and computer runs are the third row kind on the stream, projected by
+ [`feed/tools.ts`](src/lib/feed/tools.ts) from the sources that already own them —
+ the standalone `browser sessions --tasks --json` task rows, the standalone
+ `computer sessions --json` run rows, and — for a task's tabs — the live
+ `tasks.json`. The rows ride the one open stream, so a
+ consumer switching its All/Agents/Browser/Computer filter **launches no commands**:
+ every row for every tab is already there, and the filter is applied client-side on
+ `row.kind`. A surface built on per-tool, per-device polling would be the wrong shape
+ for the same reason the activity lane is read incrementally — keep new consumers on
+ the stream.
+
+ The envelope gains `tool.upsert` / `tool.remove` and `setup.snapshot`, and `reset`
+ gains `tools: ToolRow[]` + `setup: ToolSetupRow[]` beside `agents` and
+ `attention`. Setup rows are the setup teammate's cache
+ ([`setup-tool-status.ts`](src/lib/setup-tool-status.ts) `getCachedToolSetup` /
+ `subscribeToolSetup`) — that side owns DETECTION, this stream only publishes, so
+ no probe and no secret unlock. `setup` is a **snapshot, not per-row upserts**:
+ the cache always answers for every tool, so it is one coherent reading of the box
+ and three independent upserts would let a pane render a mix of two readings.
+
+ [`feed/tool-activity.ts`](src/lib/feed/tool-activity.ts) is the event-driven
+ collector. It watches three roots recursively — the browser runtime tree, the
+ event ledger dir, and the standalone computer engine's own action ledger — and
+ re-projects ONLY on a reported change, emitting the diff rather than a snapshot,
+ so a warm idle does no directory read, no projection, and no subprocess. It does
+ NOT watch the tools' history databases (`.history/browser`, `.history/computer`):
+ the listings it runs write those (WAL frames, retention, legacy adoption), so
+ watching them would make every projection trigger the next.
+ `armed()` is a FUNCTION over live watchers, not a flag captured at setup: a
+ watcher can die later, and a frozen `armed: true` left the tick short-circuiting
+ on one that would never report again, missing changes permanently. Each tick
+ re-arms what is missing and sweeps until everything is watched.
+
+ **An incomplete read preserves rows; it never removes them, and it is judged per
+ kind.** `collectToolRows` returns `{ rows, incomplete, retry }`: `incomplete`
+ names the kinds (`browser`, `computer`) whose read failed, and `ToolRowSet.diff`
+ keeps that kind's published rows while the other kind keeps updating, so a Linux
+ box with `browser` but no `computer` still streams browser changes. `retry` is
+ set only for a failed read of an INSTALLED tool; a tool that is not installed is
+ a stable state and does not re-spawn the other listing every sweep. The readers
+ it depends on THROW rather than
+ returning empty for a failure they cannot trust — an absent `tasks.json` is "no
+ live browser", but EACCES, EMFILE or malformed JSON (usually a write in progress)
+ are failures. An empty list was indistinguishable from "every task closed", so the
+ differ published a remove for every live row and the operator watched their tasks
+ flicker out.
+
+ **The rows come from the standalone tools, never from a copy of their readers
+ (PHNX-4227).** `readStandaloneBrowserRows` / `readStandaloneComputerRows` spawn
+ `browser sessions --tasks --json --no-interactive` and `computer sessions --json
+ --no-interactive --limit 500` (argv array, no shell, stdout capped at 64 MiB,
+ 30 s timeout, asynchronous so a slow tool never stalls the hub; `watchLocalFeed` starts with no tool rows and the watcher's first projection arrives as `tool.upsert` events, so sessions and attention never wait on a tool listing) through the
+ `browser-client.ts` / `computer-client.ts` resolvers. A missing binary, a non-zero
+ exit, unparseable JSON, or a row without its required fields THROWS — the error
+ names `agents setup tools` when the binary is absent — so that kind is
+ incomplete and its rows already on the stream are kept. The tools record a
+ session or launch id but cannot resolve it; `linkToolSessions` joins it against
+ agents-cli's own session index (session id first, then the launch-id join over
+ recent activity, hook sessions and the pid registry), which is what makes a row
+ `linked` rather than `unresolved`. That join runs on the daemon's event loop on every reprojection, so it is
+ incremental and yielding: a found session is reused for `TOOL_LINK_TTL_MS` (60 s, each
+ entry's expiry staggered so they never refresh together), a miss for
+ `TOOL_LINK_MISS_TTL_MS` (10 s), and the launch-id index is rebuilt at most once per
+ window by an async scan that re-reads only activity logs whose size or mtime changed,
+ then replaces the index wholesale. Precedence is unchanged: across activity logs the
+ earliest event for a launch id wins, then hook sessions, then the pid registry
+ (PHNX-4225: the uncached synchronous join held the loop for up to 21 s per pass and
+ crash-looped the daemon on its tick deadlines). The engine names a run's machine `local` when
+ it recorded no hostname; that placeholder is never published as a device.
+
+ Live browser tasks come from `tasks.json` via `readLiveBrowserTasks`, which is the
+ only authority on a task's tabs and the reason a task with zero captures appears
+ at all. It reads the real persisted schema (`Task.createdAt` / `Task.lastActionAt`
+ — **not** the `startedAt` field the service DTO uses and `tasks.json` never
+ carries). A row's session identity is resolved ONCE, durable history → live record
+ → device binding, before the owner link is projected, so one task cannot have an
+ owner on one assembly path and not another; nothing is invented when no source
+ recorded one.
+
+ Two rules are not negotiable in a consumer or a future change:
+
+ - **A computer run is history, not a session.** `ComputerToolRow` pins
+ `live: false` and carries no close/stop command — the CLI process that performed
+ those actions has exited, so an operator control there could not work. Only a
+ browser task still bound in the task index gets a `closeCommand`.
+ - **No secret values in a row.** Captures carry PATHS and names, never contents,
+ and a task URL goes through `redactToolUrl` (userinfo stripped,
+ credential-shaped query parameters replaced, fragment dropped) before it leaves
+ the producing machine. A URL that does not parse is dropped rather than
+ published unexamined.
+ - **`ToolCapture.host` is the box that HOLDS the file, which for a computer run is
+ the INVOKING machine, not the driven one.** The engine's helper RPC returns the
+ image as base64 and the invoking process resolves `--out` and writes it locally,
+ so even a remote-desktop screenshot lands on the box that ran the command.
+ Naming the driven host sends a consumer looking on a machine that never had the
+ file. `closeCommand`/`showCommand` carry `runOn` for the same reason — a later
+ browser verb resolves the device from the local binding and REJECTS `--device`,
+ so acting on a peer's task means running the plain argv on the host that holds
+ the binding. Both commands target the standalone `browser` CLI
+ (`command: "browser"`, argv without a `browser` group word); AGI Menu 1.14.6
+ is the first build that runs either `agents` or `browser` (the menubar floor is
+ now later, in `cli/src/lib/helper-versions.ts`).
+
+ ### Session request + timeline on every row (PHNX-3939)
+
+ Every session row — `agents ps --json`, `agents ps watch --json`,
+ `feed watch --json`, history rows, and the fleet session mirror — carries three
+ optional fields the AGI EXT sidebar renders directly:
+
+ - **`request`** — the session's operative ask: the **latest** genuine user turn,
+ tidied but **never rewritten**. `tidyRequest`
+ ([`sessions-cli/src/lib/session/prompt.ts`](https://github.com/phnx-labs/sessions-cli/blob/main/src/lib/session/prompt.ts)) joins the user's
+ prose verbatim and pulls everything that is not a sentence out beside it —
+ screenshot and `host:/path` clip references and `@dir` mentions into
+ `attachments`, shell echo and dispatch banners into `pastedLines`, a
+ `<command-name>`/`/name <id>` invocation into `command` — so the card shows
+ what the agent was actually told. A model paraphrase is the summarizer's
+ `goal`, labeled as such; putting one here would be a violation
+ ([spec SES-53](docs/specifications.md#sessions)).
+ - **`timeline`** — narration-anchored steps: **one step per line the agent
+ said**, with the tool calls until the next line folded into per-verb counts,
+ `failed` vs `blocked`, and milestones (`worktree created`, `PR opened`,
+ `N files changed`). Last 8 in full plus an `earlier` counter. The headline is
+ always the harness's own words; only a step where tools ran with nothing said
+ is `derived`, and `now` — the label of the call RUNNING — rides only the `live`
+ step. Pure fold in
+ [`sessions-cli/src/lib/session/timeline.ts`](https://github.com/phnx-labs/sessions-cli/blob/main/src/lib/session/timeline.ts) — **no model**.
+ - **`files`** — what the session created / modified / deleted, from the harness's
+ own ledger (Codex `FileChange`, OpenCode `patch`, Claude `file-history-delta`)
+ where it keeps one, else from Edit/Write calls; `source` says which.
+
+ **Zero request-path cost, same shape as the summarizer.** `runTimelinePass`
+ ([`src/lib/session/timeline-pass.ts`](src/lib/session/timeline-pass.ts)) runs
+ inside the daemon's existing reader-gated `SessionStateService` tick — gather,
+ fold, publish, in that order so the row written to the journal carries a current
+ timeline — at most 8 sessions per tick, and for the resumable harnesses (Claude,
+ Codex) reading only the bytes the transcript grew by. The result is cached in
+ the stamp-validated `session_timelines` table (`projection_json` for the display
+ merge, `state_json` for the resume state) and merged onto a row by
+ `mergeSessionTimeline` beside `mergeSessionSummary`. Only complete
+ newline-terminated records are folded, so the 973,963-byte record one live
+ transcript on this fleet carries can never be folded half-written. **Folding the
+ appended tail onto the prior state equals folding the whole file** — pinned
+ against the real transcripts in
+ [`timeline.test.ts`](https://github.com/phnx-labs/sessions-cli/blob/main/src/lib/session/timeline.test.ts).
+
+ **The tick's byte budget is debited by bytes READ, and a session that does not
+ fit says so.** A non-resumable harness (everything but Claude and Codex) has no
+ resume offset, so its only option is a whole-file re-parse — charging it the
+ transcript's *growth delta* under-counted it by orders of magnitude, and gating
+ its eligibility on the 4 MiB per-session allowance meant a transcript in
+ (4 MiB, 16 MiB] could never qualify however idle the tick was. Both were the same
+ bug seen from two sides: real 5.9 MiB and 4.2 MiB grok transcripts on this fleet
+ got **no row at all** — not `ready`, not `partial`, not `unavailable` — while
+ being counted `reused`, so the daemon log read healthy forever. Eligibility is now
+ `min(remaining tick budget, TIMELINE_PASS_MAX_WHOLE_FILE_BYTES)`, the debit is
+ `fileSize` for a whole-file branch and the chunk length for a resumable one, and a
+ session that genuinely does not fit gets a `partial` row naming the budget with
+ its state left at offset 0 so a quieter tick upgrades it. A per-session fold that
+ throws is logged and counted `skipped`, never swallowed as "nothing new".
+
+ **Redaction and escape-stripping happen at the projection point, not per
+ consumer.** `projectTimeline` is the single place folded transcript text leaves
+ the fold, so it scrubs every string it ships — a step's `text`, its `now`, its
+ `marks`. Secrets go through `redactSecrets` (on by default; only the local-only
+ `sessions trace --no-redact` opts out) and terminal escapes are ALWAYS stripped.
+ This is load-bearing rather than defensive: `labelForEvent` falls back to raw
+ command text when a call wrote no `description`, and `mirror.ts` publishes the
+ resulting row into `~/.agents/devices/<device>/daemon-state.json`, which the
+ usage-sync tick **sends to every fleet peer** over SSH — so an unscrubbed label is
+ a credential copied onto every box (root `AGENTS.md` §Security). Note `sanitizeEvents` does NOT
+ run on the fold's parse path (the reader's `tail` / `stream-render` modules and
+ agents-cli's `session/glance-files.ts` call it), so
+ nothing downstream may assume the events arrived pre-scrubbed.
+
+ **The parser keeps the per-tool label every harness writes.** `SessionEvent`
+ gains `label` (Claude Bash `input.description`, Kimi `tool.call.description`,
+ OpenCode `state.input.description`/`state.title`, Cursor `description` and Droid
+ `summary` — both on the shared Anthropic-message parser — and the Codex parsed
+ command),
+ `verbClass` (Codex `parsed_cmd.type`), `changes` (a harness file ledger), and
+ `phase` (Codex `AgentMessage`). Codex's typed `item_completed` stream has its own
+ reader, `parseCodexItemsContent` — deliberately NOT merged into
+ `parseCodexContent`, because a rollout writes the same turn twice (measured:
+ 648 `custom_tool_call` + 60 `function_call` records AND 1,038 `CommandExecution`
+ items in one file), so folding both would double-count every call for every
+ consumer. `agents trace <id> --steps` prints the same fold as text.
+
+ `extractSessionTopic` and `cleanFirstUserMessage` now consult **one** skip list,
+ which is what stops a `/model` echo (`<local-command-stdout>`) or a skill body
+ ("Base directory for this skill:") from becoming a session's topic and then its
+ title. `cleanGeneratedSessionLabel` closes the same hole on the OTHER title rung
+ (PHNX-3999 F26/F27): a harness auto-title is derived from the first turn, so a `!`
+ shell echo became a row titled `<bash-input>…` and a `/clear` became a row titled
+ `/clear`. That cleaner — the one place Claude's `ai-title` and Cursor's
+ `chatMeta.title` both pass through (`session/discover.ts`) — now **rejects**
+ scaffolding and a bare control token outright rather than stripping the tags off
+ (which would promote the shell command itself to the headline), so the row falls to
+ the daemon-generated title and then the user's own first prompt. A user `/rename`
+ (`customTitle`) is still kept verbatim, and the original turn stays on the row as
+ `firstUserMessage`/`request`, so nothing is lost from the details view. The fix is
+ paired with a `CONTENT_INDEX_VERSION` bump, since the label is written at index
+ time and already-indexed rows would otherwise keep the junk title.
+
+ **Every session row also carries `confirmedProject` (PHNX-3999 F08/F09).** It is the
+ registered project definition whose root contains the session's cwd
+ (`confirmedProjectForCwd`, [`src/lib/projects.ts`](src/lib/projects.ts)), or `null`
+ when the association is not confirmed. That is what a UI groups by; the older
+ `project` field stays exactly as it was — a bucket KEY derived from the path, which
+ always answers something, and grouping by it is what filed loose and unrelated
+ directories as projects of their own. Being inside *some* git repository is
+ deliberately **not** an association: a repo nobody registered would still invent a
+ project out of a folder name. `null` means Uncategorized — the row is still listed,
+ it is just not filed under a project nobody bound it to. It is resolved on the
+ device that owns the path (a definition root is a local path), and rides the live
+ row, the history row, and `agents ps --json`, so every consumer reads the
+ same verdict. `SessionMeta.lastUserMessage` (schema v48) carries the latest genuine turn
+ beside `firstUserMessage`, and `deriveSessionRecap` classifies that raw turn —
+ with the row's attachments in hand — rather than the already-collapsed `topic`.
+
+ Fleet stream ownership is projected by `src/lib/session/projection.ts` for both
+ `watchFleetSessions` and `watchFleetFeed`. A session is keyed by execution device,
+ harness and exact session id. Worker observations own lifecycle and content;
+ `observerTerminals` carry observer-local terminal/viewing/reply metadata, plus the
+ dispatching desktop's tab for a `--device` run (`originTerminal`, from the forwarded
+ `AGENTS_ORIGIN_DEVICE` + `AGENTS_ORIGIN_TERMINAL_ID`, PHNX-4263). Peer loss
+ retains state until the next owner reset; an answered owner's absent row cannot
+ be resurrected by a stale launcher or mirrored history.
+
+ Linux process-view ownership protects host snapshots and PID records from foreign
+ namespaces. Initial-host or kernel-verified daemon authority permits legacy
+ migration/reboot recovery. Reusing a private-container HOME across namespaces is
+ unsupported even after its former writer exits; refuse with a diagnostic, never
+ infer namespace death from process invisibility (`session/process-view.ts`).
+
+ ### Per-session summarizer (PHNX-3939)
+
+ Each session can carry a daemon-computed **`goal`** (1–2 lines), progress
+ **`checkpoints`** (`{text, at}`, newest last), and a detailed **`summaryChecklist`**
+ (`{text, done}`), plus a **`summaryState`** (`pending` | `ready` | `skipped`),
+ delivered on the `sessions watch` / `feed watch` stream so the AGI extension
+ sidebar can render them. The hard rule is **zero request-path cost**: the model
+ call runs **only** in the background `session-summarizer` daemon service
+ ([`src/lib/daemon/session-summarizer-service.ts`](src/lib/daemon/session-summarizer-service.ts)
+ → [`src/lib/summarizer/pass.ts`](src/lib/summarizer/pass.ts)), which is reader-gated
+ and bounded per tick, and the result is cached in the stamp-validated
+ `session_summaries` table (`db.ts`, keyed on `(session_id, file_mtime_ms,
+ file_size)` exactly like `session_preview_cache`) so it never recomputes on
+ unchanged transcript bytes. The display path only **reads** that cache — the merge
+ onto live rows rides `applyImmutableMemo`
+ ([`src/lib/session/session-cache.ts`](src/lib/session/session-cache.ts)); history
+ rows project it in `toPreviousSessionWatchRow`
+ ([`src/lib/session/remote/watch.ts`](src/lib/session/remote/watch.ts)); and the
+ fleet session mirror carries it so a peer's sessions show it too
+ ([`src/lib/session/mirror.ts`](src/lib/session/mirror.ts)). It is **off by default**:
+ disabled or unconfigured, the service makes **zero** model calls and every row reads
+ `summaryState: "skipped"`. Configure it through `summarizer.enabled` /
+ `summarizer.baseUrl` / `summarizer.model` (see the Configuration surface below).
+ The one model call is a pure boundary over the Anthropic-wire client
+ ([`src/lib/summarizer/summarize.ts`](src/lib/summarizer/summarize.ts)) — prompt input
+ is the cleaned first user turn (never the tool firehose), progress input is the
+ state engine's todos/plan/phase plus, when the timeline pass has folded any, the
+ last few narration headlines (§Session request + timeline above) — forcing strict
+ JSON and returning `undefined` (→ `skipped`) on any error.
+
+ **Owner notifications have one path: the account (PHNX-4267).** Every owner-bound
+ event (`agents send --to owner`, a `--blocked` or important `feed post`, an urgent
+ feed block, a failed routine) is one `POST /me/notifications` to rush/api through
+ [`src/lib/owner-notify.ts`](src/lib/owner-notify.ts) `postOwnerNotification`.
+ rush/api owns the preferences (edited in the console Settings page),
+ dedup on `(event, dedupKey)` (`postOwnerNotification` prefixes every producer key with `owner:` so it can never collide with a cloud-run key), and delivery: email, Slack and iMessage all go out
+ server-side, iMessage from Rush's SendBlue number. Event mapping: a block is
+ `needs_you`, a failed routine is
+ `failed`, everything else is `message`; a green routine stays silent. The bearer is
+ this box's Phoenix session, else the `notify`-scoped device token in this worker's
+ reserved store `__notify-<device>__`. Neither is an `OwnerNotSignedInError`, never
+ a local fallback; `agents doctor` reports it as `owner-not-signed-in` (critical).
+ There is no `humans.yaml`, no `notify.owner`, and no SSH forward to a Mac peer any
+ more; the one-shot `migrateHumansToAccount` (`installations/migrate.ts`) uploads an
+ existing `humans.yaml` to `PUT /me/preferences` on the first signed-in run, moves
+ it to trash and commits the removal, and leaves it in place (retrying next run)
+ while the box has no session.
+
+ - **Worker device tokens.** A worker holds no Phoenix session. The elected
+ signed-in headed box (`syncOwnerNotifyTokens` in
+ [`src/lib/owner-notify-tokens.ts`](src/lib/owner-notify-tokens.ts), run on the
+ `auth-sync` tick) mints one token per `role=worker` peer
+ (`POST /api/v1/auth/tokens {device, scopes:["notify"]}`) into that worker's own
+ reserved store `__notify-<worker>__`, then pushes that one bundle to that one
+ worker. One bundle per worker, because the push is whole-bundle and a shared
+ store would hand every worker every other worker's token. It is never pushed to
+ a headed peer. Minting replaces a device's previous token server-side, so only
+ the first signed-in headed box by name mints (each box publishes
+ `ownerNotify.{signedIn, deviceToken}` in its daemon-state envelope; a headed
+ peer's report older than `OWNER_NOTIFY_PEER_FRESH_MS` does not count, so a dead
+ box is not elected). `deviceToken` is the worker's own verdict: a token is
+ present and rush/api has not answered 401 to it, and the next authenticated call
+ clears a 401 mark. A worker reporting `false` gets the stored token re-pushed
+ only when the minter holds the live one and its last push failed; a `false`
+ reported after a successful push gets a revoke and fresh mint, as does a token the
+ minter does not hold, so a rejected token, a lost memo, or a replaced minter never
+ strands it. Each signed-in headed box revokes only the tokens it minted, for a
+ device that is no longer a worker peer, and skips that pass when its device
+ registry is empty or any role is unreadable. `agents auth logout` revokes the
+ tokens that box minted.
+ - **rush/api sends every owner channel, iMessage included.** `POST /me/notifications`
+ answers with every channel in `queued` (`delivered` is always empty), and
+ rush/api's worker sends them, iMessage from Rush's own SendBlue number to the
+ number the owner confirmed in Settings. No daemon service on this side claims
+ or sends anything; `describeOwnerResult` reports the result as "Rush is sending
+ ...".
+ - **Explicit channels are a different feature.** `agents send --channel slack --to
+ '#eng'` and feed `channel:` sinks still deliver through the local providers
+ (`notify.transports` still remaps a channel name). They no longer fall back to an
+ SSH forward when this box cannot deliver.
+
+ Feed channel sinks may override their outbound body with a `message:` template
+ using the same placeholders as command sinks (`{message}`, `{ticket}`, `{ticket_url}`, `{project}`,
+ and the rest in `feed-broadcast.ts`). Placeholder resolution is fail-closed: if
+ the post lacks a referenced value, that sink is skipped. This is the semantic
+ routing primitive for destinations such as an engineering Slack channel: a
+ template that includes `{ticket}` receives ticket-backed posts without leaking
+ unrelated owner alerts into the team channel.
+ The shared `{message}` is built to be tappable **only where a destination can
+ render a link** (PHNX-3698, `composeBroadcastMessage` in `feed-broadcast.ts`).
+ `{message}` is one logical post; the *format* is decided **per destination**
+ (`sinkMessageFormat` in `sink-format.ts`, keyed on the destination's **resolved
+ provider** — the same `notify.transports` remap delivery uses, so an aliased Slack
+ sink still turns blue), and the body is re-rendered in that format. This is a
+ **per-destination** decision, not a per-sink one: a Slack `channel:` sink gets the
+ labeled-link variant and a plain one gets the plain sentence from the **one** post,
+ **never a trailing URL line**. The owner sink always sends the plain body with the
+ title, url, session and ticket as separate fields; the account renders it per
+ channel:
+ - **Slack (any destination that resolves to the `slack` provider) → mrkdwn labeled links**
+ (`<url|label>`, blue tappable text). The `Sent from claude/6fc1db18 on zion` crumb
+ becomes `<https://prix.dev/console/sessions/<full-id>|claude/6fc1db18>` (the 8-char
+ crumb is upgraded to the full indexed id via `resolveFullSessionId`, `session/db.ts`,
+ since a truncated id would 404), and every `TEAM-N` key the title or body *names*
+ — not just the session's own `ticketId` — becomes
+ `<https://linear.app/<ws>/issue/<KEY>|<KEY>>` in place
+ (`linearIssueKeys`/`LINEAR_KEY_DENYLIST` in the `@phnx-labs/sessions-cli/reader`
+ `linear` module, the same
+ canonical detector `detectTicket` uses).
+ - **iMessage / `command:` / desktop / the owner sink / every other channel →
+ plain.** They cannot render a labeled link and a dumped naked URL reads as noise,
+ so the message stays the human sentence with **no URLs** (keys and crumb as text).
+
+ **`agents send --to owner` routes through this same composer**
+ (`ownerMessageNotification`, `owner-message.ts`, over `ownerNotificationFromContext`
+ in `feed-broadcast.ts`), so an owner ping carries the same plain body, footer and
+ fields as an important feed post. A non-owner `agents send` (explicit
+ `--channel`/`--to`) is delivered verbatim.
+
+ **`gh` is overloaded so the fleet's trained `gh pr checks` escapes the shared
+ GraphQL rate limit (PHNX-3501).** The whole fleet shares one GitHub token, and
+ `gh pr checks/view/list` are all GraphQL-backed, so the fleet drains GitHub's
+ 5000-**point**/hr GraphQL budget while REST core (5000 req/hr) sits idle — then
+ every agent's `gh pr checks --watch` dies with `GraphQL: API rate limit already
+ exceeded`. A generated PATH shim (the `browser`-shim pattern,
+ `ensureGhOverloadShim` in [`src/lib/installations/shims.ts`](src/lib/installations/shims.ts),
+ installed on `agents sync`) intercepts **only** the agent's trained `gh pr checks`
+ and routes it to the hidden `agents __gh` verb
+ ([`src/lib/github/gh-overload.ts`](src/lib/github/gh-overload.ts)), which answers
+ over REST ([`src/lib/github/rest.ts`](src/lib/github/rest.ts): `prHead` +
+ `rollupForSha` = `commits/{sha}/check-runs` ∪ `/status`, anchored to the PR's live
+ head SHA); every other verb execs the real gh byte-for-byte. `--watch` owns the
+ REST poll loop (head-SHA-anchored, so a superseded run's red is never reported —
+ closes PHNX-3042); a one-shot runs real gh first and falls back to REST only on the
+ exact rate-limit stderr. **Self-healing:** the shim execs real gh whenever
+ agents-cli is gone (`[ ! -x "$AGENTS_BIN" ]`) or the `AGENTS_GH_SHIM` sentinel is
+ already set (recursion), so a leftover/orphaned shim can never break `gh`, and
+ uninstalling restores plain `gh` immediately. It shadows via PATH precedence and
+ never touches the real binary. Tier-1 wraps only `pr checks` (clean REST mapping,
+ highest failure volume); `pr view`/`pr list` and a proactive `linear-rate-limit.ts`-style
+ budget are fast-follow. POSIX-only in v1. `reviewDecision` is deliberately never
+ REST-derived (it is a GraphQL-computed branch-protection/CODEOWNERS decision;
+ approximating it could let the merge loop bypass review).
+
+ **The open-PR reads are one GraphQL query per repository or owner, a measured
+ exception to the REST-first rule.** `agents projects prs <name>` lists a repository's
+ open PRs with one paginated `repository.pullRequests` query, and the all-orgs
+ `agents projects prs` (AGI Menu's GitHub tab) runs one paginated `search` per owner
+ plus one for `review-requested:@me`; both return each PR's head, `mergeable`,
+ `mergeStateStatus` and check rollup in the same page (`PR_NODE_FIELDS` and
+ `prFromNode` in [`src/lib/github/project-prs.ts`](src/lib/github/project-prs.ts)).
+ A page of 100 PRs costs 2 GraphQL points (measured with `rateLimit { cost }`). It
+ replaced a per-PR fan-out of three REST requests and three `gh` processes
+ (`pulls/{n}`, `check-runs`, `status`): on a 102-PR repository the read went from
+ about 40 s to 6.7 s, and the all-orgs read of 203 PRs in 28 repositories used to
+ take 97.9 s and 39 CPU-seconds. The rule above targets poll loops and per-item
+ GraphQL calls; a bounded read that removes a per-item REST fan-out spends less of
+ both budgets. GitHub computes `mergeable` lazily, so a cold read can report
+ `UNKNOWN` (null here) until the next one, exactly as the REST `pulls/{n}` read did.