Skip to content

feat: agent state authority on atch, agent profiles, and title-fallback recovery - #27

Merged
levi770 merged 155 commits into
mainfrom
atch-state-refactor
Jul 29, 2026
Merged

feat: agent state authority on atch, agent profiles, and title-fallback recovery#27
levi770 merged 155 commits into
mainfrom
atch-state-refactor

Conversation

@levi770

@levi770 levi770 commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Replaces screen-scraped guesses about what an agent is doing with state the agent itself reports, moves the terminal runtime onto atch, and adds per-account agent profiles.

Agent state

The daemon owns one canonical state per session on three independent axes: lifecycle (starting/running/exited), activity (working/idle/blocked/unknown) and health (healthy/degraded + reason). Providers post typed facts — activity, blocked/unblocked, heartbeat, tool phases, health — through hooks Desk installs into each supported CLI. Facts are fenced by an inherited session generation, so a producer that outlives a respawn cannot write into the new one, and a producer with no generation stays silent rather than guessing.

unknown is a first-class answer. A session whose producer has not reported has no evidence behind it, and the UI says so instead of inventing a state.

When no semantic producer is reporting, activity degrades to a reading derived from the terminal title. That reading is typed as its own evidence variant, cannot carry a lease, and the schema requires it to appear exactly together with degraded/title-fallback health — a title-derived state can never be presented as an authoritative one.

Reporting is decoupled from delivery. Channel messages are never withheld because an agent is busy: every provider Desk drives buffers typed input, so a message sent mid-turn is read when the turn ends. Only lifecycle holds delivery — a process that does not exist yet cannot receive — and those messages queue rather than drop.

Terminal runtime

tmux is gone from the runtime surface; sessions run under a pinned, vendored atch built as part of the distribution. The daemon owns master connections, emulator snapshots and generation state, with private per-user socket roots validated for ownership and mode before anything binds.

atch gained a bounded ndjson event sink. It is capped and compacts to the retained snapshot (ready, latest state, latest link), and the tailer separates three phases: startup replay restores canonical state without re-publishing history, a truncation resync stays journal-eligible so the transition that triggered compaction is not swallowed, and live appends journal as before.

Agent profiles

A profile is a named provider account. Assigning one points the CLI at ~/.config/desk/profiles/<id> through CLAUDE_CONFIG_DIR or CODEX_HOME, created 0700; the CLI writes and owns everything inside. A profiled launch also unsets inherited provider credentials so an ambient key cannot outrank the selected account. Profile ids are validated at every entry point, a session may not reference an unknown or provider-mismatched profile, and both launch seams share one audited shell quoter.

Conversation transcripts live inside the provider's config directory, so they move with the profile. That is documented, including what it means for --resume and for backups.

Review fixes included

  • desk hooks install and the agent-state runtime both write inside $DESK_HOME, which the uninstall validator's allow-list did not know about, so a machine that had used either could no longer uninstall Desk.
  • The real-atch integration suite never ran in CI: the binary was built after the test step and RUN_REAL_JOIN was set nowhere. It now builds first and runs, covering handshake, generation fence, round-trip, resize, spawn/retire, the binary client path and fd hygiene.
  • The channels delivery trace appended to a fixed /tmp path, which another local user could pre-create as a symlink.
  • The decimal parser in the terminal-title scanner relied on signed overflow; it now rejects out-of-range values.

Verification

Node 22.23.1 / npm 10.9.8 / Bun 1.3.14: typecheck clean; 3146 tests pass, 6 skipped (live provider probes needing real credentials); build:distribution; bundled atch answers --version; smoke:serve-modes leaves no surviving runtime or descendant; post-smoke build; all native -Werror targets, plus ASan and UBSan runs over the title scanner and the event sink.

Docs, README and the guides describe the shipped behaviour, including three corrections where they had overstated what Desk does with credentials, when it withholds messages, and how state is derived.

levi770 added 30 commits July 20, 2026 23:32
…A interlock)

Resolves the wire open-items from the design audit (record_seq envelope,
checkpoint-set, generation-scoped cursors, SIGNAL/STATE_UPDATE/heartbeat/lease_epoch,
capability/HELLO ownership, bounds, crc/checksum). Shared contract both the atch C
fork and the Desk TS codec implement against; conformance = golden vectors.
Consensus w/ @codex: TERMINAL_REPLY (type 70), capability bits, EVENT_STREAM
+journal dedupe, REDRAW retained. Reference codec (frames/codec/messages) is
byte-exact to docs/atch-wire-v3.md; 34 tests green on node 22.23.1; tsc clean.
tests/fixtures/atch-wire/vectors.json (18 valid + 2 invalid) is the shared
conformance suite the atch C fork validates against byte-for-byte.
Extend the reference codec and conformance corpus to every frame type in
the frozen v3 contract, so the C fork can validate byte-for-byte against a
complete vector set rather than a partial one.

- messages.ts: add CHECKPOINT_PUT to the fixed-layout FRAME_SCHEMA (13
  fields incl str16 xterm_version/patch_version, b32 checksum, blob32
  snapshot); add dedicated codecs for the two variable-shape frames that
  do not fit a flat schema — encodeCheckpointData/decodeCheckpointData
  (present discriminator gates the conditional snapshot body) and
  encodeJournalData/decodeJournalData (length-prefixed record array).
- tests: round-trip the two variable-shape frames (present=1/present=0,
  multi-record journal) and fold RECORD + CHECKPOINT_DATA + JOURNAL_DATA
  into golden-vector generation.
- vectors.json: regenerated — 30 valid vectors covering all 30 distinct
  frame types + 2 invalid. Complete conformance corpus.

46 tests green on node v22.23.1; tsc --noEmit 0 errors.
…nce (§6, C16)

The pure-logic core of the control plane the daemon and the web-server
projections both build on. No I/O — persistence is expressed as ports so
tests drive in-memory reference stores and the daemon later supplies fsync'd
ones. Lives in src/shared (below src/server) so both layers can import it.

- model.ts: ControlState = frozen LifecycleStatus widened with `unknown`
  (deliberate union extension, not a coerced idle); Source precedence
  typed-hook > native-fsm > worker-rendered > unknown; per-source freshness
  TTLs; resolveState = precedence + staleness-DROP (a stale source is removed
  from resolution, never masked with unknown, so a fresh lower source can win;
  all-stale → unknown, the fail-closed floor, never sticky-working).
- session.ts: the per-session reducer. Higher-generation event drops all
  prior-generation contributions (dead-process sources); lower-generation
  ignored (defense in depth behind the fence); within a generation only a
  strictly-higher sourceSeq replaces (reorder-safe). refreshSessionState
  re-resolves on a timer so staleness-driven transitions are not missed.
  Freshness is measured from event.ts (production time), so a delayed stale
  event cannot masquerade as fresh.
- intake.ts: §6.3 generation fence (reject carried != ledger-current, never
  stamp before fencing) + §6.5 exactly-once intake. The store's commit()
  dedupes invocationId BEFORE allocating sourceSeq and does both in one atomic
  durable write, so a lost-ACK retry returns the SAME eventId (not a second
  count) and a torn allocate/record is unrepresentable. Fence-first ordering
  means a retry after a generation change is fenced, not deduped.
- consumer.ts: §6.5 exactly-once side effects. Cursor + durable receipt set;
  apply-effect-and-write-receipt atomically (outbox), THEN advance cursor, so
  a crash in the gap replays into a receipt-guarded no-op. Cursor/effect
  atomicity is deliberately not required — only effect+receipt — which is what
  makes it implementable over fsync.

21 control-plane tests (precedence, staleness-drop, fence, intake dedupe,
consumer replay/crash) + 46 wire = 67 green on node v22.23.1; tsc 0 errors.
…0, H1)

Separates transport acceptance from delivery, fixing the H1 conflation the old
durability layer had (`.delivered` = "paste landed AND pane went working"). Pure
src/shared logic; the daemon supplies transport/confirmation adapters and
persists fsync-on-write.

- phases.ts: the durable txn tuple {txnId, bodyKey, submitKey, phase} and the
  phase FSM queued → body-accepted → submit-accepted → submit-confirmed → done,
  plus semantic-unknown (held, fail-closed) and stuck. A MARKED confirm (a typed
  hook echoing the txnId) is authoritative and confirms from any pre-terminal
  phase even with lost transport ACKs; an UNMARKED confirm (pane-went-working)
  applies ONLY from submit-accepted and never rescues semantic-unknown — so a
  hook that omits/mis-correlates the marker leaves the txn held, never
  auto-confirmed. The engine is idempotent (duplicate/out-of-order ACKs and
  post-terminal inputs are absorbed). recoverAction maps each persisted phase to
  the safe next transport step after a crash.
- cmdCache.ts: per-(session,generation) PREPARED/WRITTEN/ACKED store. A retry
  re-sends the same stepKey; the cache returns the cached ACKED result (dedupe),
  re-issues an ambiguous PREPARED/WRITTEN step (safe — master is idempotent by
  key), or REFUSES once the entry ages past the retry horizon (fail-closed —
  a replay whose dedupe proof is gone must not risk a double-submit). Horizon
  measured from record creation, never bumped; default 10 min / 512 entries.
  dropGeneration supersedes a dead process's keys.

16 delivery tests (FSM happy path, accepted!=delivered, lost-ACK marked rescue,
concurrency-safe unmarked confirm, semantic-unknown hold, horizon refuse,
generation drop, LRU trim) + 67 = 83 green on node v22.23.1; tsc 0 errors.
…trix (§7.4/7.7)

The web-server ↔ browser contract, replacing today's string-only JSON. Pure
src/shared, fully unit-tested; the web server and the browser terminalBrokerClient
build against it. This is DESK-lane only (not the atch C seam), so the type-ID /
width table left [CHECK] in the spec is designed and frozen here.

- frames.ts: 2-byte header (version,type) + typed payload; each WS binary
  message is one frame (no magic/reassembly — WS keeps boundaries). A SUBSCRIBE
  assigns a compact u32 channelId so the hot output path routes by handle, not
  by repeating session/surface strings. Client ids 1–15, server ids 16–31, so
  direction is readable from the type. Bounds + BpError codes.
- codec.ts: encode/decode for all 14 frames, reusing the tested atch-wire LE
  ByteWriter/ByteReader. Every data frame carries generation+revision (stale
  discard); output bytes are binary end-to-end (0x00..0xFF preserved, §7.8);
  offsets are u64 BigInt; exit code is signed i32; snapshots carry the
  SerializeAddon string as UTF-8. Validates version/type/bounds → TRUNCATED /
  UNKNOWN_TYPE / PAYLOAD_TOO_LARGE.
- querySuppression.ts: the §7.7 responder matrix (worker answers DA/DSR/CPR/
  DECRQM/XTVERSION + char-geometry locally; the lease-owning surface answers
  pixel-geometry/color/focus) and the CSI/OSC query classifier. Keyed on the
  report form so an OSC 4/10/11/12 color SET passes through unsuppressed while
  only the `?` query is suppressed+routed — avoiding the over-suppression trap
  that would silently break themes. Reused by §7.9's output-side stripper.

41 new tests (14-frame round-trip incl signed/BigInt/byte-integrity edges,
version/type/bounds rejection, the full responder matrix, set-vs-query OSC).
…ape, not a raw NUL

The (session,invocation) and (session,source) Map-key separators were emitted
as literal NUL bytes, which is collision-safe at runtime but made the source
files git-binary and unreviewable (and mislabeled by file(1)). Replace the raw
NUL with the \u0000 escape: identical runtime NUL separator, pure-ASCII source.
No behavior change; all control-plane + delivery tests stay green.
…PC (§3.2/3.3)

The correctness-critical, spawn-free cores of the one-per-user daemon, built as
pure src/shared logic so the resource-safety, split-brain, and version rules are
unit-testable without processes. The daemon composes these with fs/process/socket
adapters and the emulator port.

- workerSupervisor.ts: admission is the single chokepoint — admits up to
  MAX_LIVE_WORKERS (256) then FAILS CLOSED (refuse new + surfaced reason, never
  over-commit); shards headless emulators across N processes once live > max/2
  (§3.3); bounded-exponential restart backoff; visible-first restore batching at
  restoreConcurrency (8). Deterministic shard placement by sessionId hash.
- instanceLock.ts: the §3.2 single-instance decision keyed on PID *and* process
  start-time, so a recycled PID after a crash reads as stale (stale-pid-reused)
  instead of split-braining two daemons or deferring forever to a dead one.
  Fail-forward on an unreadable start-time (the atomic write+re-read guards the
  real race). decideStop refuses while sessions live unless --forced (§11.4).
- rpcEnvelope.ts: versioned control-plane RPC (ensure/stop/list/lease/state);
  rejects a version mismatch BEFORE trusting the shape so a mismatched-version
  peer fails cleanly rather than misparsing. Hot terminal path stays on the
  binary wire/browser protocols, not this.
- emulatorPort.ts: the narrow interface the daemon needs from the authoritative
  headless emulator (write bytes, §6.8 plain-text classifier tail, §7.3
  SerializeAddon snapshot, cursor for CPR/DSR, semantic-event hooks). Lets the
  daemon be tested against a fake; the real @xterm/headless adapter (H10
  packaging dep) is a documented thin shim.

17 tests (cap fail-closed, idempotent admit, shard thresholds, bounded backoff,
visible-first restore, PID-reuse lock, stop refusal, RPC version negotiation).
The daemon's per-session data-plane + control-plane orchestrator, tying the atch
wire codec (master I/O), the emulator port (screen), the browser protocol
(loss-aware WS), the control plane (lifecycle), and the delivery-phase engine
into one testable vertical. Pure over injected ports/callbacks — no sockets or
processes — so the full path is unit-testable; the daemon instantiates one per
session with real adapters.

Flows wired:
- master OUTPUT record → emulator.write + a browser OUTPUT frame to every
  subscriber, byte-exact (0x00..0xFF preserved), carrying generation/revision/
  offset for stale-discard.
- master RESIZE record → emulator.resize + revision bump (geometry-before-
  snapshot).
- master EVENT(EXIT) → browser EXIT frame with signed i32 code.
- subscribe → SUBSCRIBE_ACK + baseline SNAPSHOT at the current output offset.
- browser INPUT → a decodable atch INPUT frame to the master (surface/lease
  provenance preserved).
- typed hook → control-plane intake (fence + stamp) → session state, AND — when
  the hook echoes a txnId marker — an authoritative delivery confirm advancing
  that txn to submit-confirmed. A stale-generation hook is fenced (no state
  change); a retried hook (same invocationId) is idempotent (one eventId).

Lease handoff (§7.9), checkpoint restore (§8.1), GAP resync (§7.4), and native
surfaces (§6.9) are documented extension points wired the same way.

8 integration tests drive REAL wire records + REAL browser frames through the
runtime with a fake emulator. Total across the new pure layers: 149 tests green
on node v22.23.1; tsc 0 errors.
…M (§10)

Pure src/shared logic for the tmuxSession → sessionId migration. The daemon/CLI
drive the FS stores and schema bump; these encode the rules that must be exact.

- sessionId.ts: grammar ^[a-z][a-z0-9-]{2,63}$ (starts with a letter, 3–64
  chars); assertMintable rejects grammar + collision; mintSessionId slugs any
  name into grammar and dedupes with numeric suffixes; checkGlobalUniqueness
  fails the migration BEFORE commit on a duplicate.
- submitStateRepair.ts: the round-7A repair map. THE rule — nothing legacy
  imports as `done`. Legacy `.delivered` conflated genuinely-submitted with
  delivery-ack-timeout, so it imports as semantic-unknown (held, fail-closed)
  unless independent durable proof lifts it to submit-confirmed; the stuck-*
  variants likewise; only json/delivering/stuck-paste reset to queued for a
  safe re-deliver. Every record gets a fresh txn. planDrain: a complete quiesce
  drain skips import, an incomplete one imports per-file.
- migrationPhases.ts: the journaled resumable FSM quiesce→backup→transform→
  validate→commit→done. Failure before a backup unquiesces; after a backup it
  restores it. Resume re-runs the last-journaled (idempotent) phase. Browser
  localStorage is a separate schema negotiation on load (migrate-on-read /
  clear-and-rederive), not part of the server FS migration.

16 tests (grammar edges, minting/dedup, uniqueness, the never-done invariant
across all 7 legacy states, drain planning, phase advance/rollback/resume,
schema negotiation).
Pure src/shared. Tracks whether a session's terminal state is exact along two
INDEPENDENT axes plus per-buffer provenance, with the sticky recovery_lost flag
that clears only via a real hidden-state oracle. The daemon persists this in the
registry and surfaces it in ATTACH_ACK + GAP.

- current_state_exact (i) vs restart_recoverable (ii) are NOT collapsed: journal
  retention truncation while the worker is ALIVE kills (ii) — the bytes to
  rebuild are gone — but leaves (i) true (the live emulator never lost a byte,
  the screen stays trusted, NOT marked unknown). A worker crash mid-stream kills
  (i) but may leave (ii) true. Collapsing them would either blank a good live
  screen or promise an impossible recovery.
- per-buffer provenance (iii): main and alt tracked separately; a truncated
  restore marks only that buffer inexact.
- re-establishment requires an oracle that covers HIDDEN state (tab-stops /
  charsets / saved-cursor / modes): RIS-reset, app-declared snapshot, process
  restart, or a fresh exact checkpoint from a continuously-authoritative worker.
  A later-checkpoint of an incomplete buffer, resize/SIGWINCH, an alt-cycle, or a
  visual repaint do NOT re-establish (alt-cycle exit restores the still-incomplete
  main). recovery_lost clears only when both buffers are exact again.
- DISPLAY/attach drives off (i)+(iii); takeover/restart-planning off (ii).

10 tests (axis independence, per-buffer provenance, valid vs invalid oracles,
alt-cycle never rescuing main). 175 new tests total; tsc 0 errors on node v22.
…5/§7.9)

Pure state machine. Exactly one focused surface holds the controller lease; only
the owner drives INPUT and RESIZE, hidden surfaces are observers. The daemon owns
one LeaseState per session.

- claim/grant: unheld → grant + epoch++ (handoff from no-one); same-owner
  re-claim refreshes heartbeat with NO epoch bump (no handoff); held + not forced
  → DENIED; held + forced → demote the current owner (controller{released} event)
  + grant + epoch++. Every grant returns the ackOffset the new controller must
  replay to before driving (catch-up).
- release frees the lease (owner-only); the next grant bumps the epoch.
- sweepTtl auto-releases an owner whose heartbeat lapsed past LEASE_TTL (15s) so
  the daemon reclaims a dead controller; heartbeat refreshes it.
- isFencedEpoch drops a reply/resize carrying a prior owner's lease_epoch — the
  same fence that guards TERMINAL_REPLY frames (§7.7), so an in-flight reply from
  a demoted owner never injects after handoff.

7 tests (grant/deny/forced-demote/re-claim, release + TTL sweep + heartbeat
refresh, epoch fencing). 182 new tests total; tsc 0 errors on node v22.23.1.
…ckfill invalid vectors

Cross-review of @codex's C decoder (atch_wire_v3.c) surfaced a conformance
divergence the golden vectors did not catch: his reserved mask is 0xfffffff0
(bit4 reserved), mine was 0xffffffe0 — I had excluded COMPRESSED (bit4) from the
reserved set, so my TS decoder silently ACCEPTED a reserved-must-be-0 bit set,
violating doc §1.1 ("bit4 COMPRESSED (reserved, must be 0); reserved bits nonzero
under STRICT → BAD_FLAGS"). His C is correct; my TS was wrong.

- frames.ts: RESERVED_FLAG_MASK now = 0xfffffff0 (only the four live flags
  ROLE_CONTROLLER/ROLE_OBSERVER/MORE/STRICT permitted); COMPRESSED stays a named
  constant for documentation but is treated as reserved. Matches the C mask.
- tests: assert COMPRESSED (bit4) under STRICT → BAD_FLAGS, and RESERVED_FLAG_MASK
  == 0xfffffff0. The prior reserved-flag test used bit20 (in BOTH masks), which is
  why the divergence hid.
- vectors.json: backfill the doc §4.7 required invalid vectors that were missing —
  reserved_flag_strict (BAD_FLAGS) and payload_too_large (PAYLOAD_TOO_LARGE); the
  golden-vector test now self-verifies every invalid vector decodes to its code.
  Invalid vectors 2 → 4. (More required-coverage invalid vectors — truncated,
  bad-sequence, geometry/str16/blob32, record-crc, checkpoint-format — still to
  add pending a shared streaming-vs-oneshot "truncated" convention with @codex.)

47 wire + 183 new tests green on node v22.23.1; tsc 0 errors.
…§3.6, C14)

Supplies the daemon-owned native lifecycle contract the atch C lane is blocked
on. atch is TERMINAL-ONLY (§5.2 C14: "native is not atch's") — it spawns PTY
sessions and nothing else; forcing native/general session lifecycle into the C
master is out of scope and is what breaks its terminal-only legacy tests. NATIVE
(non-PTY) sessions are supervised by the DAEMON's agent-host (AgentSurfaceBroker
moved to the daemon, §3.6), which owns their process lifecycle and projects it to
the control-plane native-fsm source.

Pure state machine, testable without spawning; the daemon drives real host
processes over the stable socket.
- NativeHostPhase FSM: starting → ready → working/idle, with exit (terminal) and
  crash (restart candidate, bumps the count).
- nativeControlState projects the host to a control-plane ControlState:
  working/idle/blocked/awaiting-approval from the last AgentSurface command-result;
  starting/exited/crashed → unknown (fail-closed, never coerced to idle).
- decideNativeRestart: bounded-exponential backoff, gives up past maxRestarts
  (fail-closed — no infinite respawn), never restarts a non-crashed host.

8 tests. Establishes the atch-terminal-only vs daemon-native boundary as
executable code so the lanes agree on ownership.
…(§4.8.1, H8)

Closes a real gap: the §6.3 fence reads currentGeneration, but the durable
ledger it must read from was abstracted behind a port and never built. The
per-session registry holds the current generation, but `rm` deletes it — so a
reused sessionId would reset generation to 1 and let a delayed OLD hook
(carrying generation=1) pass the fence against the NEW session.

Fix (pure logic + durable port; daemon owns the fsync'd append-only file):
- allocate(sessionId) = max(ever-seen)+1, strictly monotonic, TOMBSTONE-surviving:
  session deletion removes the registry elsewhere but the ledger entry stays, so
  a recreated sessionId always gets a higher generation — never reset, never
  reissued. The daemon fsyncs the allocation BEFORE the registry write and BEFORE
  the child spawn (§4.8.1 order).
- a lowering write is refused (compaction/replay must never reissue a generation).

5 tests including the end-to-end proof: allocate gen1 → delete+recreate → gen2,
and a delayed hook carrying gen1 is fenced against the gen2 session. 196 tests
total; tsc 0 errors.
The logic that makes the binary protocol actually "loss-aware" — I had the
framing but not the per-subscription resync state machine. One per channel;
decisions are LOCAL and order-independent because every server data frame carries
(generation, revision, offset).

- awaiting-snapshot → live → dirty → resyncing. Stays live only while output is
  CONTIGUOUS (offset === expected); any discontinuity re-baselines from a
  snapshot instead of rendering a corrupted screen from partial deltas.
- per-frame decision: older gen, or same-gen older rev → discard (stale
  straggler); newer gen (session recreated) or newer rev (geometry advanced) →
  dirty + owe a fresh snapshot; current gen+rev with offset gap → dirty; overlap
  (offset < expected) → discard; contiguous → apply + advance.
- an explicit GAP frame or local WS backpressure/high-water → dirty; a stale
  snapshot behind the current baseline is discarded.

9 tests (baseline+contiguous, overlap discard, gap→dirty→resnapshot→resume,
GAP/backpressure, stale vs advanced gen/rev both directions). 205 tests total;
tsc 0 errors.
…der (§8.1, C3)

Pure decision implementing the §8.1 recovery ladder. Authoritative worker
recovery must be EXACT, so it picks in priority order:
1. exact-checkpoint — a kind-0 AUTHORITATIVE pinned-patch checkpoint whose
   format/xterm/patch versions MATCH the worker, sitting within the retained
   journal (retainedStart ≤ offset ≤ tail) with a contiguous crc-verified
   checkpoint→tail range and within MAX_CHECKPOINT; restore = apply the
   complete-state blob then replay offset→tail.
2. full-replay — no usable checkpoint but the journal is retained from process
   start and contiguous: exact by construction.
3. degrade — otherwise fail closed to approximate recovery + resync (§8.2); the
   caller marks recovery_lost. Never fabricate an exact restore.

A kind-1 DISPLAY checkpoint (lossy SerializeAddon framebuffer) is NEVER used for
authoritative recovery — it is the browser baseline only; it still permits full
replay when the journal is whole.

9 tests (exact restore; fall back on version-mismatch/offset-outside/oversize/
no-checkpoint; degrade when versions mismatch AND journal truncated, on
torn range, and on display-only checkpoint). 214 tests total; tsc 0 errors.
Pure read-side projector shared by authoritative recovery (§8.1 replay
checkpoint→tail) and history (§7.4). The atch C fork owns the journal WRITE
(segment format); this is the daemon's replay.

- orders records by record_seq (the TOTAL order) — output_offset alone can't
  order RESIZE/EVENT vs OUTPUT since they interleave at the same offset.
- projects each type to a replay action: OUTPUT→feed bytes, RESIZE→geometry,
  EVENT→dedupe+project, CHECKPOINT_MARK→baseline anchor, TRUNCATION→declare gap.
- EVENT dedupe by (generation, output_offset, eventType) so overlapping-segment
  replay never double-projects an event.
- a TRUNCATION record legitimately declares a gap and advances expected seq to
  its target; an UNDECLARED record_seq hole (loss inside the retained range) is
  flagged as discontinuityAt so the caller marks the buffer inexact (§8.2).

6 tests (total-order vs offset, per-type projection, dedupe, distinct-offset
kept, truncation gap without false discontinuity, undeclared-hole flagged).
…odex review)

@codex cross-review caught a real bug: onSnapshot gated the stale gen/rev discard
on state === 'live', so after a gap dropped the subscription to dirty/resyncing,
an out-of-order STALE snapshot (older generation/revision than the remembered
baseline) skipped the guard and REGRESSED the baseline. §7.4 requires stale
discard in every non-initial state.

Fix: gate the discard on an explicit hasBaseline flag (set on the first applied
snapshot) instead of state === 'live'. A stale snapshot is now discarded whether
we are live, dirty, or resyncing; only a current-or-newer snapshot re-baselines.

Regression tests: a stale older-generation snapshot arriving DURING resync is
discarded and does not regress the baseline (the genuine fresh snapshot then
resumes); a stale-revision snapshot during dirty is likewise discarded. 11 resync
tests; tsc 0 errors.
Materializes two of the §15 shipping gates as executable tests over the shipped
codecs.
- OUTPUT (§7.8): a torture body of all 256 byte values plus a hostile
  invalid-UTF-8/NUL tail is fed through the wire FrameReassembler at EVERY split
  boundary and one byte at a time, asserting byte-exact reassembly — proving the
  binary-end-to-end property no chunk boundary can corrupt. Plus the browser
  OUTPUT frame round-trip preserving the same bytes.
- INPUT (§7.6 two channels): onBinary preserves all 256 values 1:1; onData
  round-trips arbitrary UTF-8 incl control + multibyte + astral; the binary flag
  keeps the two channels distinguishable for identical bytes.

6 tests. No production change — a gate over existing code.
Ties the whole DESK lane into a callable daemon: the durable generation ledger
(§4.8.1), the worker supervisor's fail-closed cap (§3.3), a per-session
SessionRuntime (§7.1), and the controller lease (§7.9). Pure over injected
ports/callbacks — the unix-socket server, RPC transport, and real host processes
are the thin outer shell added at integration; this is the logic they drive.

- ensure(sessionId, geometry): get-or-create. Admission is the fail-closed
  chokepoint (past MAX_LIVE_WORKERS → cap-exceeded); a NEW session's generation
  is allocated from the durable ledger, so a reused sessionId after retire gets a
  HIGHER generation — the §6.3 fence holds across delete+recreate at the daemon
  level (tested end-to-end: retire → recreate → generation 2, never reset to 1).
- retire frees the supervisor slot but keeps the ledger tombstone.
- list / state project the control-plane model; onMasterRecord / subscribe /
  onBrowserInput / ingestHook route to the session runtime; claimLease /
  releaseLease drive the per-session lease; canStop refuses while sessions live
  unless forced (§11.4).

9 tests (ensure idempotence, cap fail-closed, the fence property, retire frees a
slot, output/input routing, hook-driven state + list/state projection, lease
claim/deny/release, stop refusal). Full suite 2585 passing, project tsc 0.
…3.7)

Turns the pure DaemonCore into an actual running process, Node stdlib only (no
atch binary, no @xterm/headless), so it runs and is testable today; the real
master link and the @xterm/headless emulator factory drop in at lane-join behind
the same DaemonCore + EmulatorFactory seams.

- single-instance unix-socket server: acquires the PID+start-time lock
  (decideLock), repairs a stale lock (dead pid / PID reuse), refuses to start
  when a live peer holds it; 0700 dir / 0600 sock; atomic lockfile write.
- newline-framed versioned RPC (rpcEnvelope) dispatched into DaemonCore:
  ping / ensure / retire / list / state / stop; unknown method + unknown session
  return typed errors; stop refuses while sessions live unless forced (§11.4).
- readProcStartTime parses /proc/<pid>/stat field 22; probePid does the
  kill(pid,0) existence check + start-time read (the PID-reuse guard).

5 integration tests over a REAL unix socket (ping; ensure→list→state; typed
errors; stop refusal; stale-lock repair), stable across repeated runs. The
live-peer defer path needs a 2nd OS process and is unit-tested in runtime.test.ts.
tsc 0 errors.
…tart (§4.8.1)

The fence-critical durable backing behind GenerationLedger. Append-only,
fsync-per-allocation, Node stdlib only. Demonstrates the durable-adapter pattern
the other daemon stores (intake dedupe/allocator, consumer receipts/cursor,
CMD_CACHE) follow.

- write appends {s,g} + fsyncs BEFORE returning, so the allocation is durable
  before the daemon writes the registry + spawns (the §4.8.1 order).
- on startup the log is replayed to rebuild the max-per-sessionId map; a torn
  final line (crash mid-append) is skipped — safe because a torn append means the
  spawn that would have consumed that generation never happened.
- monotonic guard: a lowering write is refused across instances (never reissue a
  generation).

5 tests including THE restart property (a fresh store recovers the max and
continues higher — the fence stays sound across a daemon restart), a reused
sessionId after restart never resetting to 1, torn-tail skip, and the
cross-instance monotonic guard. tsc 0 errors.
levi770 and others added 18 commits July 28, 2026 13:25
A mid-turn agent is not unreachable. Every provider Desk drives buffers
typed input and consumes it when the turn ends -- the same thing that
happens when an operator types a follow-up without waiting. Refusing
delivery on `working` bought no safety and cost the thing the channel
exists for: a busy agent looked unreachable, and the operator had to
watch the lamp before daring to speak.

`blocked` still refuses, for a different reason than "busy": a blocked
session sits on a prompt that CONSUMES the next input, so a channel
message would be read as an approval the operator never gave. Two tests
that encoded the old rule now pin this distinction instead.

Also fixes a precedence bug found while testing the above. The hook shim
resolved its provider as `DESK_AGENT || --agent`, so an ambient
environment variable outranked the flag Desk itself writes into the hook
command at install time. A hook firing under another session's
environment -- a nested shell, a spawned helper -- was therefore
attributed to the wrong provider, and a mislabelled producer is worse
than a silent one: it is accepted as evidence about a session that did
nothing. The argument now wins; the variable remains a fallback for a
hand-written hook that names no agent. The defect was invisible until
this session gained a DESK_AGENT of its own.

Wizard, per the operator's list: terminal is the default UI mode because
it works for every agent Desk drives, native is marked experimental
where the choice is actually made, and the submit button says what the
operator is doing -- create or save -- rather than naming the manifest
write. Creating a profile from the wizard is a DETOUR that returns to
the form: `openAddSession` resets it, so a plain jump to Settings would
have discarded whatever had been typed.

The narrow-viewport fix underneath it is deliberate and wider than the
button. `.thinForm` used `1fr`, which floors a column at its content's
min-content width, so one long account label or path pushed the whole
form past its modal. With `minmax(0, 1fr)` plus an ellipsis on the
select's value, a 320px container now fits the picker and its new action
exactly, measured rather than eyeballed.
The operator's rule, stated plainly after seeing the argument for the
exception: the engine must never withhold a message. So no activity
refuses any more -- not working, not blocked, not unknown.

The case with the strongest argument against this was `blocked`: a
session sitting on an approval prompt consumes the next input, so an
arriving message can be read as an answer nobody gave. That risk is
real, and it is the operator's to take. It is visible the moment it
happens and recoverable; a channel that silently keeps messages is
neither, and a messaging surface whose messages sometimes do not arrive
is not a messaging surface. Every state this gate refused on was one the
operator could see and reason about themselves.

Reporting and gating were one wire and are now two. `activity` and
`actionable` still describe the session for the lamp and the status dot;
they no longer decide whether text is sent.

Lifecycle is untouched and is not the same thing: `starting` and
`exited` are about whether a process exists to receive at all, and both
QUEUE rather than drop. Holding a prompt for a session that is still
coming up delivers it a moment later; delivering into a dead pane loses
it.

Removing the gate made four DeliveryBlockReason members unreachable --
input-requested, not-ready, operator-blocked, provider-blocked -- along
with the function that produced them. They are deleted rather than left
in the union: a reason nothing emits is a state the console would render
and an operator would try to interpret.
The docs described a product that no longer exists in three places, and one
of them was about credentials.

SECURITY FIRST. The FAQ answered "does Desk store my model credentials?" with
a flat "no", and the security checklist told operators to keep credentials in
their tool-managed locations. Neither survives agent profiles: a profiled
session authenticates INTO ~/.config/desk/profiles/<id>, because Desk points
the CLI there with CLAUDE_CONFIG_DIR or CODEX_HOME. Desk still never reads or
forwards those files, and both answers now say exactly that instead of a
comforting simplification. The backup answer listed desk.yml, channels, and
notes -- an operator restoring from that list gets a workspace whose agents
are all logged out, so it now names the profiles directory and says why it is
the one that hurts to lose.

DELIVERY. README's highlight list promised that a message "never interrupts an
agent mid-turn", and the protocol reference carried a whole section on marking
agents busy and releasing the queue on a turn-complete signal. Activity has
stopped gating delivery entirely, so both were advertising the opposite of the
behaviour. The replacement explains why the old rule was wrong -- every agent
CLI buffers typed input, so a mid-turn agent was never unreachable -- and what
still holds a queue: lifecycle, which asks whether a process exists to receive
at all, and operator pause.

STATE. Four documents described attention as OSC 9 and BEL parsed out of the
terminal, with a poller for unattached sessions. That machinery is gone;
agents report typed lifecycle facts through hooks. The rewrite names the facts
a provider can assert, and says plainly that anything unasserted stays
`unknown` -- which is the property that makes the lamp worth looking at.
agent-integrations.md also printed a Codex launch line with notification flags
Desk no longer passes.

PROFILES were documented nowhere: not a field, not a path, not a page, while
configuration.md actively contradicted the parser by calling the manifest
three top-level blocks when it accepts four. There is now a reference section
covering the schema, the credential-directory mechanism, and the two
consequences an operator meets in practice -- credentials move, and Claude
keeps transcripts per config directory, so switching a live session's profile
loses --resume.

One word had two meanings across the set: agents-and-terminals.md called the
built-in CLI integrations "profiles". They are integrations; a profile is an
account.

Moves atch-cutover-plan, atch-item2-server-lane-plan, and
agent-state-research-claude out of the repo to the workspace root: they are
working records of how the migration went, not product documentation, and
nothing referenced them.
The docs described a product that no longer exists in three places, and one
of them was about credentials.

SECURITY FIRST. The FAQ answered "does Desk store my model credentials?" with
a flat "no", and the security checklist told operators to keep credentials in
their tool-managed locations. Neither survives agent profiles: a profiled
session authenticates INTO ~/.config/desk/profiles/<id>, because Desk points
the CLI there with CLAUDE_CONFIG_DIR or CODEX_HOME. Desk still never reads or
forwards those files, and both answers now say exactly that instead of a
comforting simplification. The backup answer listed desk.yml, channels, and
notes -- an operator restoring from that list gets a workspace whose agents
are all logged out, so it now names the profiles directory and says why it is
the one that hurts to lose.

DELIVERY. README's highlight list promised that a message "never interrupts an
agent mid-turn", and the protocol reference carried a whole section on marking
agents busy and releasing the queue on a turn-complete signal. Activity has
stopped gating delivery entirely, so both were advertising the opposite of the
behaviour. The replacement explains why the old rule was wrong -- every agent
CLI buffers typed input, so a mid-turn agent was never unreachable -- and what
still holds a queue: lifecycle, which asks whether a process exists to receive
at all, and operator pause.

STATE. Four documents described attention as OSC 9 and BEL parsed out of the
terminal, with a poller for unattached sessions. That machinery is gone;
agents report typed lifecycle facts through hooks. The rewrite names the facts
a provider can assert, and says plainly that anything unasserted stays
`unknown` -- which is the property that makes the lamp worth looking at.
agent-integrations.md also printed a Codex launch line with notification flags
Desk no longer passes.

PROFILES were documented nowhere: not a field, not a path, not a page, while
configuration.md actively contradicted the parser by calling the manifest
three top-level blocks when it accepts four. There is now a reference section
covering the schema, the credential-directory mechanism, and the two
consequences an operator meets in practice -- credentials move, and Claude
keeps transcripts per config directory, so switching a live session's profile
loses --resume.

One word had two meanings across the set: agents-and-terminals.md called the
built-in CLI integrations "profiles". They are integrations; a profile is an
account.
Desk launches OpenCode with OPENCODE_CONFIG_DIR pointed at its own config
root, and OpenCode loads plugins from the directory it is given. The
installer wrote to `~/.config/opencode/plugin/` instead -- the operator's
own OpenCode config, which no Desk-launched session reads.

The consequence was silent and total: every Desk session loaded whatever
plugin happened to be in Desk's root, which on this machine was one from
an earlier release speaking a retired schema. The current plugin, and the
adapter work that shipped with it, never reached a running agent. Both
halves had passing tests, because each tested its own side. `desk hooks
install` printed `installed` throughout.

The installer and the probe now derive the path from the same helper the
launch command uses, and honour DESK_OPENCODE_CONFIG_DIR for the same
reason -- an override moves the read side, so an installer that ignored it
would restore exactly this bug.

The new seam test never names a literal path. It parses the config dir out
of the generated launch command and asserts the installer targets it, so
it fails if either half moves. Two existing tests that hardcoded the old
literal are rewritten to derive it too: a literal in a test is what let the
two directories drift apart with nothing noticing.
The session form pre-selects terminal, but an omitted `uiMode` still
resolved to native. The product therefore gave two different answers to
"what is the default": one for an operator using the wizard, another for an
operator editing desk.yml by hand -- and the configuration reference,
correctly describing the manifest, documented the answer the UI did not
give.

Terminal is the right default for both. It runs the CLI's own TUI, so
anything the agent can do the session can do; native is a richer surface but
a narrower one, which is why the selector now marks it experimental. An
explicit `uiMode: native` is unchanged and still wins.

The spec test that pinned the old default now asserts the pair -- explicit
native honoured, undeclared resolving to terminal -- and the resume-entry
test declares its mode explicitly, because it is about specs being stable
across a manifest round trip rather than about what an omitted field means.
Four documents in docs/ were records of how the work was done, not
descriptions of what the product is: the native UI mode draft (status
"DRAFT -- requires 3-way sign-off", naming its worktree and branch), the
atch refactor lane status, the cutover-readiness status, and the CLI
install/serve contract spec. They sat beside the user guides, where a
reader takes them for current documentation, and parts of them were
plainly false -- the native draft describes a `DESK_TMUX_SESSION` env var
and `send-keys` delivery, neither of which exists in the codebase, and a
UI-mode endpoint keyed by `tmuxSession` when the route reads `sessionId`.

The atch v3 wire contract stays. It is not a plan: the shipped codec
names it as its authority ("implements docs/atch-wire-v3.md byte-exactly")
and the golden vectors in tests/fixtures test conformance against it.
Moving it would leave the repo shipping a protocol whose specification
lives somewhere else.

Seven source files cited the native draft by path for a section number.
Those citations now dangle, so they are removed -- a comment pointing at
a file the reader cannot open is worse than no citation, and the prose
around each already says what the code does.

Also scrubs the engineering rules of internal references: an agent handle
named as a rule's "authoritative validator", a first-person account of two
agents editing one tree, and eight citations to private memory slugs that
no reader outside this machine can resolve. Two rules whose entire
rationale WAS a slug now state the actual reason -- a rule that cannot
explain itself is not a rule, and R11.3's explanation was empty after the
scrub until it got one.
Profiles shipped with a schema section in the configuration reference and a
field bullet in the fleet guide. Every comparable feature -- channels,
layouts, events, git, projects -- has its own guide page with screenshots;
this one had a table row. So the feature was documented the way a field is
documented, not the way a feature is.

The guide covers what a profile actually is (Desk points the CLI at its own
config directory and never reads what the CLI writes there), how to create
and assign one, and the two things that surprise operators in practice:
credentials move into `~/.config/desk/profiles/<id>`, and Claude keeps its
transcripts inside its config directory -- so changing a live session's
profile points it at a different history and `--resume` will not find the
old conversation.

The screenshot was taken against a throwaway Desk instance on its own port
with a demo manifest -- four fictional accounts, an empty project -- not
against a real workspace. Nothing in the image is anyone's data.
The session-form screenshot the guide was missing. Headless automation could
not produce it -- the row actions that open the form are hover-gated and
report zero size to a scripted click -- so the operator took it against the
same throwaway demo instance: fictional accounts, an empty project under
/tmp.

It earns its place by showing three things at once that prose describes
poorly: the picker offers only accounts whose provider matches the chosen
agent (Codex is selected, so the two Codex accounts appear and the Claude
ones do not), the + beside it creates a profile without discarding the form,
and the submit button says what the operator is about to do.
`desk hooks install` writes the event shim to $DESK_HOME/hooks, and the
agent-state runtime writes one producer binding per session to
$DESK_HOME/producers. The uninstall ownership validator allowed only
releases, toolchains, current, and .desk-install, so it rejected both as
"unidentified Desk home entries" and refused to run. Any machine that had
used hooks or started a session under the new agent-state system could no
longer uninstall Desk, and the refusal read as tampering rather than as
Desk's own state.

Both directories are Desk-owned state inside Desk's own home. Allow them,
verify their ownership the way releases and toolchains are verified, reject
a symlink in their place, and remove them with the rest of the application.

The post-uninstall notice was wrong in the same area: it listed four paths
as still firing. Only agent-global configuration keeps firing once Desk is
gone. Hook config under ~/.config/desk is read only when Desk launches the
agent, so it is inert; the Codex hooks file is user-global and runs on every
codex invocation anywhere, and the 0.1.x OpenCode plugin path was left in
the list after the plugin moved into Desk's own config directory. Name the
two that actually keep invoking the deleted shim, and say the Desk-scoped
ones are preserved because they never run without Desk.
The four real-binary tests resolve atch from libexec/atch, which is
gitignored and produced by build:distribution. CI ran the suite before that
step, so the binary never existed at test time and every one of them took
its skipIf branch. Three also required RUN_REAL_JOIN=1, which was set
nowhere in the repository, so they could not run in CI even by accident.
The refactor that replaced tmux with atch therefore shipped with no
automated coverage of the daemon-to-atch seam; the only check was that the
packaged binary answered --version.

Build atch before the suite and set RUN_REAL_JOIN=1 for it. The seam is now
exercised on every push: handshake and generation fence, single and
multi-line round-trip, RESIZE, the SessionManager detached spawn and retire
lifecycle, the full binary client path through the bridge and the real
xterm emulator, control-socket fd hygiene, and the ATTACH preamble.

Two of the tests defaulted ATCH_BIN to an absolute path inside a personal
scratch worktree. That path is specific to one machine, and the binary in
it is a different and older build than the one this repo ships, so on the
only machine where the default resolved the tests were exercising the wrong
binary. Default to libexec/atch as the other two already do.

Locally on Node 22.23.1: 299 files and 3111 tests pass, up from 296 and
3103. The remaining six skips are the live provider probes, which need real
agent credentials and cannot run unattended.
The DESK_CHANNELS_DEBUG trace appended to /tmp/chan-engine-debug.log, a
fixed name in a world-writable directory. Another local user can create
that path first as a symlink, and appendFileSync follows it, so enabling
the trace on a shared development box lets them redirect Desk's writes into
any file the operator can write. The same predictable name also hands them
a readable log of session ids and message kinds.

Write beside the queues in the engine's own state directory instead, which
is already private to the operator, and create it 0700.

Pre-existing since the initial public release, not a regression from the
agent-state work; found while reviewing the delivery path.
The profiled launch prefix reaches sh as source text, so the whole isolation
guarantee rests on one quoter. The suite asserted the prefix's shape but
never ran it, which cannot distinguish correct quoting from a string that
merely looks right.

Run the real prefix through a real shell with a home path built to escape
the quotes, and assert both that the credential directory arrives intact and
that a witness file was never created. Replacing the quoter with a naive
one that omits the '\'' escape fails this test with the injected command
executing, so it is a real guard rather than a restatement.

Also assert an inherited ANTHROPIC_API_KEY is genuinely absent from the
child, rather than only that the scrub text precedes the assignment.
Brings the atch terminal-title state fallback and its sink recovery onto
main. The agent activity axis now degrades to a title-derived reading when
no semantic producer is reporting, and says so: title evidence is a typed
variant that cannot carry a lease, and the schema requires it to appear
exactly together with degraded/title-fallback health, so a title-derived
state can never be presented as an authoritative one.

The sink side bounds recovery rather than disk. The event sink is capped
and compacts to the retained snapshot (ready, latest state, latest link),
and the tailer distinguishes three phases: startup replay restores canonical
state without re-publishing history, a truncation resync stays journal
eligible so the transition that triggered compaction is not swallowed, and
live appends journal as before. The C decimal parser rejects overflow
instead of relying on signed wraparound.

Reviewed independently on both commits: every fix was verified by reverting
it and confirming a test fails, the contract invariants were probed
adversarially, and compaction was probed for manufactured duplicates with
scenarios the branch does not carry. No file was touched by both sides, so
the merge is textual only; the four main-side fixes and the tmux removal
were checked to survive it.
Integrates the installer npm-probe fix from PR #26, which resolves the
release fixture version from package.json instead of hardcoding it and runs
npm through the pinned toolchain's own node.

One conflict, in the installer workflow's failure-path probe, where both
sides had edited the same command line. Resolved by keeping both intents:
their removal of the inline DESK_VERSION (it now arrives from the fixver
step) and this branch's removal of the tmux probe, which no longer belongs
in a runtime that ships atch.
Copilot AI review requested due to automatic review settings July 28, 2026 21:16

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot wasn't able to review this pull request because it exceeds the maximum number of files (300). Try reducing the number of changed files and requesting a review from Copilot again.

Comment thread src/server/runtime/spawnMaster.ts
levi770 and others added 6 commits July 28, 2026 23:23
CI failed on the fd-hygiene test with a bare ENOENT from scandir
/proc/<pid>/fd. The pid was found in setup and had exited by the time the
baseline was sampled, so the run reported a missing directory rather than
anything about fd hygiene.

Two defects made that possible. The master is located by scanning /proc for
a cmdline containing the socket path and the string "atch", but the socket
lives under a directory named atch-fd-leak-*, so the second condition was
always true and the predicate was really just the path — which the launcher
we spawned also satisfies while it is still daemonizing. And the first
match was accepted immediately, with no check that it survives.

Match the binary path instead of the substring, skip the launcher pid we
already know, and require a candidate to still exist a beat later. Report a
vanished master as exactly that, since a master dying under probing is the
failure this test exists to catch and should not surface as an ENOENT from
inside a helper.

This only became visible now because the suite reached CI for the first
time; it passes locally either way, so the fix targets the mechanism the
runner's evidence points to rather than a local reproduction.
The daemon's own entry point read the variable raw and fell back to the
bare name "atch". Node spawns it without a shell, so a bare name is
resolved through PATH at exec time: the daemon would run whichever atch
came first on the path it inherited, and an unusable override would fail
only at the first provision rather than at startup.

That is exactly what resolveAtchBinPath exists to prevent — it preflights
DESK_ATCH_BIN as an executable regular file, falls back to the same-release
libexec/atch, and yields an absolute path from PATH, never a bare name. The
supervisor already resolves the path before launching the daemon, so the
raw read only surfaced when the daemon ran on its own, but the entry point
still has to honour the contract rather than rely on its caller.

Route it through the resolver and pin the behaviour: a non-executable
override is refused, an executable one passes through, and the resolved
path is never the bare name.

Found while tracing a CodeQL alert (js/shell-command-injection-from-
environment) on the spawn site. The alert's premise about a shell does not
hold — nothing in this path uses shell:true — but following its data flow
surfaced this genuine gap, which an earlier analysis of mine had missed by
checking the resolver rather than this call site.
…obody reads

A review of the channels surface found the documentation still describing
behaviour the refactor removed. Every point checked out against the code.

The engine console section listed the retired PaneState vocabulary — ready,
busy, booting, offline, empty-capture, unobservable. The field is gone;
only a comment marking its removal remains. Describe the axes the console
actually shows: lifecycle, activity, what the session waits on, and the
delivery status.

The protocol page claimed standalone prompts wait for a fresh ready-state
snapshot. They do not: drain applies the same canonical decision to every
kind, and the only thing the prompt kind changes is whether the submit is
verified afterwards. It also claimed native-mode prompts use the agent
surface's delivery state, but that strategy and its accessor were deleted;
native sessions simply skip the terminal submit check. And it kept a
boot-grace classification that no longer exists — booting now follows the
daemon's starting lifecycle.

The comment on enqueuePrompt promised idle-gated delivery, which nothing
does any more.

bootGraceMs and sessionCreatedAt were still declared as engine options and
read nowhere; a deprecated option that no code path consults is dead, not
deprecated. Removing both, and the call sites in tests that still passed
them, changes no behaviour: 359 channel tests and the full suite pass.
@levi770
levi770 merged commit 1dbef8a into main Jul 29, 2026
15 checks passed
@levi770
levi770 deleted the atch-state-refactor branch July 29, 2026 10:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants