git:20261014.44cfc37 to git:20261017.baba0f1

1979 added, 2025 removed, shown as one replacement because the pair was too large to align, first 3,000 lines shown. Audit B to B.

# agents-cli (the CLI)
`@phnx-labs/agents-cli` — the `agents` / `ag` CLI for managing AI coding-agent
versions, config, sessions, and cloud dispatch (Claude, Codex, Cursor,
OpenCode, OpenClaw, Grok, Droid, …).
> **New agent? Start with [`docs/README.md`](docs/README.md).** It links the
> compact architecture, concepts, execution, and subsystem decision documents.
This is the **internal architecture** map. The user-facing feature tour is
[README.md](../README.md) (pin versions, run, sessions, 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`.
-
- **Every attention row carries `urgency` and, when there is one, `urgencyReason`
- (PHNX-4369).** `urgent | high | normal | low`, computed once by the reconciler
- (`attentionUrgency`, [`src/lib/feed/attention.ts`](src/lib/feed/attention.ts)) so no
- consumer classifies: an ask that holds up a release, deploy or merge is `urgent`
- ("Blocks a release (npm publish)") — matched on the command for a permission ask,
- on the text for a question; otherwise the linked ticket's Linear priority decides
- (Urgent/High/Low; Medium and None read normal; "High ticket PHNX-3890"); otherwise
- `normal`. `costOfDelay` is not an input — every declared block carries `high`. The
- ticket comes from the session row (`ticket.id`) or the block, and its priority from
- [`src/lib/feed/ticket-priority.ts`](src/lib/feed/ticket-priority.ts): a synchronous
- disk-cache read that starts at most one background `linear tasks <id> --json` per
- ticket per 30 minutes, only once an ask exists. A peer on an older CLI sends no
- urgency; `normalizePeerEnvelope` reads it as `normal`.
-
- **The banner schedule, reminders, withdraw and the fleet rule live in the
- `attention-notify` daemon service** and the helper argv they use is a cross-repo
- contract — see [`docs/menubar.md` §Attention banners](docs/menubar.md#attention-banners-phnx-4369).
- The same service runs `gcMailbox` hourly, so an open ask whose session is gone
- leaves the feed a day later even when nobody runs `agents feed --dispatch`, and
- `rankFeedBlocks` caps an ask's idle term at 24 h so a stale ask cannot outrank
- every live one.
-
- Claude's `PermissionRequest` hook writes "Claude needs approval to run <tool>:
- <command>" (Codex keeps "Codex needs approval to run …"), and the later
- `permission_prompt` Notification does not overwrite that richer block with
- "Claude needs your permission".
-
- `agents lead` is the per-project **Lead** (`src/commands/lead.ts`, `src/lib/lead/`): one
- warm headless Claude per project, owned by a detached host (`agents __lead-host <project>`,
- unix socket in `~/.agents/lead/<project>/`) that `ask` starts on demand. The host owns the
- stream-json child and one `feed watch --json` subscription for the per-turn state delta;
- the daemon does not host it (it stays on production code and restarts on self-update).
- The Lead runs with isolated Claude settings, its own cwd and a minimal env, and may only call
- the fixed-shape `agents lead tool` commands; never widen that allow-list to raw `agents`,
- `linear` or shell prefixes (a compound command or a flag like `--mode skip` slips through
- them). Stops run only through `agents lead confirm <token>`. The NDJSON event shapes are a
- cross-repo contract with AGI EXT and the FastWispr bubble: change them only with both.
- Lead sessions carry `lead: true` and are hidden from `ps`, `ps watch` and `feed watch`
- unless `--include-lead` (`src/lib/lead/visibility.ts`); `sessions` still indexes them.
- Reference: [`docs/lead.md`](docs/lead.md).
-
- 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
- agents config set lead.model sonnet
- ```
-
- 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`). Four keys schedule attention banners (PHNX-4369):
- `notifyUrgent`, `notifyHigh`, `notifyNormal` and `notifyLow`, token lists of times
- (`0m`, `15m`, `1h`, increasing, from when the ask opened) plus `stay` and `sound`, or
- just `sidebar`; see [`docs/menubar.md` §Attention banners](docs/menubar.md#attention-banners-phnx-4369). Settings is never in
- either tab list: it is always shown. The 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
- skipped by `autoPoolSkips`, `pool.ts`), 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
- (`run`/`exec`/`teams` placement and `devices pick` all pass it; `ssh auto` and the
- generic passthrough still sample by launch history). Hard health is
- `blocksPlacement` (`devices/health.ts`): `loaded`, or `disk-low` when `/` has
- under 20 GiB free, because an agent that cannot create a worktree does nothing.
- Memory pressure counts only while under 16 GiB is free, so a large box at 48%
- used is not ranked below a small idle one. `agents devices list --json` reports
- the verdict per device as `autoSkip` (`autoSkipReason`, `pool.ts`), which is what
- AGI EXT's Settings > Devices section renders;
- `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
- lead/ # `agents lead`: per-project host, stream-json parser, brief/delta, refs, confirm tokens (docs/lead.md)
- ```
-
- 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): `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. The `label` rung itself has one resolver,
- `resolveSessionName` (`title.ts`, PHNX-4387): user `/rename` → Claude's current
- name from `<config>/sessions/<pid>.json` in any config home (`readClaudeLiveNames`)
- → `agents run --name` (passed to Claude as `--name`) → Claude's `ai-title`. The
- index calls it in `readClaudeMeta`, live rows in `applySessionNames`; the
- extension's tab title is a projection of it, never an input. Live rows also
- carry `startedAs`, the first prompt with `/goal` and injected turns stripped. `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`
+ plugins, browser, secrets). 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`.
+
+ **Every attention row carries `urgency` and, when there is one, `urgencyReason`
+ (PHNX-4369).** `urgent | high | normal | low`, computed once by the reconciler
+ (`attentionUrgency`, [`src/lib/feed/attention.ts`](src/lib/feed/attention.ts)) so no
+ consumer classifies: an ask that holds up a release, deploy or merge is `urgent`
+ ("Blocks a release (npm publish)") — matched on the command for a permission ask,
+ on the text for a question; otherwise the linked ticket's Linear priority decides
+ (Urgent/High/Low; Medium and None read normal; "High ticket PHNX-3890"); otherwise
+ `normal`. `costOfDelay` is not an input — every declared block carries `high`. The
+ ticket comes from the session row (`ticket.id`) or the block, and its priority from
+ [`src/lib/feed/ticket-priority.ts`](src/lib/feed/ticket-priority.ts): a synchronous
+ disk-cache read that starts at most one background `linear tasks <id> --json` per
+ ticket per 30 minutes, only once an ask exists. A peer on an older CLI sends no
+ urgency; `normalizePeerEnvelope` reads it as `normal`.
+
+ **The banner schedule, reminders, withdraw and the fleet rule live in the
+ `attention-notify` daemon service** and the helper argv they use is a cross-repo
+ contract — see [`docs/menubar.md` §Attention banners](docs/menubar.md#attention-banners-phnx-4369).
+ The same service runs `gcMailbox` hourly, so an open ask whose session is gone
+ leaves the feed a day later even when nobody runs `agents feed --dispatch`, and
+ `rankFeedBlocks` caps an ask's idle term at 24 h so a stale ask cannot outrank
+ every live one.
+
+ Claude's `PermissionRequest` hook writes "Claude needs approval to run <tool>:
+ <command>" (Codex keeps "Codex needs approval to run …"), and the later
+ `permission_prompt` Notification does not overwrite that richer block with
+ "Claude needs your permission".
+
+ `agents lead` is the per-project **Lead** (`src/commands/lead.ts`, `src/lib/lead/`): one
+ warm headless Claude per project, owned by a detached host (`agents __lead-host <project>`,
+ unix socket in `~/.agents/lead/<project>/`) that `ask` starts on demand. The host owns the
+ stream-json child and one `feed watch --json` subscription for the per-turn state delta;
+ the daemon does not host it (it stays on production code and restarts on self-update).
+ The Lead runs with isolated Claude settings, its own cwd and a minimal env, and may only call
+ the fixed-shape `agents lead tool` commands; never widen that allow-list to raw `agents`,
+ `linear` or shell prefixes (a compound command or a flag like `--mode skip` slips through
+ them). Stops run only through `agents lead confirm <token>`. The NDJSON event shapes are a
+ cross-repo contract with AGI EXT and the FastWispr bubble: change them only with both.
+ Lead sessions carry `lead: true` and are hidden from `ps`, `ps watch` and `feed watch`
+ unless `--include-lead` (`src/lib/lead/visibility.ts`); `sessions` still indexes them.
+ Reference: [`docs/lead.md`](docs/lead.md).
+
+ 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) 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`, everything else is `message`. 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, or as a teammate.** 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 |
+
+ Source of truth: `buildExecEnv` / `emitResolvedSessionId` in
+ [`src/lib/exec.ts`](src/lib/exec.ts), and `listTeamsActive` in
+ [`src/lib/session/active.ts`](src/lib/session/active.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`).
+
+ 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.
+
+ ### 8. 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.
+
+ ### 9. 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