AGENTS.md@cli/src/lib/daemon · git:20261020.ac859d7 · 2026-10-20 · sha256 08f84f0fcc7fd056
AGENTS.md@cli/src/lib/daemon git:20261020.ac859d7A
Immutable. This exact content never changes and is served at /api/v1/blob/08f84f0fcc7fd056.
# Daemon module
The background process behind `agents __daemon-run` — the interval-driven
services that keep sessions, device probes,
self-heal, and reapers running on each machine without a human polling them.
## Current architecture
**N independent per-device daemons, no daemon-to-daemon bus.** Every device
that runs agents-cli runs its own daemon process (`agents __daemon-run` →
`runDaemon()`, `daemon.ts:850`). No daemon opens a persistent connection to
another daemon. Cross-device state either rides per-device files in the existing
fleet-synced user repo (`fleet-shared-state.ts`, used by usage/auth sync) or an
on-demand `ssh` invocation of a peer CLI verb — e.g.
`watchFleetFeed` spawns `ssh <peer> agents feed watch --local`
(`cli/src/lib/feed/watch.ts:151`) and `watchFleetSessions` does the same for
sessions (`cli/src/lib/session/remote/watch.ts:245`). There is no shared daemon
protocol and no standing device-to-device usage/auth probe mesh.
**Start, claim, and stop are one serialized lifecycle.** All three mutate the
shared pid/heartbeat/socket inventory behind `<daemonDir>/daemon.lock`.
`stopDaemon()` holds that lock from target resolution through its postcondition,
accepts a pid only when the live process command ends in `__daemon-run`, and
removes a pid or POSIX socket only while it still matches the owner/inode captured
under the lock. A failed process-command inspection is an unverified live owner,
never permission to signal it, erase its files, or launch a duplicate.
`reapStrayDaemons()` applies the same identity gate, waits for SIGTERM death, then
escalates and waits again; it removes a registry marker only after death is
observed, so a wedged duplicate cannot survive while becoming invisible.
**A daemon launch under a redirected HOME is refused (W4, PHNX-3736).**
`startDaemon()` throws `RedirectedHomeDaemonError` when `isolatedHomeSuffix()`
detects HOME pointing away from the account's real home, unless the caller opted
in with `AGENTS_ALLOW_TEST_DAEMON=1` (the deliberate test/e2e seam — a harness
that sets it owns stopping what it starts). The explicit start commands
(`daemon start`) fail loud; auto-start side effects
(`feed watch`, `recordings`) catch the refusal, state it,
and leave the foreground command green — the same tier split as the auto-start
circuit breaker. A daemon launched under a temp HOME
keeps its own pid file there, so the real install's pid-file takeover can never
see it: the `HOME=/tmp/pin-e2e-<pid>` daemon a headless e2e session leaked ran
4+ days on yosemite-s1 invisible to every existing surface. Detection lives in
`leaked-daemons.ts` (`findLeakedDaemons`): any same-uid `__daemon-run` whose pid
is neither the service manager's unit main PID nor the recorded `daemon.pid` —
checked against BOTH this process's HOME and the real account home, so a
redirected-HOME caller never accuses the production daemon — is surfaced by
`agents doctor` as a `leaked-daemon` warning with the process's HOME, start
time, and a `kill <pid>` remediation.
**One runtime model: every declared service runs under the supervisor
(RUSH-3193 plus PHNX-3265/PHNX-3608/PHNX-3695).** `cli/src/lib/daemon-services.ts`
defines `DaemonServiceId`, the catalog every id in `runDaemon()` registers under. Two services that used to live here, `secrets-broker` and
`keychain-reap`, moved out of this daemon entirely with the standalone
`secrets` engine (PHNX-3989 OWN-1) — this daemon no longer hosts, self-heals,
or reaps that broker at all; `agents daemon status`/`services` only probes its
reachability (`probeSecretsBroker`, `commands/daemon.ts`), reporting a health
record of `null` for it.
- **Supervised (`ServiceSupervisor`, `supervisor.ts`):**
`browser-ipc`
(`browser-ipc-service.ts`), `account-state` + `account-auth`
(`account-state-daemon-service.ts` — PHNX-3608 split the old single
account-state service into `AccountUsageService` and `AccountAuthService`, two
periodic services with INDEPENDENT circuit breakers so a run of usage-refresh
failures parks only usage and never starves the slower auth refresh), `session-index`
(`session-index-service.ts`) (all P1/P2), and — since P3 — `device-probe` (`device-probe-service.ts`), `self-heal`
(`self-heal-service.ts`), and
`state-dir-check` (`state-dir-check-service.ts`), `session-state`
(`session-state-service.ts`), `feed-stream` (`feed-stream-service.ts`: owns the
ONE fleet feed fan-out and serves it to every reader over a socket, so N
consumers cost one ssh per peer instead of N — PHNX-3999; the socket binds on
start but the fan-out itself is demand-gated on a connected reader), and
`daemon-heartbeat`
(`heartbeat-service.ts`), `tmux-reap` (`tmux-reap-service.ts`), and `auth-sync`
(`auth-sync-service.ts`: also mints and pushes owner-notify device tokens to
workers, PHNX-4267), `usage-sync` (`usage-sync-service.ts`), and — since
PHNX-3695 — `self-update` (`self-update-service.ts`: checks npm for a newer
agents-cli roughly every 75 minutes, installs + byte-verifies it with the
same primitives `agents upgrade` uses, best-effort pulls the `.system`
companion repo and reconciles with `agents sync --local`, then
`process.exit(0)`s so the OS supervisor — launchd `KeepAlive` / systemd
`Restart=always` — relaunches the daemon onto the new code; fails CLOSED on
any step — a failed install/verify leaves the running daemon untouched and
retries next tick, never exits into unverified code. Before any of that, the
tick compares the on-disk `package.json` version (`getCliVersionFresh`) with
the version the process booted with; when another `agents` process has
already upgraded the install underneath the daemon, it exits for the relaunch
without downloading anything — the writer of that install byte-verified it —
once that install has settled (`installLooksSettled`: package.json at rest for
a minute and every `bin` entry present; bun's write is not atomic).
No-ops on a dev build, and on a shadowed install unless the disk is already
newer (a relaunch installs nothing, so a shadow copy cannot make it unsafe);
the shadow no-op is logged once per process. `triggerSelfUpdateInBackground`
(`self-update-service.ts`) starts the same self-update in the background, sharing
the in-flight guard, so an on-demand caller is never parked behind the full
check→download→install→verify. Its former on-demand caller, the browser-IPC
`request-self-update` action, left with the browser engine (PHNX-4101); nothing in
this repo calls it today.
When the tick does not exit for a relaunch, it also brings installed host CLIs
up to their manifest pin (`upgradeOutdatedClis`, `cli-resources.ts`). A
`clis/<name>.yaml` entry such as `npm: "@phnx-labs/secrets-cli@0.1.8"` is a
floor: a binary reporting an older `--version` is reinstalled at the pin into
the npm prefix that owns the copy on PATH (not npm's own prefix, which differs
on an nvm box), then re-probed; `upgraded` is logged only when the binary on
PATH answers with the pin. It never installs a missing tool, never downgrades,
skips a binary that is not an npm install of the pinned package (logged), and
leaves the old version in place on a failed install. Project-layer manifests
never drive it: the project layer is whatever `.agents/clis/` sits above the
daemon's cwd, and an unattended `npm install -g` must not be steerable by a
checkout, so only the user, system and explicitly added repos count. An install
that could not finish inside the tick's remaining deadline is deferred to the
next tick instead of being started and killed mid-write. Async throughout, so
the event loop is never parked on `npm install`.
Each implements the
`DaemonService` contract (`service.ts`) — `id`,
`start`/`stop`/`restart`/`health()`, plus `intervalMs`/`deadlineMs`/`tick()`
for the periodic ones — and is `supervisor.register()`ed in `runDaemon()`
only when `isDaemonServiceEnabled(id)` is true at boot. Every P3 service
registers alongside the P1/P2 batch before `supervisor.startAll()` except
`state-dir-check`, which registers (and starts) separately, right after
`handleShutdown` is declared later in `runDaemon()` — the supervisor fires
an immediate first tick on registration, and `state-dir-check`'s tick calls
`handleShutdown` on a marker mismatch, so registering it before that const
exists would reference it in its temporal dead zone. The supervisor gives
each periodic service a per-tick deadline race and a per-service try/catch that
never escapes to the process-wide crash handler. A THROWN tick is recoverable:
it is recorded (`recordFailure`) and the service keeps ticking on its own
interval, so a transient failure retries in place and no sibling is disturbed.
A tick that OVERRUNS its deadline is aborted (its `AbortSignal` fires), logged
as `tick.breach` with the in-flight snapshot, and recorded as a failure; the
service then skips its interval ticks until that tick settles and resumes on its
own, with no process exit (PHNX-4225). A slow tick is not a wedged daemon: on an
overloaded box the first `getActiveSessions` after boot routinely took 10-15 s,
and exiting for that made the restart itself the load (see the recordings note
below). Only a tick still unsettled at 10x its deadline (`HANG_DEADLINES`), or a
`start()`/`restart()` lifecycle call past `lifecycleDeadlineMs`, is a HANG whose
promise may never settle, so the supervisor records the cause, flushes it to
`daemon-health.ts`, logs `tick.hang`, and EXITS the process (code 70); systemd
(`Restart=always`, `RestartSec=30`, `StartLimitIntervalSec=0`) / launchd
(`KeepAlive` + `ThrottleInterval=30`) restart the whole daemon within ~30s
(PHNX-4116). There is NO `parked` state and NO in-process backoff restart — a
wedged daemon is handed back to its OS supervisor rather than left sitting
unresponsive. SIGHUP control transitions
queue on `awaitIdle()` rather than polling, so a requested toggle applies after
real settlement without another timer owner.
`getServiceSupervisorHealth()` (`daemon.ts:60`)
exposes the live in-process `supervisor.health()` map for a future
same-process reader; a cross-process reader (`agents daemon services`, a
separate CLI invocation) instead reads the persisted mirror below.
- **A service tick MUST NOT do synchronous IO — the deadline race cannot save
it (PHNX-3695).** Every service runs on the daemon's single Node event loop, so
a synchronous `execFileSync`/`readFileSync`/… inside an `onTick`/`onStart` body
freezes that loop for the whole call. While it is frozen NOTHING else on the
loop runs — including the supervisor's own per-tick deadline `setTimeout` and
the browser IPC server's socket handlers, so the deadline race above is
detection only against ASYNC overruns and is powerless against a sync freeze.
That freeze is the "accept but never reply" wedge: the trivial `version` probe
(`browser/ipc.ts:233`) times out only because the loop that would answer it is
blocked. Use async IO on tick paths — `fs/promises`, and `execFileBounded`
([`../exec-bounded.ts`](../exec-bounded.ts)) for a deadline-bounded,
process-group-killable subprocess spawn. The heartbeat tick
(`heartbeat-service.ts`) only publishes the heartbeat and does no blocking IO.
**Self-heal broke the rule through its callee, not its tick body.** Its tick
was async, but `runSelfHeal` → `heal` → `diffVersionResources` byte-compares
every synced resource in every version home with `readFileSync`. On an operator
box with 23 version homes that held the loop for over a minute, so
`attention-notify` breached its 10 s deadline, the supervisor exited (code 70,
the pre-PHNX-4225 overrun policy), and the restart ran self-heal again 30 s later: 1,843 restarts in under three
days with a core pinned. The static guard below cannot see a callee, so a
heavy synchronous pass belongs in a child: the tick now spawns
`agents __self-heal-run` ([`../self-heal/child.ts`](../self-heal/child.ts))
through `driveCooperativeChild`, the `__harness-update-run` pattern. It also
persists the attempt time (`self-heal-last-attempt` in the daemon dir) before
spawning and skips a tick inside the interval, so a restart cannot re-run it.
**A slow tick breaches too, even on a live loop.** The since-removed
monitor engine (PHNX-4241) ran every due poll one after another; with 26
monitors the first tick after boot ran past its 2-minute deadline, the
supervisor exited, and the restart found every poll due again: an exit every
two minutes. Bound a tick's work, not only its IO: cap concurrency, stop
launching new work partway through the deadline, and leave the rest due.
**The worst tick-path halt was the event-log LOCK, and it is reached far more
broadly than one service.** `emit()` (`feed/events.ts`)
acquire the log lock with `withFileLock` → `lockfile.lockSync` + `sleepSync`
(`Atomics.wait`), which HALTS the thread for up to 30s under contention — worse
than any `ps`. And **every `ctx.log(...)` on every tick** routes there
(`daemon.ts` `log()` mirrors into `emit()`). The fix is `withFileLockAsync` +
`emitAsync` (`fs-atomic.ts`, `feed/events.ts`): the async
lock yields with a real timer between retries instead of `sleepSync`. `log()`'s
event mirror is now a fire-and-forget `emitAsync`. The other UNCONDITIONAL every-tick halts are fixed the
same way: **usage-sync + auth-sync** publish through
`updateFleetSharedDeviceStateAsync` (was a `withFileLock` every tick,
`fleet-shared-state.ts`); **keychain-reap**'s whole-process-table `ps` is now
`execFileBounded` (`secrets/reaper.ts`); **tmux-reap**'s per-process
`/proc/<pid>/environ` scan uses `fs/promises` (`tmux/orphan-reap.ts`).
The structural guard `guard-no-sync-io.test.ts` (1) scans every `*-service.ts`
tick/start body for banned sync fs/exec/lock and (2) PINS that each hot tick
call site uses its async variant (`emitAsync`, `getConfigValueAsync`,
`await publish…`, `await reap…`) — a full transitive scan is intractable (every
service imports most of the tree), so it fails if a fix is swapped back to its
sync twin. Startup and stop/uninstall lifecycle code in `daemon.ts` (pid/lock at
`:272`/`:343`, `launchctl`/`systemctl`, the `ps` identity probe behind
`isDaemonRunning`/stop) stays synchronous — it runs in a short-lived
`agents daemon …` CLI process, never on a tick.
**Accepted residue (named, not hidden), a follow-up to this PR.** These are
either µs-scale single-file writes or CONDITIONAL/rare, not the 30s-lock /
whole-table-`ps` halts above: the tiny `writeHeartbeat` + `log()` daemon-log
append (µs; `log()` must stay a sync primitive that flushes before a crash);
`captureProcessStartTime`'s memoized per-helper
`ps` fingerprints (keychain-reap, a small pid subset); and
`session-index`'s synchronous better-sqlite3 (a different class, not a fs/exec
primitive). Converting these is a separate change and does not block the
loop the way the halts this PR removed did.
`runDaemon()` has no bare fixed-cadence maintenance timer and no direct
`setInterval`; every cadence belongs to a supervised service. Resource-local timers may still live inside lower-level socket implementations
(for example the browser IPC server's own idle/reap timers); those own the
resource they maintain and are closed by the surrounding service lifecycle. (The
secrets broker's own eviction timers are no longer an example here — that broker
moved out with the standalone `secrets` engine, PHNX-3989 OWN-1.)
Enabled/disabled state lives separately in a `DaemonServicesConfig`, read
and written via `isDaemonServiceEnabled` (`daemon-services.ts`),
`setDaemonServiceEnabled` (`daemon-services.ts`), and
`listDaemonServiceStates` (`daemon-services.ts`) — one `services.yaml` under
`getDaemonConfigDir()`.
**Cross-process health is file-backed, keyed by the same `DaemonServiceId`
strings the supervisor uses.** `cli/src/lib/daemon-health.ts` is the
one health mirror both the daemon (writer) and `agents daemon` /
`agents daemon services` (a separate reader process) use —
`recordSubsystemOk`/`recordSubsystemError` (ok/error streak + last
error/timestamp) and, since RUSH-3193 P4, `recordSubsystemState` (the
supervisor's `idle`/`running`/`stopped` lifecycle state, written on
every transition in `supervisor.ts`'s `startOne`/`stopOne`). A deadline overrun
does not write a new state — it records the cause via `recordSubsystemError`; only
a hang also writes `recordDaemonRestart` and exits (PHNX-4116, PHNX-4225), so
`agents daemon status`'s restart count is real exits, not slow ticks. Supervised services call
`recordSubsystemState`, so a `SubsystemHealth` record's `state` field being
present is the signal `agents daemon services` uses to render
"measured" vs "inferred" (`commands/daemon.ts`'s `buildServiceRows`).
Every update holds a cross-process `proper-lockfile` lease across the entire
read-modify-write and publishes `health.json` by atomic rename. Readers therefore
see complete JSON, and concurrent daemon/foreground writers preserve every
failure-streak increment used by the auto-start circuit breaker.
**Crash model: any uncaught error in the process kills and restarts the
whole daemon, not just the failing service — except for a supervised service's
THROWN tick, which the supervisor's own try/catch isolates (the service keeps
ticking).** A supervised tick that HANGS (unsettled at 10x its deadline) or a
lifecycle call past its deadline is the one case the supervisor deliberately exits
the process for, so systemd/launchd restart the wedged daemon (PHNX-4116); a tick
that merely overruns is aborted and skipped, never an exit (PHNX-4225). A throw
outside a supervised tick is still uncaught at the process level. `cli/src/index.ts:96-102` installs the
top-level handlers:
```ts
process.on('uncaughtException', crash('uncaughtException'));
process.on('unhandledRejection', crash('unhandledRejection'));
```
`crash()` logs and then calls `process.exit(1)` (`index.ts:100`), relying on
the OS supervisor (launchd `KeepAlive` on macOS, `systemd Restart=always` on
Linux) to relaunch the whole process, so every service restarts together.
Two library/callback boundaries are explicitly contained before that terminal
policy. Direct `proper-lockfile` leases use
`logAndContinueOnLockCompromised` because lock refresh happens on the library's
own timer, outside the owning service promise; a lost advisory lease is logged
without throwing from that timer. The process-level `SIGHUP` registration wraps
`handleReload` with `guardSignalHandler`, so a synchronous reload failure is
logged and leaves the daemon and its other services alive.
**Live enable/disable/restart (RUSH-3193 P4).** `agents daemon services
enable|disable|restart <id>` persists the toggle (or, for `restart`, queues
a one-shot restart request via `queueDaemonServiceRestart` —
`daemon-services.ts`) and then signals the running daemon over the same
`SIGHUP` `agents daemon reload` already used (`signalDaemonReload`) — there
is no separate control socket. The daemon's `handleReload` diffs the reloaded
`services.yaml` against the config it booted with and, for any registered
supervised id, calls `supervisor.start(id)` / `supervisor.stop(id)` live — no
daemon restart. It also drains any queued restart via
`supervisor.restartOne(id)`. Most services disabled at boot are not registered
and therefore still require an operator restart to enable. (The former
always-registered `browser-ipc` exception left with the browser engine, PHNX-4101;
the standalone `browser` CLI runs its own IPC service.)
`agents daemon services` (no subcommand) reads
`readAllSubsystemHealth()` plus `listDaemonServiceStates()` and renders one
row per registered id — state, enabled, consecutive failures, last error —
additively alongside the pre-existing `secretsBroker`/`browserIpc` hosted-
socket fields in `--json` (`commands/daemon.ts`'s `runServices`). An
interactive TTY browser (reusing `dynamicPicker`, `lib/picker.ts`) is
intentionally out of scope for this PR — see the linked follow-up ticket.
**Three cross-device distribution patterns already exist, each hand-rolled
independently — there is no shared abstraction between them:**
- **fanout** (per-peer watch, local process per remote): `watchFleetFeed`
(`cli/src/lib/feed/watch.ts:133`) and `watchFleetSessions`
(`cli/src/lib/session/remote/watch.ts:223`) each spawn one `ssh` child per
peer device and merge their streamed output into one local view.
- **mirror** (every device publishes its own row, readers union all rows):
`publishLocalFleetStatus` (`cli/src/lib/fleet-status.ts:151`) and
`writeAuthHealthEntries` (`cli/src/lib/auth-health.ts:373`, called at
`auth-health.ts:603`) each write a local file that other devices read and
merge — no single device owns the aggregate. `fleet-shared-state.ts` applies
the same pattern to an untracked per-device file: headed usage publishers
write rows that workers merge newest-wins, while auth writes only a safe
readiness verdict and elects one ready source for an exceptional, async,
deadline-bounded secret provision. The transport is the `usage-sync` tick's
SSH exchange (`accounting/usage-sync.ts`, PHNX-4116): a headed box dials each
dialable peer with `agents __usage-ingest --reply` under a 20-second per-peer
deadline, pushing its own envelope and storing the peer's reply stamped
`receivedAt`; a worker never dials. Nothing rides the user repo, so no human
`agents repo sync user` is involved and no git lock exists to contend for.
The daemon used to host a fourth pattern — **elected-singleton** (first-come
binds, others detect and back off) — for the secrets broker, via
`startHostedBroker`/`shouldTakeOverBroker`. That hosting is gone (PHNX-3989,
OWN-1): the standalone `secrets-cli` engine owns its own broker's process
lifecycle exclusively now, and the daemon only probes its reachability
(`probeSecretsBroker` in `cli/src/commands/daemon.ts`) — there is no longer a
daemon-side elected-singleton example to point at.
**Caching is mostly ad-hoc — the bounded primitive is underused.**
`createMemoryCache` (`cli/src/lib/memory-cache.ts:21-23`) is a bounded
LRU+TTL cache (`max` and `ttlMs` are required, with optional
`fetchMethod` coalescing):
```ts
export function createMemoryCache<K extends {}, V extends {}>(options: MemoryCacheOptions<K, V>): LRUCache<K, V>
```
Only 3 call sites actually use it: `cli/src/lib/session/session-cache.ts:78`
and `cli/src/commands/sessions-picker.ts:142` / `:160`. Roughly 28 other
files implement their own ad-hoc TTL constants plus disk-mirror files with
little or no eviction (`fleet-status.ts`, `auth-health.ts`, `mailbox.ts`,
`linear-cache.ts`, `devices/stats-cache.ts`,
and others) — so roughly 3 of ~31 cache-like constructs
in this area route through the shared bounded cache. State this as it is:
most daemon-adjacent caching has no shared eviction policy today.
## Diagnosing the daemon: levels, ticks, spans, vitals
The daemon log (`logs.jsonl`, read with `agents daemon logs`) is the record a
crash loop is diagnosed from, so it explains *why*, not just *that*. Every line
is `{ts, level, message, data?}`; `data` carries the structured fields below.
Levels are `debug < info < warn < error`; the daemon writes at or above its
level (`info` by default), set live with `agents daemon logs level <level>` (stored as
`logLevel` in `services.yaml`, re-read on SIGHUP). `log()` in `daemon.ts` redacts
every string in `data` the same way it redacts the message. Every `log()` is a
synchronous append, so `debug` (a line per tick and per span) adds load of its own:
leave it on only while diagnosing, and read a stall at `debug` with that in mind.
| Event | Level | Written by | What it answers |
|---|---|---|---|
| `tick.start`, `tick.ok` | debug | `ServiceSupervisor.runTick` | every supervised tick and its duration |
| `tick.slow` | warn | `runTick` | a tick that used over half its deadline, with the other in-flight ticks |
| `tick.failed` | warn | `recordFailure` | a thrown tick: error, duration, consecutive failures |
| `tick.breach` | error | `recordOverrun` | a tick past its deadline, aborted and skipped until it settles (no exit): elapsed time, every in-flight tick, the synchronous sections that ran during the tick, span totals since the last vitals line, and a vitals snapshot |
| `tick.settled` | warn | `runTick` | an overrun tick finally settling: its duration and how many interval ticks were skipped meanwhile |
| `tick.hang` | error | `exitForRestart` | the restart cause: a tick unsettled at 10x its deadline or a lifecycle call past its deadline, with the same snapshot as `tick.breach`; the process exits 70 |
| `span.slow` | warn | `diagnostics.ts` `spanSync` | one synchronous section that held the event loop past 250 ms |
| `span` | debug | `spanSync` / `span` | every instrumented section and its duration |
| `loop.stall` | warn | `DaemonVitals` | the event loop did not run for over 1 s, and which instrumented sections ran in that window (or that none did: uninstrumented code, or the process was descheduled) |
| `vitals` | info | `DaemonVitals.report`, once a minute | event-loop delay p50/p99/max, process CPU %, RSS, heap, load average, in-flight ticks, and the top sections by total time |
**Recordings encode one file at a time and reap what a dead daemon left
(PHNX-4225).** `ffmpeg` runs detached in its own process group, so a daemon exit
never takes it down. Before the fix every exit orphaned the in-flight encodes and
the next boot started a fresh encode of every queued recording at once: on one
operator laptop that was 21 concurrent full-resolution encodes (about 1,260
threads) and load 200-400 on 18 cores, and attention-notify's first tick after
every boot ran past 10 s, which exited again. The pipeline now records each
transcode's pid and work dir on its ledger row (`TranscodeJob`),
`recoverInterrupted` kills a pid that still writes into that work dir and deletes
the dir before requeueing, and `drain` starts one recording only when none is
active.
**A deadline breach is usually starvation, not a slow service.** The supervisor's
deadline is a timer on the same event loop as every other service and the feed
hub, so a tick that does almost no work still breaches when something else holds
the loop. That is why the breach line carries what *else* was running rather than
only naming the service whose timer fired. Code that runs outside the supervisor
(the feed hub, `getActiveSessions`, the fleet projection) is attributed through
spans: wrap a new hot path in `spanSync(name, fn)` when it is synchronous and
`span(name, fn)` when it is not. Both are passthroughs outside the daemon, where
no span log is installed.
## Service ownership status
**Shipped:** the `DaemonService`/`PeriodicService` contract and
`ServiceSupervisor` (P1); the first 5 services migrated onto it — secrets-broker,
browser-ipc, account-state, session-index, monitors (P2; `monitors`
was later removed with the feature, PHNX-4241); the operator
surface — `agents daemon services` reporting every registered service's
health plus live `enable`/`disable`/`restart` for the supervised set over the
existing SIGHUP reload path (P4); and the remaining 5 interval-driven
services — `watchdog`, `device-probe`, `self-heal`, `keychain-reap`,
`state-dir-check` — migrated onto the supervisor too (P3), bringing the
supervised total to 10 (`watchdog` was later deleted, PHNX-4439). PHNX-3265 moved live-session publishing and webhook
ingress plus every fixed-cadence maintenance loop onto the same supervisor,
and fixed manual periodic restarts so they
replace, rather than duplicate, the timer. PHNX-3695 added `self-update` as a
new periodic supervised service — the daemon previously ran with
`AGENTS_CLI_DISABLE_AUTO_UPDATE=1` forced on (`bootstrap.ts`), so R5 ("the
installed CLI auto-updates") held for the interactive CLI but silently did NOT
hold for the one process that runs unattended for days. `agents daemon
services` reports measured health for every declared service. (Two services counted in the P2/P3 history
above, `secrets-broker` and `keychain-reap`, no longer exist in this daemon
at all — they moved out entirely with the standalone `secrets` engine,
PHNX-3989 OWN-1.)
This doc's Current architecture section
above is the source of truth for all of it.
**Still open:**
- **Interactive `agents daemon services` browser** (RUSH-3210) — a TTY-only view reusing
`dynamicPicker` (`lib/picker.ts`), one row per service with a live-updating
preview pane (log tail, last error, config) and inline
enable/disable/restart keybindings, mirroring the session
browser behind `agents ps` (`commands/sessions-browser.ts`). Deferred out of the P4 PR.
- **Cross-device distribution patterns** (fanout / mirror / elected-singleton,
described above) formalized as declared `placement` (`every-device` |
`singleton`) and `distribution` (`fanout` | `mirror` | `local`) fields
resolved by shared helpers, instead of independent implementations. Usage and
reserved-auth readiness now share one versioned per-device mirror envelope,
but the other distribution sites remain independently implemented.
- **Caching** routed through `createMemoryCache` under a declared `cache`
policy instead of the ~28 files of ad-hoc TTL constants + disk-mirror files
described above.
## Audit rule: what belongs in a daemon service
Move work behind `DaemonService` when it is autonomous after the initiating
command exits, repeats on a cadence or event edge, owns a long-lived socket or
child, or must expose independent health and restart semantics. By that rule,
the next high-value moves are:
1. **Fleet feed/session watch coordination** — `watchFleetFeed` and
`watchFleetSessions` each create an O(devices) SSH fanout per consumer. A
daemon-hosted coordinator is justified only with one multiplexed local
subscription surface, bounded buffers/backpressure, and explicit peer
unavailable envelopes; moving the same fanout unchanged merely relocates it.
2. **Cross-device orphan/session ownership** — the local `session-state` service
now owns metadata publication, but a peer still must classify its own pane
and publish that fact. The launching device must not infer peer liveness from
absent local signals.
Do not turn synchronous launch-time invariants into background services. Host,
cloud, fork, and local session registration currently duplicate metadata
assembly; that belongs in one pure canonical registration API invoked inside
the launch transaction, with the daemon as reconciler/repairer after crashes.
Foreground-only timers (TTY spinners, log tailing, interactive login polling,
one command's progress display) stay with their foreground command because they
have no useful lifetime once that caller exits.
See [PHNX-3193](https://linear.app/getrush/issue/PHNX-3193) for the original design,
[PHNX-3265](https://linear.app/getrush/issue/PHNX-3265) for the composed hardening audit,
and [RUSH-2654](https://linear.app/getrush/issue/RUSH-2654) for the
originating context. Do not describe P3/distribution/caching work as shipped
until the corresponding code lands.