# 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, 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. A definition's `aliases:` (former names) are applied on top: a cwd
that resolves to a former name, and a peer row still reporting one, read as the
current name (`canonicalProjectName`; peers in `normalizePeerEnvelope` and
`canonicalizePeerSessionEnvelope`; also the path-derived `project` key and the
`projects` status rollup). The alias table is data in the project YAML, never a
name check in code. Matching is case-insensitive, and `resolveProjectAliases`
never throws on the read path: an alias that names another live project (two that
name each other are a cycle) or that two definitions claim is ignored, and
`listProjectDefs` prints one stderr warning for it.

**Live rows also carry `pending`, `effort` and `fast`.** `pending` is
`{subagents, shells, monitors}`, each `[{id, description, startedAtMs}]`, and lists
only work running NOW: subagents whose status is `running`, Claude background
shells (`run_in_background`, or a call the harness backgrounded) with no
`<task-notification>` status and no successful `TaskStop`/`KillShell` (an
`is_error` result does not end the task), and `Monitor` watches not yet ended,
expired by notice, or past `timeoutMs`. All three die with the harness, so they
count only while `pidAlive` is true and only when started by the CURRENT process:
work older than the row's `processStartedAtMs`, or before the transcript's latest
process boundary (a `cost-state` exit record, or a `SessionStart:startup`/`resume`
hook record; `clear` and `compact` keep the process), is dropped, so a resumed or
forked session never inherits a ghost. The field is absent when nothing is
pending. A client shows `idle` + `pending` as Waiting, not as needing
the operator — `subAgentCount` is cumulative and never a liveness signal.
`effort`/`fast` come from the latest Claude reply (`effort`, `usage.speed`) or Codex
`turn_context` (`effort` only; Codex records no speed, so `fast` is absent).
`effort` is one of `low | medium | high | xhigh | max`. One
incremental per-file fold reads them
([`src/lib/session/pending-work.ts`](src/lib/session/pending-work.ts), at most the
last 64 MiB on a cold read, appended bytes after; a task started further back is
not seen by a cold reader — the daemon's fold stays warm, so this bounds only a
fresh process on a very long transcript), stamped by
`loadLocalActiveSessions` and, for this box's rows, `loadFleetActiveSessions`; a
peer's rows arrive already stamped by its own daemon. `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.
The tradeoff: when the GraphQL budget is exhausted the list itself fails, so that
repository (or owner) reads empty with `error` set and the envelope `partial`,
where the REST list kept the PRs and only lost CI. AGI Menu keeps its last good read
on disk and shows it with its age in that case. The list asks newest-created first,
the REST order, and a PR with more than 100 check contexts takes GitHub's own
rollup `state` so a later failing check is not lost. AGI Menu reads at most once a
minute while a tab showing PRs is open: 2 to 6 points per read. Merged-PR and
default-branch checks still use the cached REST rollup.

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
  session-backup Worker (`sessions export --to-r2`, provisioned by `agents setup session-backup`), 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
`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;
`sessions --teams` includes them, while live teammates appear in
`agents ps` 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`, `secrets-engine-incompatible` (the `secrets` engine predates 0.4.0; remediation `agents setup tools --tool secrets`) 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** (the agent-wide
  `agents sync <agent>@all` sweep skips them, so it 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:298`).
- **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:836`), 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:1002`,
`lib/sync-umbrella.ts:64`), 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 session picker and
`agents ps 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 config set browser.profile 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).

`menubar.menu.*` (user-scope, central `agents.yaml`, syncs fleet-wide) are the
**AGI Menu preferences** (PHNX-3999) — `defaultProject`, `todoProject` (the project whose open
Linear issues the menu lists as to-dos, `agents projects todo list --project <name>`; unset = your
quick to-dos), `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` (due/priority/updated/newest/oldest/title, default due), `ticketGroup`, `homeSections`, `sessionFilter`, `artifactApps`, `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 `MENUBAR_TABS` exactly once; default in that order — an order
saved before Goals, Secrets, Linear or GitHub 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; the retired `projects`
tab, `MENUBAR_RETIRED_TABS`, is still accepted and kept in place for older menus),
`hiddenTabs` (any of those tabs 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. Three keys shape the GitHub tab (PHNX-4269):
`githubHidden` (org/account logins or `owner/repo` slugs it leaves out; default `[]`),
`githubDrafts` (show drafts; default `true`) and `githubBots` (show bot-opened PRs; default
`false`). Settings is never in
either tab list: it is always shown. The five 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` and `todoProject` 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`, `homeSections`, `artifactApps`, `githubHidden`) 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
forced wrap 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. Read it with
`agents config get interactive.host`.

One older spelling still works but is deprecated and prints a warning pointing
at the replacement:

- `agents models tier` → `agents config set run.<agent@version>.tier.<tier>`

`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.138 | ✓ | <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 `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 (renderActiveSessions over lib/session/active.ts gatherActiveSessions)
                       #   and the stop/focus/detach/migrate/preview/watch verbs (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/           # context.ts: the fd-3 JSON agents-cli hands the standalone `browser` engine when IT runs the
                       #   engine (`agents setup browser`, `browser show` for a viewer). People and agents call `browser`
                       #   directly; `agents browser` was removed (PHNX-4227). The feed reads `browser sessions --json`.
    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/           # session discovery, index writer and live identity (the parse/render reader is `@phnx-labs/sessions-cli/reader`, see the note below); also `migrate-targets.ts` (the `agents ps migrate` target scorer); `db.ts` `queryResourceUsageStats`/`backfillResourceUsage` back `agents insights resources` + `agents daemon index 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). The cached multi-harness friction/correction/automation facets behind `agents insights` come from the reader's `insights` module (`@phnx-labs/sessions-cli/reader`), including a shell-command-by-binary breakdown (`bashCommands`/`bashCommandFailures`, keyed by its `bash-command` module'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 / Cursor / host)
    teams/             # `agents teams` orchestration
    computer/          # policy.ts (permissions → the allow list) + context.ts (the fd-3 JSON) for the one place
                       #   agents-cli still runs the standalone `computer` engine: `agents setup computer`.
                       #   `agents computer` was removed (PHNX-4227); everything else is `computer …`.
    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/` keeps only the provisioning of
the managed session-backup Worker (`agents setup session-backup`); the Worker script
and its `DEFAULT_SESSIONS_*` names come from `@phnx-labs/sessions-cli/backup`, and
the backup client itself is the standalone `sessions export --to-r2` / `import --from-r2`.
Transcript reads are the standalone `sessions` CLI; agents-cli runs its bin
(`lib/sessions-client.ts`) only for its own surfaces (`agents events logs`, the
picker's remote transcript view), and fails loud when no bin resolves. The
live-session **writer** is a separate package,
[`packages/session-tracker`](../packages/session-tracker) — different data,
different consumer; see its AGENTS.md.

### Session picker and preview architecture (map before you touch it)

This maps the picker behind `agents ps` (and the `agents run --resume` picker);
users browse and read transcripts with the standalone `sessions` CLI.
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 daemon index 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`. A peer is asked with `agents ps preview <id> --local
--json`; a peer older than the PHNX-4227 release answers with an unknown-command
error, which reads as unreachable.
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. The digest builder and its cache read live in
`lib/session/preview-digest.ts`, not in the picker, because the watch stream
reuses them: a previous-session row takes its card text (`lastAgentLine`, `todos`,
`artifacts`) from the cached digest, the reset never parses, and missing digests
are built one per macrotask after it (PHNX-4326). Do not give previous rows their
own transcript parse. 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 `agents ps 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 `agents ps` 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 through the hidden `agents ps resolve <selector> --json --local`
peer protocol, 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.

The rich browser is `runSessionBrowser`
([`src/commands/sessions-browser.ts`](src/commands/sessions-browser.ts)); its
history pool asks each peer's standalone `sessions --all --json --local` over SSH.
`sessionPicker` ([`src/commands/sessions-picker.ts`](src/commands/sessions-picker.ts))
is the single-select variant. `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: the session picker hops to the owner with
`agents run auto --resume <id> --interactive` (the user spellings are `agents ps
focus <id>` and `agents run auto --resume <id>`). The batch picker behind a bare
`agents run <agent> --resume` mostly inherits it for free: every TAB it opens runs
that `run` argv, and `agents run --resume <id>` routes to the source device itself. 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 `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 run test   # vitest; plain `bun test` skips tests/setup.ts and its sandboxed HOME
```

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
ps`. 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), `scripts/postinstall.js`, and CHANGELOG/README/LICENSE
ship (`prepack` copies the repo-root README in). No native
binary is in it (RUSH-3100).
Nothing from 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/**` (the CLI's path
before it moved to `cli/`, kept so older attested ancestors still compare),
`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.**
`scripts/remote-sign-mac.sh` signs on a Mac over SSH with
`secrets exec apple.com`, so that Mac needs the `apple.com` secrets bundle
*on that host*. Push it with the standalone engine's **file backend** —
`secrets export apple.com --host <signer> --remote-backend file` (**no
passphrase required**: the remote keys it under a machine-local key and reads it
headlessly; `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 (the engine read-back-verifies a
keychain push and fails loudly if it did not persist). The standalone takes
`--host`. See [`docs/secrets.md`](docs/secrets.md) for what agents-cli still
owns.

**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 the standalone `secrets` CLI (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 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 insights` emits aggregate-only
  actions (SES-IF-4c); `agents insights resources` emits its own versioned
  `sessions-stats` rollup of skill/command usage and never the list/detail shape
  (SES-IF-4b); `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 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 — `teams` and `cloud` have design docs but zero
RFC-2119 requirements, and surfaces like `sync`, `feed`, and `send`
have neither. The
[coverage inventory](docs/specifications.md#coverage-inventory) says which row a
surface sits in; check it before treating a behavior as guaranteed.

Every section enumerates **known gaps** (implemented-vs-intended drift) — a new
feature MUST NOT widen them and SHOULD close the one it touches. A gap that has
been closed stays as a `(resolved)` entry so references never dangle.

## Detailed design

[`docs/`](docs/README.md) is the source-grounded reference. Start with
[`architecture.md`](docs/architecture.md) for CLI ownership boundaries and the
session mechanisms, then [`concepts.md`](docs/concepts.md) for the resource
model. Extension-owned presentation design lives in the
[AGI EXT architecture](https://github.com/phnx-labs/agi-ext/tree/main/docs).
The normative contract
([`specifications.md`](docs/specifications.md)) sits
alongside the reference docs ([sessions.md](docs/sessions.md),
[secrets.md](docs/secrets.md)) — read the spec for the guarantee, the reference
for the how-to.

### AGI Menu update preference (PHNX-4323)

`menubar.autoUpdate` is a synced user boolean, default true. The CLI owns app update
checks and installation through `updateMenubarHelperIfNewer`; all automatic entry
points share `update-state.ts`'s persisted 12-hour attempt gate and install lock.
`menubar update --check --json` only checks; `menubar update --json` manually
installs even when auto updates are off. Their status object also rides
`snapshot.menubarUpdate`; see `docs/menubar.md`. Menu commands and preference writes
bypass bootstrap updates. Do not add an app-owned timer or restore cached-bundle
startup upgrades around the preference gate. Same-version CLI path repair remains
allowed. Manual self-update responds before the bounded restart handoff, avoiding
the menu's child-process reaper. Lookup/restart errors remain explicit status.
