1	# spt-core Roadmap
2
3	> Lightweight-but-structured path from design → fruition. Not a GSD/phase-gated process — a clear sequence with de-risk gates. Planning corpus is complete (PRD + CONTEXT + 5 ADRs + 6 design docs); this is how we build it.
4
5	## End goal
6
7	A harness-independent core for an agent ecosystem — messaging, live-agent lifecycle, terminal hosting, **first-class P2P networking**, and a runtime-manifest harness contract — shipped as Rust crates + a single `spt` binary. The proof: the same agent runs natively across all of a user's machines with a synced mind, reachable and operable from anywhere, with seamless self-update and zero-config cross-machine messaging. First thing built atop it: a rebuilt `spt` plugin at parity with today's modern SPT.
8
9	## Scope decisions (locked)
10
11	- **v1 = the full PRD, networking included.** The networked element is the *reason* spt-core exists; it is not deferrable to v1.1.
12	- **The hard freeze on `claude_skill_owl` is not a clock.** It works today and delivers value as-is while spt-core is built — no schedule pressure from it.
13	- **Process: lightweight but structured.** GSD is too heavy. This roadmap + the design docs are the structure.
14
15	## Path to fruition (sequence)
16
17	**Stage A — Red-team review (before building).** Pressure-test the design adversarially. Focus on the riskiest architectural decisions, not bikeshedding. Tools: `plan-eng-review` (architecture) + `codex` (adversarial second opinion). Scope-cutting is *not* a goal (v1 is locked as full PRD), but surfacing contradictions / over-engineering / unproven assumptions is.
18
19	**Stage B — De-risk spikes.** Throwaway proofs of the load-bearing unknowns — anything that, if it fails, forces a redesign. Do these before committing to the full build. See *De-risk spikes* below.
20
21	**Stage C — Walking skeleton.** Build the killer-quickstart slice end-to-end through the real crate boundaries: **two agents exchange a message** (first local; ultimately cross-node — the real proof). Validates the whole stack early; then thicken.
22
23	**Stage D — Phased build (M0–M5).** Dependency-ordered, each milestone ships something usable.
24
25	## Milestones (all v1)
26
27	- **M0 — Workspace + commodity layer.** Cargo workspace; `spt-proto` (wire envelope grammar, endpoint types, framing, Ed25519 identity, typed+binary payloads) + `spt-store` (spool, perch layout, trust store, registry persistence). Copy-verbatim the stable formats from the sister project (ADR-0001).
28	- **M1 — Local messaging + binary.** `spt-msg` (delivery TCP+spool, routing, send/ring/ready) + the `spt` binary + CLI. **= the killer quickstart (local): two agents exchange a message.** Task breakdown: [`M1-PLAN.md`](./M1-PLAN.md).
29	- **M2 — Harness contract + lifecycle.** **Strictly harness-agnostic — zero Claude Code conventions in this repo.** spt-core defines the adapter contract; the only adapter-shaped artifact in-tree is a generic mock/test adapter (manifest fixture + fake `api` caller) that exercises the contract (PRD R-DOCS-2 dev-agent quickstart). **The rebuilt `spt` plugin (Claude Code) is a SEPARATE downstream project** built atop these crates/binary — spt-core v1's acceptance proof (PRD §"feature parity"), not an spt-core deliverable. It lives and builds outside this repo. **Split into two JIT milestones** (decision 2026-06-01):
30	  - **M2a — Harness contract. ✅ delivered (2026-06-01).** `spt-runtime` (AgentRuntime, ManifestRuntime, manifest schema) + the `api` subcommand surface (seed/listen/bind/state/echo-gate/poll/worker-*/boundary/session-end/presence/history-log/emit/capability) + the **harness-hosted** startup topology (seed→listen→bind, interim seed-file) + local-api-auth + the generic mock adapter + contract E2E. All M2a reqs activated + green. Task breakdown: [`M2a-PLAN.md`](./M2a-PLAN.md).
31	  - **M2b — Live-agent lifecycle. ✅ delivered (2026-06-01).** `spt-live` (fifth layer): history subsystem (fetcher/locate-normalize/native) + spawn-psyche (nested perch, spt-core-owned `$psyche_prompt`) + echo-commune (history brief → single-writer commune drop) + drop-file ingest (single-writer 6.4 + direct-write precedence 6.5) + interim pulse + signoff/boundary ordering invariants (echo-before-signoff 3.3, grace-before-signoff 1.1, stale-signoff sweep 3.2) + resume seam (continue-existing / fresh-with-preload). Hosted by the `api listen` runner + `api shutdown`; mock adapter gained the lifecycle templates; LiveAgent E2E proves spawn→Psyche-perch→commune-ingest→graceful-signoff harness-agnostically. All M2b reqs activated + green. Task breakdown: [`M2b-PLAN.md`](./M2b-PLAN.md).
32	  - **Interim, no daemon** (like M1): the `api listen` process holds the perch + relays; the seed bridges via a file. **The spt-hosted topology (PTY-launch) + the consolidated `spt-daemon` move to M3** (they need the `spt-term` broker) — this narrows the earlier "both topologies in M2" wording.
33	- **M3 — Terminal wrapper + daemon + self-update.** `spt-term` (session-surface, PTY, broker) + the consolidated `spt-daemon` (broker/brain split) + peer-propagated signed self-update. Split M3a/M3b/M3c. Scope + carried-forward Spike #1 gaps: [`M3-PLAN.md`](./M3-PLAN.md).
34	  - **M3a — `spt-term` session-surface mechanism. ✅ delivered (2026-06-02).** The sixth crate (`…→spt-msg→spt-term`, sibling of spt-runtime): the `SessionSurface` trait + native PTY backends (ConPTY/forkpty via `portable-pty`, REQ-TERM-1) · ConPTY-DSR auto-answer drain pump (REQ-HAZARD-CONPTY-DSR) · send-keys/send-line injection (REQ-TERM-2) · bounded byte-stream (REQ-TERM-3) · the PTY-digest parser primitive (REQ-TERM-4 impl/unit, ADR-0008). OS-neutral per Spike #4/#5 (no OS's stream contract baked in; ANSI-stripped, tail-wins repaint tolerance). Validated on Windows (ConPTY) + Linux (forkpty). All M3a reqs activated + green. Task breakdown: [`M3a-PLAN.md`](./M3a-PLAN.md).
35	  - **M3b — `spt-daemon` broker/brain process** (NEXT). Hosts many `spt-term` surfaces behind versioned IPC: broker/brain split + daemon-authoritative liveness + idempotent boundary + auto-start, plus the digest's daemon half (manifest-sourcing, `spt digest` pull, delta-stream, Path-B persistence → REQ-TERM-4 `int`). Stub: [`M3b-PLAN.md`](./M3b-PLAN.md).
36	  - **M3c — peer-propagated signed self-update.**
37	- **M4 — Networking + instances.** `spt-net` (Iroh WAN, mDNS LAN, TOTP-SPAKE2 pairing, subnet registry) + the multi-instance model + cross-node Psyche sync (P2P, opt-in GitHub hub). **= the real proof: cross-node message + drive an agent on machine A from machine B.**
38	- **M5 — Shells, presence, deferred capabilities.** Shell adapters + sleep/wake, PresenceChannel, and the consent-gated capabilities (remote-exec, instantiate-anywhere) landing into the v1 consent seam.
39
40	## De-risk spikes (do early — these can invalidate the architecture)
41
42	1. **Broker/brain no-terminate handoff** — prove a PTY child + open socket survive a daemon-*logic* restart (FD-passing or stable-broker shim). The hardest invariant in the design (ADR-0004).
43	2. **Iroh + mDNS smoke test** — two nodes, separate NATs, one message end-to-end; measure direct-vs-relay + binary size (research brief §5). Validates the whole networking premise.
44	3. **ConPTY + `portable-pty`** — host and drive a real session in a broker PTY on Windows + Linux.
45	4. **Manifest-driven lifecycle** — a manifest drives one real Claude Code session through spawn → bind → commune → signoff, proving harness-independence works in practice.
46
47	## Immediate next steps
48
49	Progress (2026-05-31):
50	- ✅ **Stage A — codex adversarial pass** — done. 14 findings (5 FATAL, 9 SERIOUS) in `docs/reviews/STAGE-A-codex-redteam.md`.
51	- ✅ **Spike #1 (broker/brain handoff)** — PASS on Windows ConPTY. `docs/spikes/SPIKE-01-broker-handoff.md`. Found+fixed the ConPTY DSR-stall hazard. 4 open gaps carried to M3.
52	- ✅ **Spike #2 (Iroh smoke test)** — PASS (single-host). `docs/spikes/SPIKE-02-iroh-smoke.md`. iroh 0.98 API workable; 13.57 MB release. Cross-NAT/relay proof deferred to M4.
53	- ✅ **Findings folded** into ADR-0002/0003/0004/0005 + KNOWN-HAZARDS (+3) + traceable-reqs.toml (+4 REQ-HAZARD).
54
55	Remaining before M0:
56	1. **Stage A — `plan-eng-review`** (interactive architecture pass; second half of Stage A).
57	2. **Spike #4 (manifest-driven lifecycle)** — a manifest drives one real session spawn→bind→commune→signoff. (Spike #3 ConPTY hosting is largely covered by Spike #1.)
58	3. Resolve the open user decisions logged in the ADR amendments (context-merge model, seed-rotation, R-UPD-3 rewording).
59	4. **M0** (workspace + `spt-proto` + `spt-store`) — **started**; task breakdown in [`M0-PLAN.md`](./M0-PLAN.md). Each milestone gets its own just-in-time plan (this one is M0); the roadmap stays the milestone sequence, the JIT plan is the task layer.
60
61	## Tooling: requirement traceability (adopted)
62
63	`traceable-reqs` is adopted as the requirement-tracing gate. `traceable-reqs.toml` seeds the `REQ-*` registry from PRD `R-*` + KNOWN-HAZARDS invariants (all inactive until milestone activation). The development contract + enforcement (CI gate, milestone activation, agent rule, quality audit) is `docs/TRACEABILITY.md`; the agent rule lives in `CLAUDE.md`. **M0 task:** install the CLI, validate the seed, wire the CI workflow, activate M0 reqs.
64
65	## Open threads carried forward
66
67	- New deferred items logged this session (`docs/DEFERRED.md`): block-level OCC context merge; the rebalance / Memory-Health pass; the consent framework's gated capabilities.
68	- The dual-audience docs strategy (`docs/DOCS-STRATEGY.md`) governs the *shipped* docs, authored as code lands.
69

# spt-core Roadmap

> Lightweight-but-structured path from design → fruition. Not a GSD/phase-gated process — a clear sequence with de-risk gates. Planning corpus is complete (PRD + CONTEXT + 5 ADRs + 6 design docs); this is how we build it.

## End goal

A harness-independent core for an agent ecosystem — messaging, live-agent lifecycle, terminal hosting, **first-class P2P networking**, and a runtime-manifest harness contract — shipped as Rust crates + a single `spt` binary. The proof: the same agent runs natively across all of a user's machines with a synced mind, reachable and operable from anywhere, with seamless self-update and zero-config cross-machine messaging. First thing built atop it: a rebuilt `spt` plugin at parity with today's modern SPT.

## Scope decisions (locked)

- **v1 = the full PRD, networking included.** The networked element is the *reason* spt-core exists; it is not deferrable to v1.1.
- **The hard freeze on `claude_skill_owl` is not a clock.** It works today and delivers value as-is while spt-core is built — no schedule pressure from it.
- **Process: lightweight but structured.** GSD is too heavy. This roadmap + the design docs are the structure.

## Path to fruition (sequence)

**Stage A — Red-team review (before building).** Pressure-test the design adversarially. Focus on the riskiest architectural decisions, not bikeshedding. Tools: `plan-eng-review` (architecture) + `codex` (adversarial second opinion). Scope-cutting is *not* a goal (v1 is locked as full PRD), but surfacing contradictions / over-engineering / unproven assumptions is.

**Stage B — De-risk spikes.** Throwaway proofs of the load-bearing unknowns — anything that, if it fails, forces a redesign. Do these before committing to the full build. See *De-risk spikes* below.

**Stage C — Walking skeleton.** Build the killer-quickstart slice end-to-end through the real crate boundaries: **two agents exchange a message** (first local; ultimately cross-node — the real proof). Validates the whole stack early; then thicken.

**Stage D — Phased build (M0–M5).** Dependency-ordered, each milestone ships something usable.

## Milestones (all v1)

- **M0 — Workspace + commodity layer.** Cargo workspace; `spt-proto` (wire envelope grammar, endpoint types, framing, Ed25519 identity, typed+binary payloads) + `spt-store` (spool, perch layout, trust store, registry persistence). Copy-verbatim the stable formats from the sister project (ADR-0001).
- **M1 — Local messaging + binary.** `spt-msg` (delivery TCP+spool, routing, send/ring/ready) + the `spt` binary + CLI. **= the killer quickstart (local): two agents exchange a message.** Task breakdown: [`M1-PLAN.md`](./M1-PLAN.md).
- **M2 — Harness contract + lifecycle.** **Strictly harness-agnostic — zero Claude Code conventions in this repo.** spt-core defines the adapter contract; the only adapter-shaped artifact in-tree is a generic mock/test adapter (manifest fixture + fake `api` caller) that exercises the contract (PRD R-DOCS-2 dev-agent quickstart). **The rebuilt `spt` plugin (Claude Code) is a SEPARATE downstream project** built atop these crates/binary — spt-core v1's acceptance proof (PRD §"feature parity"), not an spt-core deliverable. It lives and builds outside this repo. **Split into two JIT milestones** (decision 2026-06-01):
  - **M2a — Harness contract. ✅ delivered (2026-06-01).** `spt-runtime` (AgentRuntime, ManifestRuntime, manifest schema) + the `api` subcommand surface (seed/listen/bind/state/echo-gate/poll/worker-*/boundary/session-end/presence/history-log/emit/capability) + the **harness-hosted** startup topology (seed→listen→bind, interim seed-file) + local-api-auth + the generic mock adapter + contract E2E. All M2a reqs activated + green. Task breakdown: [`M2a-PLAN.md`](./M2a-PLAN.md).
  - **M2b — Live-agent lifecycle. ✅ delivered (2026-06-01).** `spt-live` (fifth layer): history subsystem (fetcher/locate-normalize/native) + spawn-psyche (nested perch, spt-core-owned `$psyche_prompt`) + echo-commune (history brief → single-writer commune drop) + drop-file ingest (single-writer 6.4 + direct-write precedence 6.5) + interim pulse + signoff/boundary ordering invariants (echo-before-signoff 3.3, grace-before-signoff 1.1, stale-signoff sweep 3.2) + resume seam (continue-existing / fresh-with-preload). Hosted by the `api listen` runner + `api shutdown`; mock adapter gained the lifecycle templates; LiveAgent E2E proves spawn→Psyche-perch→commune-ingest→graceful-signoff harness-agnostically. All M2b reqs activated + green. Task breakdown: [`M2b-PLAN.md`](./M2b-PLAN.md).
  - **Interim, no daemon** (like M1): the `api listen` process holds the perch + relays; the seed bridges via a file. **The spt-hosted topology (PTY-launch) + the consolidated `spt-daemon` move to M3** (they need the `spt-term` broker) — this narrows the earlier "both topologies in M2" wording.
- **M3 — Terminal wrapper + daemon + self-update.** `spt-term` (session-surface, PTY, broker) + the consolidated `spt-daemon` (broker/brain split) + peer-propagated signed self-update. Split M3a/M3b/M3c. Scope + carried-forward Spike #1 gaps: [`M3-PLAN.md`](./M3-PLAN.md).
  - **M3a — `spt-term` session-surface mechanism. ✅ delivered (2026-06-02).** The sixth crate (`…→spt-msg→spt-term`, sibling of spt-runtime): the `SessionSurface` trait + native PTY backends (ConPTY/forkpty via `portable-pty`, REQ-TERM-1) · ConPTY-DSR auto-answer drain pump (REQ-HAZARD-CONPTY-DSR) · send-keys/send-line injection (REQ-TERM-2) · bounded byte-stream (REQ-TERM-3) · the PTY-digest parser primitive (REQ-TERM-4 impl/unit, ADR-0008). OS-neutral per Spike #4/#5 (no OS's stream contract baked in; ANSI-stripped, tail-wins repaint tolerance). Validated on Windows (ConPTY) + Linux (forkpty). All M3a reqs activated + green. Task breakdown: [`M3a-PLAN.md`](./M3a-PLAN.md).
  - **M3b — `spt-daemon` broker/brain process** (NEXT). Hosts many `spt-term` surfaces behind versioned IPC: broker/brain split + daemon-authoritative liveness + idempotent boundary + auto-start, plus the digest's daemon half (manifest-sourcing, `spt digest` pull, delta-stream, Path-B persistence → REQ-TERM-4 `int`). Stub: [`M3b-PLAN.md`](./M3b-PLAN.md).
  - **M3c — peer-propagated signed self-update.**
- **M4 — Networking + instances.** `spt-net` (Iroh WAN, mDNS LAN, TOTP-SPAKE2 pairing, subnet registry) + the multi-instance model + cross-node Psyche sync (P2P, opt-in GitHub hub). **= the real proof: cross-node message + drive an agent on machine A from machine B.**
- **M5 — Shells, presence, deferred capabilities.** Shell adapters + sleep/wake, PresenceChannel, and the consent-gated capabilities (remote-exec, instantiate-anywhere) landing into the v1 consent seam.

## De-risk spikes (do early — these can invalidate the architecture)

1. **Broker/brain no-terminate handoff** — prove a PTY child + open socket survive a daemon-*logic* restart (FD-passing or stable-broker shim). The hardest invariant in the design (ADR-0004).
2. **Iroh + mDNS smoke test** — two nodes, separate NATs, one message end-to-end; measure direct-vs-relay + binary size (research brief §5). Validates the whole networking premise.
3. **ConPTY + `portable-pty`** — host and drive a real session in a broker PTY on Windows + Linux.
4. **Manifest-driven lifecycle** — a manifest drives one real Claude Code session through spawn → bind → commune → signoff, proving harness-independence works in practice.

## Immediate next steps

Progress (2026-05-31):
- ✅ **Stage A — codex adversarial pass** — done. 14 findings (5 FATAL, 9 SERIOUS) in `docs/reviews/STAGE-A-codex-redteam.md`.
- ✅ **Spike #1 (broker/brain handoff)** — PASS on Windows ConPTY. `docs/spikes/SPIKE-01-broker-handoff.md`. Found+fixed the ConPTY DSR-stall hazard. 4 open gaps carried to M3.
- ✅ **Spike #2 (Iroh smoke test)** — PASS (single-host). `docs/spikes/SPIKE-02-iroh-smoke.md`. iroh 0.98 API workable; 13.57 MB release. Cross-NAT/relay proof deferred to M4.
- ✅ **Findings folded** into ADR-0002/0003/0004/0005 + KNOWN-HAZARDS (+3) + traceable-reqs.toml (+4 REQ-HAZARD).

Remaining before M0:
1. **Stage A — `plan-eng-review`** (interactive architecture pass; second half of Stage A).
2. **Spike #4 (manifest-driven lifecycle)** — a manifest drives one real session spawn→bind→commune→signoff. (Spike #3 ConPTY hosting is largely covered by Spike #1.)
3. Resolve the open user decisions logged in the ADR amendments (context-merge model, seed-rotation, R-UPD-3 rewording).
4. **M0** (workspace + `spt-proto` + `spt-store`) — **started**; task breakdown in [`M0-PLAN.md`](./M0-PLAN.md). Each milestone gets its own just-in-time plan (this one is M0); the roadmap stays the milestone sequence, the JIT plan is the task layer.

## Tooling: requirement traceability (adopted)

`traceable-reqs` is adopted as the requirement-tracing gate. `traceable-reqs.toml` seeds the `REQ-*` registry from PRD `R-*` + KNOWN-HAZARDS invariants (all inactive until milestone activation). The development contract + enforcement (CI gate, milestone activation, agent rule, quality audit) is `docs/TRACEABILITY.md`; the agent rule lives in `CLAUDE.md`. **M0 task:** install the CLI, validate the seed, wire the CI workflow, activate M0 reqs.

## Open threads carried forward

- New deferred items logged this session (`docs/DEFERRED.md`): block-level OCC context merge; the rebalance / Memory-Health pass; the consent framework's gated capabilities.
- The dual-audience docs strategy (`docs/DOCS-STRATEGY.md`) governs the *shipped* docs, authored as code lands.


NOTE fROM THE USER: proceedwithnextsteps.


B3 next (REQ-DAEMON-1, consolidation). Read plan task table.


1	# M3b Plan — `spt-daemon` broker/brain process
2
3	> **Just-in-time, lightweight** — same pattern as `M0/M1/M2a/M2b/M3a-PLAN.md`. The
4	> ordered task layer between `ROADMAP.md`, `M3-PLAN.md` (the M3 umbrella), and
5	> `traceable-reqs.toml` (the requirement checklist), scoped to **M3b only**.
6	> Branch: `dev-freeform`. Authoritative architecture: ADR-0004 (broker/brain split
7	> + the §B ownership table + the closed §E spike gaps) and `M3-PLAN.md` §M3b.
8
9	> **Upstream is done:** Phase 0 spike-gate ✅ (Spikes #3/#4/#5/#6 all PASS — ADR-0004
10	> §E closed on both OSes) and **M3a ✅ delivered** — `spt-term` provides the
11	> single-surface PTY *mechanism* (SessionSurface + ConPTY/forkpty backend + DSR
12	> drain + injection + bounded byte-stream + digest parser primitive). **M3b builds
13	> the *process* that hosts many of those surfaces** behind a versioned local IPC,
14	> consolidating the interim no-daemon model.
15
16	## Goal
17
18	Replace the interim no-daemon model (M1/M2a/M2b: a long-lived `spt api listen`
19	process holds the perch + drives a relay/pulse loop; a seed *file* bridges
20	binding; per-pid `is_process_alive` is liveness) with the **real architecture**
21	(ADR-0004): one per-machine `spt-daemon`, split into a **broker** (stable kernel
22	holding the un-transferable resources) and a **brain** (restartable logic), so a
23	routine self-update swaps the brain with **zero endpoint interruption**.
24
25	**M3b done =** the daemon hosts the M2b LiveAgent lifecycle as in-process loops
26	(Psyche-as-loop, durable pulse — no separate listener/wrapper process, only a thin
27	stateless in-session relay residue) · the broker hosts `spt-term` PTYs + harness
28	children + sockets behind a **versioned local IPC** that a newer brain speaks to an
29	older broker · **a brain kill/restart leaves the child + its live output stream
30	intact and gapless** (Spike #1 shape, made real) · liveness for daemon-hosted
31	perches is the daemon's `status`, never `is_process_alive(info.pid)` · every
32	broker↔brain side-effect boundary is **exactly-once** across a brain crash (Spike
33	#6) · any `api` invocation **auto-starts** the daemon · spt-hosted startup is
34	spawn-session→`api bind` with an **in-memory** seed (no file) · the **digest
35	daemon-half** completes REQ-TERM-4 (`int`) · `cargo test --workspace` + clippy
36	`-D warnings` + `traceable-reqs check` green on the CI matrix (ubuntu + windows).
37
38	## Scope
39
40	### In — the `spt-daemon` crate (above `spt-live`, below `spt`)
41
42	- **broker** (stable kernel, `REQ-DAEMON-2`) — owns **only** the resources a live
43	  consumer would lose continuity on if the brain restarted (ADR-0004 §B normative
44	  table): PTY master handles (**from `spt-term::PtySession`**), spawned harness
45	  child processes, accepted local client sockets, listening sockets. No logic.
46	  Almost never restarts.
47	- **brain** (userspace, `REQ-DAEMON-2`) — all logic: routing, registry, manifest
48	  parse, **the re-hosted M2b lifecycle**. Restarts freely; rehydrates from disk +
49	  re-attaches to broker-held handles over IPC. `gen_start = now()` on cold-start
50	  **and** handoff (`REQ-HAZARD-GEN-START-NOW` / 2.4).
51	- **versioned local IPC** (`REQ-HAZARD-HANDOFF-ARGV-COMPAT` / 2.3) — named pipe
52	  (Windows) / Unix-domain socket, behind one transport trait (mirrors how
53	  `SessionSurface` abstracts the PTY OS-split). A version handshake + a
54	  forward-compatible frame schema so a **newer brain talks to an older broker**.
55	- **consolidation** (`REQ-DAEMON-1`) — the `spt api listen` poll-loop + the pulse
56	  loop + the Psyche loop move **into the brain** as scheduled in-process loops; the
57	  only residue is the thin **stateless in-session relay** for harness-hosted
58	  sessions (topology 1), freely killable. Durable, configurable pulse period
59	  replacing the interim 5s constant.
60	- **daemon-authoritative liveness** (`REQ-HAZARD-DAEMON-HOSTED-LIVENESS` / 2.5) —
61	  the daemon's in-memory endpoint table + a `status` field on `info.json`
62	  supersede `is_process_alive(info.pid)` for **daemon-hosted** perches; localized
63	  behind the single liveness resolver so external/interim perches still probe.
64	- **idempotent broker↔brain boundary** (`REQ-HAZARD-RESTART-IDEMPOTENT` / 7.2,
65	  Spike #6) — durable IDs + dedup-at-effect at every side-effect crossing (spool
66	  write, PTY write, registry update), with a **broker-owned recovery anchor**;
67	  exactly-once across a brain crash at before-intent / before-effect / after-effect.
68	- **auto-start** (`REQ-DAEMON-3`) — any `api` invocation starts the daemon if
69	  absent (`listen` is the reliable anchor); ensure-running lives in the `api` layer.
70	- **spt-hosted startup** (`REQ-START-3`) — daemon spawn-session into a broker PTY,
71	  then `api bind`; **in-memory** seed map replaces the `spt-store::seed` file bridge.
72	- **orphan-watch** — carry the M2b graceful ordering (grace 1.1 / echo 3.3 /
73	  stale-sweep 3.2) into the **supervised-crash** path the always-on daemon now
74	  enables (M2b delivered graceful-only).
75	- **digest daemon-half** (`REQ-TERM-4` `int`, ADR-0008) — manifest-sourcing of the
76	  `input`/`agent`/`tool` patterns (the `pty_digest` manifest seam), running the
77	  M3a `DigestParser` over the broker PTY; `spt digest <id>` snapshot pull, the
78	  broker-pushed structured **delta-stream** on the R-TERM-3 substrate, and opt-in
79	  Path-B persistence. Local addressing only (the qualified `[subnet:]id@node` form
80	  is M4); `REQ-DAEMON-4` (honor every KNOWN-HAZARDS invariant) is the meta-gate.
81
82	### Out
83
84	- networking / P2P, multi-subnet, notifications, **cross-node** digest addressing —
85	  **M4** (`spt-net`). The QUIC-stream broker-ownership *implementation* is M4
86	  (only its *shape* was spiked, #3).
87	- self-update (update-class taxonomy, signing, ripple) — **M3c**.
88	- whole-daemon live FD-passing (zero-interruption even for *broker* updates) —
89	  explicit future polish, not v1 (ADR-0004 consequences).
90
91	## Clean-room posture
92
93	- **`spt-daemon` is brand-new.** The sister ran poll listeners + Psyche wrappers as
94	  separate processes; ADR-0004 deliberately consolidates them. Clean-room the crate.
95	- **Re-host, don't rewrite.** The M2b lifecycle is already pure, unit-tested
96	  functions in `spt-live` (`spawn_psyche`, `resume_psyche`, `run_echo_commune`,
97	  `ingest_drops`, `graceful_signoff`, `sweep_stale_signoff`, `pulse::tick`,
98	  `fetch_history`, `write_resume_commune`, `compose_psyche_prompt`). The brain
99	  *calls* these as loops; the ordering invariants (3.3 / 1.1 / 3.2) stay where they
100	  are. **Reuse `spt-term::PtySession` for the broker's PTYs** — do not re-implement.
101	- **Spike findings are binding inputs:** #1 (no-EOF-while-writer-held + DSR; the
102	  broker-survives-brain-restart shape), #3 (live-stream survival shape), #6
103	  (broker-owned recovery anchor + dedup-at-effect keyed by durable ID).
104
105	## New requirements to register first (TRACEABILITY rule 3)
106
107	**Assessment — likely none.** The M3b reqs already exist: `REQ-DAEMON-1/2/3/4`,
108	`REQ-START-3`, `REQ-HAZARD-HANDOFF-ARGV-COMPAT`, `REQ-HAZARD-GEN-START-NOW`,
109	`REQ-HAZARD-DAEMON-HOSTED-LIVENESS`, `REQ-HAZARD-RESTART-IDEMPOTENT`, and
110	`REQ-TERM-4` (`int` stage). They sit `required_stages = []`; activate as each task
111	lands. **If** the versioned-IPC frame contract or the in-session relay turns out to
112	need its own conformance id during B0/B3, register it in `traceable-reqs.toml`
113	first, then satisfy it.
114
115	## Sequencing rationale
116
117	Scaffold + the **versioned IPC transport** (the load-bearing seam everything rides)
118	→ the **broker kernel** (owns resources, holds no logic) → the **brain + gapless
119	handoff** (the milestone's hardest proof: kill brain, child + stream survive) →
120	**consolidation** (fold the interim loops in) → **daemon liveness** (localized swap)
121	→ **idempotent boundary** (durable IDs at every effect) → **auto-start + in-memory
122	startup** (retire the seed file) → **orphan-watch** → **digest daemon-half** →
123	activation + E2E. Each task tags evidence + activates its reqs in the same commit;
124	`traceable-reqs check` green and **both OSes** validated before the next (the M3a
125	discipline: Windows local + Linux via gravity-linux).
126
127	## Tasks — `spt-daemon` (the broker/brain process)
128
129	| # | Task | Reqs / hazards | Acceptance |
130	|---|------|----------------|------------|
131	| B0 | **Scaffold `crates/spt-daemon`** (above spt-live, below spt; deps spt-store/msg/runtime/term/live) + the **versioned local-IPC transport**: a `DaemonTransport` trait over named-pipe (win) / UDS (unix), a length-framed message codec, and a **version handshake** with a forward-compatible frame schema | REQ-DAEMON-2 (foundation), REQ-HAZARD-HANDOFF-ARGV-COMPAT | crate compiles; layering `…→spt-live→spt-daemon→spt` acyclic; a brain frame with an unknown trailing field is accepted by an older broker (forward-compat unit test); `check` green |
132	| B1 | **Broker kernel**: spawn a child under a `spt-term::PtySession`; hold the PTY master + child + a listening/client socket; serve them over IPC (subscribe-to-output, write-input, resize, lifecycle). **No logic in the broker.** | REQ-DAEMON-2 (broker half) | a broker hosts one real PTY child on both OSes, streams its output + accepts injected input over IPC; the ADR-0004 §B ownership table is the only state it holds (review-asserted) |
133	| B2 | **Brain + gapless handoff**: brain connects over the versioned IPC, subscribes to the broker's PTY output, sends input; **a brain kill + restart re-attaches with no lost/dup output and the child untouched** (Spike #1 made real). `gen_start = now()` on cold-start **and** handoff | REQ-HAZARD-GEN-START-NOW, REQ-DAEMON-2 (brain half) | regression test: spawn child → brain attached → kill+restart brain ≥10× → child alive throughout, output stream contiguous across each restart, `gen_start` advances each handoff |
134	| B3 | **Consolidation**: fold the `api listen` poll-loop + pulse + Psyche loops into the brain as scheduled in-process loops (re-hosting the spt-live seams); leave only the thin **stateless in-session relay** for harness-hosted sessions. Durable, configurable pulse period | REQ-DAEMON-1 | a LiveAgent runs fully daemon-hosted (spawn→Psyche-loop→commune-ingest→pulse) with **no** separate listener/wrapper process; the relay is killable+respawnable without losing state; period is config-driven |
135	| B4 | **Daemon-authoritative liveness**: the brain's endpoint table + `info.json` `status` supersede `is_process_alive(info.pid)` for daemon-hosted perches; localized behind the single liveness resolver (`is_online`/registry stale-clean) so external/interim perches still probe | REQ-HAZARD-DAEMON-HOSTED-LIVENESS | a daemon-hosted Psyche reads alive/offline from the daemon `status`, not its pid; killing the summarizer subprocess does **not** flip the perch offline (regression test); external perches still pid-probe |
136	| B5 | **Idempotent broker↔brain boundary**: durable IDs + dedup-at-effect at every side-effect crossing (spool/PTY/registry), broker-owned recovery anchor; replay rules on brain restart | REQ-HAZARD-RESTART-IDEMPOTENT (Spike #6) | crash the brain before-intent / before-effect / after-effect at each boundary → exactly-once (no dup, no drop) across all three, value-set verified |
137	| B6 | **Auto-start + spt-hosted startup**: any `api` invocation starts the daemon if absent (`listen` anchor); spt-hosted startup = daemon spawn-session into a broker PTY then `api bind`, with an **in-memory** seed map replacing the `spt-store::seed` file bridge | REQ-DAEMON-3, REQ-START-3 | a cold `api` call transparently spins up the daemon; an spt-hosted session binds with **no seed file written** (the file path is dead); the harness-hosted seed is in-memory + consumed by `parent_pid` |
138	| B7 | **Orphan-watch**: carry the M2b graceful ordering (grace 1.1 / echo 3.3 / stale-sweep 3.2) into the supervised-crash path the always-on daemon enables | REQ-DAEMON-4 (partial) | a supervised crash of a hosted session triggers the same echo-before-signoff + grace + stale-sweep ordering as graceful teardown (regression test) |
139	| B8 | **Digest daemon-half**: manifest `pty_digest` seam (adapter-supplied `input`/`agent`/`tool` patterns) → run the M3a `DigestParser` over the broker PTY; `spt digest <id>` snapshot pull + broker-pushed structured **delta-stream** on the R-TERM-3 substrate + opt-in Path-B persistence. Local addressing only | REQ-TERM-4 (`int`), ADR-0008 | `spt digest <id>` returns the live structured buffer for a daemon-hosted session; a subscriber receives only deltas; opt-in persistence appends to the history store; cross-node addressing explicitly deferred to M4 |
140	| B9 | **Activation sweep + E2E**: activate REQ-DAEMON-1/2/3/4 + REQ-START-3 + the 4 hazards + REQ-TERM-4(`int`); a daemon E2E (spawn→Psyche-loop→commune→**brain-restart-survives**→graceful-signoff) on the CI matrix; amend ROADMAP (M3b delivered) + CONTEXT; author the **M3c plan** stub | — | `check` green with M3b reqs activated; clippy `-D warnings`; CI matrix (ubuntu+windows) green; both-OS validated |
141
142	## M3b requirement-activation map
143
144	Activate as each task lands (default `["impl","unit"]`; daemon E2E supplies `int`):
145	- **REQ-DAEMON-2** (B0–B2), **REQ-HAZARD-HANDOFF-ARGV-COMPAT** (B0),
146	  **REQ-HAZARD-GEN-START-NOW** (B2), **REQ-DAEMON-1** (B3),
147	  **REQ-HAZARD-DAEMON-HOSTED-LIVENESS** (B4),
148	  **REQ-HAZARD-RESTART-IDEMPOTENT** (B5), **REQ-DAEMON-3** + **REQ-START-3** (B6),
149	  **REQ-DAEMON-4** (B7/B9 — the honor-all-hazards meta-gate),
150	  **REQ-TERM-4 `int`** (B8). The brain-restart-survives daemon E2E (B9) is the
151	  cross-cutting `int` evidence for REQ-DAEMON-1/2.
152
153	**Stay `[]` (M3c/M4):** all `REQ-UPD-*`, the M4 grill reqs (`REQ-INST-9..14`,
154	`REQ-PAIR-5/6/7`, `REQ-NOTIF-1/2`, `REQ-SEC-1`), cross-node digest addressing.
155
156	## Workspace change
157
158	Add `crates/spt-daemon` (above `spt-live`, below `spt`). Layering (R-ARCH-1, acyclic):
159
160	```
161	spt-proto → spt-store → spt-msg → {spt-net, spt-term, spt-runtime} → spt-live → spt-daemon → spt
162	```
163
164	`spt-daemon` is **not** public SDK (R-ARCH-2 stays proto/runtime/msg) — it is the
165	internal supervisor; the `spt` binary becomes a thin CLI over it.
166
167	## Key seams (from the interim model M3b replaces)
168
169	| Concern | Interim locus | M3b move |
170	|---|---|---|
171	| listen relay loop | `spt/src/api/startup.rs` (`cmd_listen`) | daemon brain loop |
172	| pulse driver | `spt-live/src/pulse.rs` (`tick`, 5s constant) | daemon-scheduled, configurable |
173	| liveness | `spt-store/src/proc.rs` (`is_process_alive`) via `deliver::is_online` + `registry::clean_stale_entries` | daemon `status` behind the one resolver |
174	| seed bridge | `spt-store/src/seed.rs` (file by `parent_pid`) | in-memory daemon seed map |
175	| Psyche/echo/signoff | `spt-live` seams (already pure fns) | brain calls the same fns |
176	| broker PTY | `spt-term::PtySession` (M3a) | broker holds it, serves over IPC |
177
178	## Risks carried into M3b
179
180	- **The two-process IPC is the hard part.** Abstract the OS split (named pipe vs
181	  UDS) behind one transport trait from B0, exactly as M3a abstracted ConPTY vs
182	  forkpty behind `SessionSurface`. Version the frame schema from the first byte —
183	  retrofitting forward-compat after B3 is expensive.
184	- **Broker scope creep.** The broker holds *only* the ADR-0004 §B resources and
185	  **no logic**. A task that finds itself putting routing/registry/parse in the
186	  broker has crossed the line — stop and surface it. (The §B table grew to include
187	  Iroh streams; that is M4, not M3b.)
188	- **Gapless handoff (B2) is the milestone's load-bearing proof.** Spike #1 proved
189	  the shape on ConPTY; B2 makes it real in the daemon on **both** OSes. Treat the
190	  brain-restart regression as a gate, not polish.
191	- **Idempotency (B5) must thread durable IDs through every effect.** Design against
192	  Spike #6's two constraints (broker-owned recovery anchor; dedup-at-effect keyed by
193	  durable ID), not bolted on after.
194	- **Digest scope (B8).** Local addressing only; the `[subnet:]id@node` qualified
195	  form + cross-node fetch are M4. Resist pulling multi-subnet forward.
196

# M3b Plan — `spt-daemon` broker/brain process

> **Just-in-time, lightweight** — same pattern as `M0/M1/M2a/M2b/M3a-PLAN.md`. The
> ordered task layer between `ROADMAP.md`, `M3-PLAN.md` (the M3 umbrella), and
> `traceable-reqs.toml` (the requirement checklist), scoped to **M3b only**.
> Branch: `dev-freeform`. Authoritative architecture: ADR-0004 (broker/brain split
> + the §B ownership table + the closed §E spike gaps) and `M3-PLAN.md` §M3b.

> **Upstream is done:** Phase 0 spike-gate ✅ (Spikes #3/#4/#5/#6 all PASS — ADR-0004
> §E closed on both OSes) and **M3a ✅ delivered** — `spt-term` provides the
> single-surface PTY *mechanism* (SessionSurface + ConPTY/forkpty backend + DSR
> drain + injection + bounded byte-stream + digest parser primitive). **M3b builds
> the *process* that hosts many of those surfaces** behind a versioned local IPC,
> consolidating the interim no-daemon model.

## Goal

Replace the interim no-daemon model (M1/M2a/M2b: a long-lived `spt api listen`
process holds the perch + drives a relay/pulse loop; a seed *file* bridges
binding; per-pid `is_process_alive` is liveness) with the **real architecture**
(ADR-0004): one per-machine `spt-daemon`, split into a **broker** (stable kernel
holding the un-transferable resources) and a **brain** (restartable logic), so a
routine self-update swaps the brain with **zero endpoint interruption**.

**M3b done =** the daemon hosts the M2b LiveAgent lifecycle as in-process loops
(Psyche-as-loop, durable pulse — no separate listener/wrapper process, only a thin
stateless in-session relay residue) · the broker hosts `spt-term` PTYs + harness
children + sockets behind a **versioned local IPC** that a newer brain speaks to an
older broker · **a brain kill/restart leaves the child + its live output stream
intact and gapless** (Spike #1 shape, made real) · liveness for daemon-hosted
perches is the daemon's `status`, never `is_process_alive(info.pid)` · every
broker↔brain side-effect boundary is **exactly-once** across a brain crash (Spike
#6) · any `api` invocation **auto-starts** the daemon · spt-hosted startup is
spawn-session→`api bind` with an **in-memory** seed (no file) · the **digest
daemon-half** completes REQ-TERM-4 (`int`) · `cargo test --workspace` + clippy
`-D warnings` + `traceable-reqs check` green on the CI matrix (ubuntu + windows).

## Scope

### In — the `spt-daemon` crate (above `spt-live`, below `spt`)

- **broker** (stable kernel, `REQ-DAEMON-2`) — owns **only** the resources a live
  consumer would lose continuity on if the brain restarted (ADR-0004 §B normative
  table): PTY master handles (**from `spt-term::PtySession`**), spawned harness
  child processes, accepted local client sockets, listening sockets. No logic.
  Almost never restarts.
- **brain** (userspace, `REQ-DAEMON-2`) — all logic: routing, registry, manifest
  parse, **the re-hosted M2b lifecycle**. Restarts freely; rehydrates from disk +
  re-attaches to broker-held handles over IPC. `gen_start = now()` on cold-start
  **and** handoff (`REQ-HAZARD-GEN-START-NOW` / 2.4).
- **versioned local IPC** (`REQ-HAZARD-HANDOFF-ARGV-COMPAT` / 2.3) — named pipe
  (Windows) / Unix-domain socket, behind one transport trait (mirrors how
  `SessionSurface` abstracts the PTY OS-split). A version handshake + a
  forward-compatible frame schema so a **newer brain talks to an older broker**.
- **consolidation** (`REQ-DAEMON-1`) — the `spt api listen` poll-loop + the pulse
  loop + the Psyche loop move **into the brain** as scheduled in-process loops; the
  only residue is the thin **stateless in-session relay** for harness-hosted
  sessions (topology 1), freely killable. Durable, configurable pulse period
  replacing the interim 5s constant.
- **daemon-authoritative liveness** (`REQ-HAZARD-DAEMON-HOSTED-LIVENESS` / 2.5) —
  the daemon's in-memory endpoint table + a `status` field on `info.json`
  supersede `is_process_alive(info.pid)` for **daemon-hosted** perches; localized
  behind the single liveness resolver so external/interim perches still probe.
- **idempotent broker↔brain boundary** (`REQ-HAZARD-RESTART-IDEMPOTENT` / 7.2,
  Spike #6) — durable IDs + dedup-at-effect at every side-effect crossing (spool
  write, PTY write, registry update), with a **broker-owned recovery anchor**;
  exactly-once across a brain crash at before-intent / before-effect / after-effect.
- **auto-start** (`REQ-DAEMON-3`) — any `api` invocation starts the daemon if
  absent (`listen` is the reliable anchor); ensure-running lives in the `api` layer.
- **spt-hosted startup** (`REQ-START-3`) — daemon spawn-session into a broker PTY,
  then `api bind`; **in-memory** seed map replaces the `spt-store::seed` file bridge.
- **orphan-watch** — carry the M2b graceful ordering (grace 1.1 / echo 3.3 /
  stale-sweep 3.2) into the **supervised-crash** path the always-on daemon now
  enables (M2b delivered graceful-only).
- **digest daemon-half** (`REQ-TERM-4` `int`, ADR-0008) — manifest-sourcing of the
  `input`/`agent`/`tool` patterns (the `pty_digest` manifest seam), running the
  M3a `DigestParser` over the broker PTY; `spt digest <id>` snapshot pull, the
  broker-pushed structured **delta-stream** on the R-TERM-3 substrate, and opt-in
  Path-B persistence. Local addressing only (the qualified `[subnet:]id@node` form
  is M4); `REQ-DAEMON-4` (honor every KNOWN-HAZARDS invariant) is the meta-gate.

### Out

- networking / P2P, multi-subnet, notifications, **cross-node** digest addressing —
  **M4** (`spt-net`). The QUIC-stream broker-ownership *implementation* is M4
  (only its *shape* was spiked, #3).
- self-update (update-class taxonomy, signing, ripple) — **M3c**.
- whole-daemon live FD-passing (zero-interruption even for *broker* updates) —
  explicit future polish, not v1 (ADR-0004 consequences).

## Clean-room posture

- **`spt-daemon` is brand-new.** The sister ran poll listeners + Psyche wrappers as
  separate processes; ADR-0004 deliberately consolidates them. Clean-room the crate.
- **Re-host, don't rewrite.** The M2b lifecycle is already pure, unit-tested
  functions in `spt-live` (`spawn_psyche`, `resume_psyche`, `run_echo_commune`,
  `ingest_drops`, `graceful_signoff`, `sweep_stale_signoff`, `pulse::tick`,
  `fetch_history`, `write_resume_commune`, `compose_psyche_prompt`). The brain
  *calls* these as loops; the ordering invariants (3.3 / 1.1 / 3.2) stay where they
  are. **Reuse `spt-term::PtySession` for the broker's PTYs** — do not re-implement.
- **Spike findings are binding inputs:** #1 (no-EOF-while-writer-held + DSR; the
  broker-survives-brain-restart shape), #3 (live-stream survival shape), #6
  (broker-owned recovery anchor + dedup-at-effect keyed by durable ID).

## New requirements to register first (TRACEABILITY rule 3)

**Assessment — likely none.** The M3b reqs already exist: `REQ-DAEMON-1/2/3/4`,
`REQ-START-3`, `REQ-HAZARD-HANDOFF-ARGV-COMPAT`, `REQ-HAZARD-GEN-START-NOW`,
`REQ-HAZARD-DAEMON-HOSTED-LIVENESS`, `REQ-HAZARD-RESTART-IDEMPOTENT`, and
`REQ-TERM-4` (`int` stage). They sit `required_stages = []`; activate as each task
lands. **If** the versioned-IPC frame contract or the in-session relay turns out to
need its own conformance id during B0/B3, register it in `traceable-reqs.toml`
first, then satisfy it.

## Sequencing rationale

Scaffold + the **versioned IPC transport** (the load-bearing seam everything rides)
→ the **broker kernel** (owns resources, holds no logic) → the **brain + gapless
handoff** (the milestone's hardest proof: kill brain, child + stream survive) →
**consolidation** (fold the interim loops in) → **daemon liveness** (localized swap)
→ **idempotent boundary** (durable IDs at every effect) → **auto-start + in-memory
startup** (retire the seed file) → **orphan-watch** → **digest daemon-half** →
activation + E2E. Each task tags evidence + activates its reqs in the same commit;
`traceable-reqs check` green and **both OSes** validated before the next (the M3a
discipline: Windows local + Linux via gravity-linux).

## Tasks — `spt-daemon` (the broker/brain process)

| # | Task | Reqs / hazards | Acceptance |
|---|------|----------------|------------|
| B0 | **Scaffold `crates/spt-daemon`** (above spt-live, below spt; deps spt-store/msg/runtime/term/live) + the **versioned local-IPC transport**: a `DaemonTransport` trait over named-pipe (win) / UDS (unix), a length-framed message codec, and a **version handshake** with a forward-compatible frame schema | REQ-DAEMON-2 (foundation), REQ-HAZARD-HANDOFF-ARGV-COMPAT | crate compiles; layering `…→spt-live→spt-daemon→spt` acyclic; a brain frame with an unknown trailing field is accepted by an older broker (forward-compat unit test); `check` green |
| B1 | **Broker kernel**: spawn a child under a `spt-term::PtySession`; hold the PTY master + child + a listening/client socket; serve them over IPC (subscribe-to-output, write-input, resize, lifecycle). **No logic in the broker.** | REQ-DAEMON-2 (broker half) | a broker hosts one real PTY child on both OSes, streams its output + accepts injected input over IPC; the ADR-0004 §B ownership table is the only state it holds (review-asserted) |
| B2 | **Brain + gapless handoff**: brain connects over the versioned IPC, subscribes to the broker's PTY output, sends input; **a brain kill + restart re-attaches with no lost/dup output and the child untouched** (Spike #1 made real). `gen_start = now()` on cold-start **and** handoff | REQ-HAZARD-GEN-START-NOW, REQ-DAEMON-2 (brain half) | regression test: spawn child → brain attached → kill+restart brain ≥10× → child alive throughout, output stream contiguous across each restart, `gen_start` advances each handoff |
| B3 | **Consolidation**: fold the `api listen` poll-loop + pulse + Psyche loops into the brain as scheduled in-process loops (re-hosting the spt-live seams); leave only the thin **stateless in-session relay** for harness-hosted sessions. Durable, configurable pulse period | REQ-DAEMON-1 | a LiveAgent runs fully daemon-hosted (spawn→Psyche-loop→commune-ingest→pulse) with **no** separate listener/wrapper process; the relay is killable+respawnable without losing state; period is config-driven |
| B4 | **Daemon-authoritative liveness**: the brain's endpoint table + `info.json` `status` supersede `is_process_alive(info.pid)` for daemon-hosted perches; localized behind the single liveness resolver (`is_online`/registry stale-clean) so external/interim perches still probe | REQ-HAZARD-DAEMON-HOSTED-LIVENESS | a daemon-hosted Psyche reads alive/offline from the daemon `status`, not its pid; killing the summarizer subprocess does **not** flip the perch offline (regression test); external perches still pid-probe |
| B5 | **Idempotent broker↔brain boundary**: durable IDs + dedup-at-effect at every side-effect crossing (spool/PTY/registry), broker-owned recovery anchor; replay rules on brain restart | REQ-HAZARD-RESTART-IDEMPOTENT (Spike #6) | crash the brain before-intent / before-effect / after-effect at each boundary → exactly-once (no dup, no drop) across all three, value-set verified |
| B6 | **Auto-start + spt-hosted startup**: any `api` invocation starts the daemon if absent (`listen` anchor); spt-hosted startup = daemon spawn-session into a broker PTY then `api bind`, with an **in-memory** seed map replacing the `spt-store::seed` file bridge | REQ-DAEMON-3, REQ-START-3 | a cold `api` call transparently spins up the daemon; an spt-hosted session binds with **no seed file written** (the file path is dead); the harness-hosted seed is in-memory + consumed by `parent_pid` |
| B7 | **Orphan-watch**: carry the M2b graceful ordering (grace 1.1 / echo 3.3 / stale-sweep 3.2) into the supervised-crash path the always-on daemon enables | REQ-DAEMON-4 (partial) | a supervised crash of a hosted session triggers the same echo-before-signoff + grace + stale-sweep ordering as graceful teardown (regression test) |
| B8 | **Digest daemon-half**: manifest `pty_digest` seam (adapter-supplied `input`/`agent`/`tool` patterns) → run the M3a `DigestParser` over the broker PTY; `spt digest <id>` snapshot pull + broker-pushed structured **delta-stream** on the R-TERM-3 substrate + opt-in Path-B persistence. Local addressing only | REQ-TERM-4 (`int`), ADR-0008 | `spt digest <id>` returns the live structured buffer for a daemon-hosted session; a subscriber receives only deltas; opt-in persistence appends to the history store; cross-node addressing explicitly deferred to M4 |
| B9 | **Activation sweep + E2E**: activate REQ-DAEMON-1/2/3/4 + REQ-START-3 + the 4 hazards + REQ-TERM-4(`int`); a daemon E2E (spawn→Psyche-loop→commune→**brain-restart-survives**→graceful-signoff) on the CI matrix; amend ROADMAP (M3b delivered) + CONTEXT; author the **M3c plan** stub | — | `check` green with M3b reqs activated; clippy `-D warnings`; CI matrix (ubuntu+windows) green; both-OS validated |

## M3b requirement-activation map

Activate as each task lands (default `["impl","unit"]`; daemon E2E supplies `int`):
- **REQ-DAEMON-2** (B0–B2), **REQ-HAZARD-HANDOFF-ARGV-COMPAT** (B0),
  **REQ-HAZARD-GEN-START-NOW** (B2), **REQ-DAEMON-1** (B3),
  **REQ-HAZARD-DAEMON-HOSTED-LIVENESS** (B4),
  **REQ-HAZARD-RESTART-IDEMPOTENT** (B5), **REQ-DAEMON-3** + **REQ-START-3** (B6),
  **REQ-DAEMON-4** (B7/B9 — the honor-all-hazards meta-gate),
  **REQ-TERM-4 `int`** (B8). The brain-restart-survives daemon E2E (B9) is the
  cross-cutting `int` evidence for REQ-DAEMON-1/2.

**Stay `[]` (M3c/M4):** all `REQ-UPD-*`, the M4 grill reqs (`REQ-INST-9..14`,
`REQ-PAIR-5/6/7`, `REQ-NOTIF-1/2`, `REQ-SEC-1`), cross-node digest addressing.

## Workspace change

Add `crates/spt-daemon` (above `spt-live`, below `spt`). Layering (R-ARCH-1, acyclic):

```
spt-proto → spt-store → spt-msg → {spt-net, spt-term, spt-runtime} → spt-live → spt-daemon → spt
```

`spt-daemon` is **not** public SDK (R-ARCH-2 stays proto/runtime/msg) — it is the
internal supervisor; the `spt` binary becomes a thin CLI over it.

## Key seams (from the interim model M3b replaces)

| Concern | Interim locus | M3b move |
|---|---|---|
| listen relay loop | `spt/src/api/startup.rs` (`cmd_listen`) | daemon brain loop |
| pulse driver | `spt-live/src/pulse.rs` (`tick`, 5s constant) | daemon-scheduled, configurable |
| liveness | `spt-store/src/proc.rs` (`is_process_alive`) via `deliver::is_online` + `registry::clean_stale_entries` | daemon `status` behind the one resolver |
| seed bridge | `spt-store/src/seed.rs` (file by `parent_pid`) | in-memory daemon seed map |
| Psyche/echo/signoff | `spt-live` seams (already pure fns) | brain calls the same fns |
| broker PTY | `spt-term::PtySession` (M3a) | broker holds it, serves over IPC |

## Risks carried into M3b

- **The two-process IPC is the hard part.** Abstract the OS split (named pipe vs
  UDS) behind one transport trait from B0, exactly as M3a abstracted ConPTY vs
  forkpty behind `SessionSurface`. Version the frame schema from the first byte —
  retrofitting forward-compat after B3 is expensive.
- **Broker scope creep.** The broker holds *only* the ADR-0004 §B resources and
  **no logic**. A task that finds itself putting routing/registry/parse in the
  broker has crossed the line — stop and surface it. (The §B table grew to include
  Iroh streams; that is M4, not M3b.)
- **Gapless handoff (B2) is the milestone's load-bearing proof.** Spike #1 proved
  the shape on ConPTY; B2 makes it real in the daemon on **both** OSes. Treat the
  brain-restart regression as a gate, not polish.
- **Idempotency (B5) must thread durable IDs through every effect.** Design against
  Spike #6's two constraints (broker-owned recovery anchor; dedup-at-effect keyed by
  durable ID), not bolted on after.
- **Digest scope (B8).** Local addressing only; the `[subnet:]id@node` qualified
  form + cross-node fetch are M4. Resist pulling multi-subnet forward.


B3 = large integration. Let me see daemon crate + seams before planning.


Bash

ls "C:/Users/decid/Documents/projects/spt-core/crates/spt-daemon/src" && echo "---LIVE---" && ls "C:/Users/decid/Documents/projects/spt-core/crates/spt-live/src" && echo "---API---" && ls "C:/Users/decid/Documents/projects/spt-core/crates/spt/src/api"

List daemon, live, api source dirs


brain.rs
broker.rs
codec.rs
frame.rs
lib.rs
msg.rs
transport.rs
---LIVE---
context.rs
echo.rs
history.rs
ingest.rs
lib.rs
psyche.rs
pulse.rs
resume.rs
signoff.rs
test_support.rs
---API---
auth.rs
delivery.rs
live.rs
mod.rs
reporting.rs
startup.rs
worker.rs


1	//! The brain (B2) — the restartable logic half that attaches to the broker.
2	//!
3	//! The brain holds all the volatile logic; it may be killed and restarted at
4	//! any moment (a routine self-update swaps it; a crash kills it). It connects to
5	//! the stable [`crate::broker::Broker`] over the versioned IPC, drives sessions
6	//! (spawn / input / resize / kill), and consumes their output. Two invariants
7	//! this type encodes:
8	//!
9	//! - **`gen_start = now()` on cold-start *and* handoff (KNOWN-HAZARDS 2.4).**
10	//!   The wall-clock generation stamp is taken fresh on every start. A handed-off
11	//!   brain rehydrates its resume cursor and generation *counter* from prior
12	//!   state, but its `gen_start` is `now()`, **never** the stale value from that
13	//!   state — a rehydrated `gen_start` would fire time-based discriminators on
14	//!   the new generation. [`Brain::handoff`] takes the prior state by value yet
15	//!   ignores its `gen_start_ms`.
16	//! - **Gapless, exactly-once resume.** The brain tracks `next_seq` — the next
17	//!   output sequence it still needs. On handoff it `subscribe`s from that cursor;
18	//!   the broker replays buffered output from there. [`Brain::read_event`] accepts
19	//!   only the contiguous next `seq` (advancing the cursor), silently drops an
20	//!   already-seen `seq` (a replay duplicate), and treats a forward gap as a hard
21	//!   error — so a lost chunk can never pass silently.
22	// [impl->REQ-HAZARD-GEN-START-NOW]
23	// [impl->REQ-DAEMON-2]
24
25	use std::io;
26	use std::time::{SystemTime, UNIX_EPOCH};
27
28	use interprocess::local_socket::Stream;
29
30	use crate::codec::{read_frame, write_frame};
31	use crate::frame::{Envelope, Role};
32	use crate::msg::{
33	    decode_bytes, encode_bytes, ErrorEvent, ExitEvent, InputReq, KillReq, OutputEvent, ResizeReq,
34	    SpawnReq, Spawned, SubscribeReq, KIND_ERROR, KIND_EXIT, KIND_INPUT, KIND_KILL, KIND_OUTPUT,
35	    KIND_RESIZE, KIND_SPAWN, KIND_SPAWNED, KIND_SUBSCRIBE,
36	};
37	use crate::transport::{send_hello, LocalSocketTransport};
38
39	/// Wall-clock now in epoch milliseconds — the source for `gen_start` (matches
40	/// the epoch-ms stamping used elsewhere in the tree; no date dependency).
41	pub fn now_ms() -> u64 {
42	    SystemTime::now()
43	        .duration_since(UNIX_EPOCH)
44	        .map(|d| d.as_millis() as u64)
45	        .unwrap_or(0)
46	}
47
48	/// The brain's durable state across a handoff: enough to re-attach gaplessly and
49	/// advance the generation. `gen_start_ms` is recorded for observability but is
50	/// **deliberately not authoritative on restart** — [`Brain::handoff`] sets a
51	/// fresh `gen_start` from `now()` (2.4).
52	#[derive(Debug, Clone)]
53	pub struct BrainState {
54	    /// The session the brain was driving.
55	    pub session_id: u64,
56	    /// The generation counter (incremented on each handoff).
57	    pub generation: u64,
58	    /// The next output sequence still needed (the resume cursor).
59	    pub next_seq: u64,
60	    /// The prior generation's wall-clock start (ignored as authoritative on
61	    /// handoff — kept only to demonstrate it is *not* carried forward).
62	    pub gen_start_ms: u64,
63	}
64
65	/// A demuxed broker→brain event.
66	#[derive(Debug)]
67	pub enum BrokerEvent {
68	    /// A contiguous chunk of a session's output (the cursor has advanced past it).
69	    Output { session_id: u64, seq: u64, bytes: Vec<u8> },
70	    /// A spawn succeeded.
71	    Spawned { session_id: u64, pid: Option<u32> },
72	    /// A session's child exited.
73	    Exit { session_id: u64, code: Option<i32> },
74	    /// The broker reported a command error.
75	    Error { message: String },
76	    /// A frame of an unrecognized kind (forward-compat: tolerated, surfaced).
77	    Other(Envelope),
78	}
79
80	/// A live brain attached to a broker.
81	pub struct Brain {
82	    conn: Stream,
83	    generation: u64,
84	    gen_start_ms: u64,
85	    session_id: Option<u64>,
86	    /// The next output sequence still needed (resume cursor / dedup floor).
87	    next_seq: u64,
88	}
89
90	impl Brain {
91	    /// Cold start: connect, handshake, fresh generation 0, `gen_start = now`.
92	    pub fn cold_start(name: &str, now_ms: u64) -> io::Result<Self> {
93	        let mut conn = LocalSocketTransport::connect(name)?;
94	        send_hello(&mut conn, Role::Brain)?;
95	        Ok(Brain { conn, generation: 0, gen_start_ms: now_ms, session_id: None, next_seq: 0 })
96	    }
97
98	    /// Handoff: connect, handshake, rehydrate the resume cursor + generation
99	    /// from `prior`, but stamp `gen_start = now()` (NOT `prior.gen_start_ms` —
100	    /// the 2.4 invariant), and re-subscribe to the session from the cursor for a
101	    /// gapless resume.
102	    pub fn handoff(name: &str, prior: BrainState, now_ms: u64) -> io::Result<Self> {
103	        let mut conn = LocalSocketTransport::connect(name)?;
104	        send_hello(&mut conn, Role::Brain)?;
105	        let mut brain = Brain {
106	            conn,
107	            generation: prior.generation + 1,
108	            gen_start_ms: now_ms, // fresh — prior.gen_start_ms is intentionally discarded
109	            session_id: Some(prior.session_id),
110	            next_seq: prior.next_seq,
111	        };
112	        brain.subscribe(prior.session_id, prior.next_seq)?;
113	        Ok(brain)
114	    }
115
116	    /// The wall-clock generation start (epoch ms) — always this generation's own
117	    /// start time, never a rehydrated value.
118	    pub fn gen_start_ms(&self) -> u64 {
119	        self.gen_start_ms
120	    }
121
122	    /// The generation counter (0 on cold start, +1 per handoff).
123	    pub fn generation(&self) -> u64 {
124	        self.generation
125	    }
126
127	    /// The next output sequence still needed (the resume cursor).
128	    pub fn next_seq(&self) -> u64 {
129	        self.next_seq
130	    }
131
132	    /// The session id the brain is driving, if any.
133	    pub fn session_id(&self) -> Option<u64> {
134	        self.session_id
135	    }
136
137	    /// Snapshot durable state for a future handoff.
138	    pub fn snapshot(&self) -> BrainState {
139	        BrainState {
140	            session_id: self.session_id.expect("snapshot before a session exists"),
141	            generation: self.generation,
142	            next_seq: self.next_seq,
143	            gen_start_ms: self.gen_start_ms,
144	        }
145	    }
146
147	    /// Spawn a session under a broker PTY; the broker auto-subscribes this
148	    /// connection. Reads until the `spawned` reply (consuming any output that
149	    /// races ahead).
150	    pub fn spawn_session(&mut self, req: SpawnReq) -> io::Result<u64> {
151	        self.send(KIND_SPAWN, serde_json::to_value(req).expect("SpawnReq serializes"))?;
152	        loop {
153	            match self.read_event()? {
154	                BrokerEvent::Spawned { session_id, .. } => {
155	                    self.session_id = Some(session_id);
156	                    return Ok(session_id);
157	                }
158	                BrokerEvent::Error { message } => {
159	                    return Err(io::Error::other(message));
160	                }
161	                _ => continue,
162	            }
163	        }
164	    }
165
166	    /// Inject input bytes into the current session (send-keys).
167	    pub fn send_input(&mut self, bytes: &[u8]) -> io::Result<()> {
168	        let sid = self.require_session()?;
169	        self.send(
170	            KIND_INPUT,
171	            serde_json::to_value(InputReq { session_id: sid, data_b64: encode_bytes(bytes) })
172	                .expect("InputReq serializes"),
173	        )
174	    }
175
176	    /// Resize the current session's PTY.
177	    pub fn resize(&mut self, rows: u16, cols: u16) -> io::Result<()> {
178	        let sid = self.require_session()?;
179	        self.send(
180	            KIND_RESIZE,
181	            serde_json::to_value(ResizeReq { session_id: sid, rows, cols })
182	                .expect("ResizeReq serializes"),
183	        )
184	    }
185
186	    /// Terminate the current session's child.
187	    pub fn kill_session(&mut self) -> io::Result<()> {
188	        let sid = self.require_session()?;
189	        self.send(
190	            KIND_KILL,
191	            serde_json::to_value(KillReq { session_id: sid }).expect("KillReq serializes"),
192	        )
193	    }
194
195	    /// Read output events until the accumulated bytes contain `needle`,
196	    /// appending all received output to `buf`. Returns the highest seq seen.
197	    /// A session `Exit` before the needle is found is an error (the child died).
198	    pub fn read_output_until(&mut self, needle: &[u8], buf: &mut Vec<u8>) -> io::Result<u64> {
199	        loop {
200	            if contains(buf, needle) {
201	                return Ok(self.next_seq.saturating_sub(1));
202	            }
203	            match self.read_event()? {
204	                BrokerEvent::Output { bytes, .. } => buf.extend_from_slice(&bytes),
205	                BrokerEvent::Exit { .. } => {
206	                    return Err(io::Error::new(
207	                        io::ErrorKind::UnexpectedEof,
208	                        "session exited before the expected output arrived",
209	                    ));
210	                }
211	                BrokerEvent::Error { message } => {
212	                    return Err(io::Error::other(message));
213	                }
214	                _ => {}
215	            }
216	        }
217	    }
218
219	    /// Read and demux one broker event, enforcing exactly-once contiguous output:
220	    /// accept the next `seq` (advancing the cursor), drop an already-seen `seq`
221	    /// (replay duplicate), and reject a forward gap (lost chunk).
222	    pub fn read_event(&mut self) -> io::Result<BrokerEvent> {
223	        loop {
224	            let env = read_frame(&mut self.conn)?;
225	            match env.kind.as_str() {
226	                KIND_OUTPUT => {
227	                    let ev: OutputEvent = serde_json::from_value(env.payload)
228	                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
229	                    if ev.seq < self.next_seq {
230	                        continue; // already processed — a replay duplicate; dedup
231	                    }
232	                    if ev.seq > self.next_seq {
233	                        return Err(io::Error::new(
234	                            io::ErrorKind::InvalidData,
235	                            format!("output gap: got seq {} want {}", ev.seq, self.next_seq),
236	                        ));
237	                    }
238	                    self.next_seq = ev.seq + 1;
239	                    let bytes = decode_bytes(&ev.data_b64)
240	                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
241	                    return Ok(BrokerEvent::Output { session_id: ev.session_id, seq: ev.seq, bytes });
242	                }
243	                KIND_SPAWNED => {
244	                    let ev: Spawned = serde_json::from_value(env.payload)
245	                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
246	                    return Ok(BrokerEvent::Spawned { session_id: ev.session_id, pid: ev.pid });
247	                }
248	                KIND_EXIT => {
249	                    let ev: ExitEvent = serde_json::from_value(env.payload)
250	                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
251	                    return Ok(BrokerEvent::Exit { session_id: ev.session_id, code: ev.code });
252	                }
253	                KIND_ERROR => {
254	                    let ev: ErrorEvent = serde_json::from_value(env.payload)
255	                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
256	                    return Ok(BrokerEvent::Error { message: ev.message });
257	                }
258	                _ => return Ok(BrokerEvent::Other(env)),
259	            }
260	        }
261	    }
262
263	    /// Re-attach to a session's output from `from_seq` (handoff resume).
264	    fn subscribe(&mut self, session_id: u64, from_seq: u64) -> io::Result<()> {
265	        self.send(
266	            KIND_SUBSCRIBE,
267	            serde_json::to_value(SubscribeReq { session_id, from_seq })
268	                .expect("SubscribeReq serializes"),
269	        )
270	    }
271
272	    fn send(&mut self, kind: &str, payload: serde_json::Value) -> io::Result<()> {
273	        write_frame(&mut self.conn, &Envelope::new(kind, payload))
274	    }
275
276	    fn require_session(&self) -> io::Result<u64> {
277	        self.session_id
278	            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no active session"))
279	    }
280	}
281
282	/// Whether `haystack` contains the contiguous `needle`.
283	fn contains(haystack: &[u8], needle: &[u8]) -> bool {
284	    if needle.is_empty() {
285	        return true;
286	    }
287	    haystack.windows(needle.len()).any(|w| w == needle)
288	}
289

//! The brain (B2) — the restartable logic half that attaches to the broker.
//!
//! The brain holds all the volatile logic; it may be killed and restarted at
//! any moment (a routine self-update swaps it; a crash kills it). It connects to
//! the stable [`crate::broker::Broker`] over the versioned IPC, drives sessions
//! (spawn / input / resize / kill), and consumes their output. Two invariants
//! this type encodes:
//!
//! - **`gen_start = now()` on cold-start *and* handoff (KNOWN-HAZARDS 2.4).**
//!   The wall-clock generation stamp is taken fresh on every start. A handed-off
//!   brain rehydrates its resume cursor and generation *counter* from prior
//!   state, but its `gen_start` is `now()`, **never** the stale value from that
//!   state — a rehydrated `gen_start` would fire time-based discriminators on
//!   the new generation. [`Brain::handoff`] takes the prior state by value yet
//!   ignores its `gen_start_ms`.
//! - **Gapless, exactly-once resume.** The brain tracks `next_seq` — the next
//!   output sequence it still needs. On handoff it `subscribe`s from that cursor;
//!   the broker replays buffered output from there. [`Brain::read_event`] accepts
//!   only the contiguous next `seq` (advancing the cursor), silently drops an
//!   already-seen `seq` (a replay duplicate), and treats a forward gap as a hard
//!   error — so a lost chunk can never pass silently.
// [impl->REQ-HAZARD-GEN-START-NOW]
// [impl->REQ-DAEMON-2]

use std::io;
use std::time::{SystemTime, UNIX_EPOCH};

use interprocess::local_socket::Stream;

use crate::codec::{read_frame, write_frame};
use crate::frame::{Envelope, Role};
use crate::msg::{
    decode_bytes, encode_bytes, ErrorEvent, ExitEvent, InputReq, KillReq, OutputEvent, ResizeReq,
    SpawnReq, Spawned, SubscribeReq, KIND_ERROR, KIND_EXIT, KIND_INPUT, KIND_KILL, KIND_OUTPUT,
    KIND_RESIZE, KIND_SPAWN, KIND_SPAWNED, KIND_SUBSCRIBE,
};
use crate::transport::{send_hello, LocalSocketTransport};

/// Wall-clock now in epoch milliseconds — the source for `gen_start` (matches
/// the epoch-ms stamping used elsewhere in the tree; no date dependency).
pub fn now_ms() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0)
}

/// The brain's durable state across a handoff: enough to re-attach gaplessly and
/// advance the generation. `gen_start_ms` is recorded for observability but is
/// **deliberately not authoritative on restart** — [`Brain::handoff`] sets a
/// fresh `gen_start` from `now()` (2.4).
#[derive(Debug, Clone)]
pub struct BrainState {
    /// The session the brain was driving.
    pub session_id: u64,
    /// The generation counter (incremented on each handoff).
    pub generation: u64,
    /// The next output sequence still needed (the resume cursor).
    pub next_seq: u64,
    /// The prior generation's wall-clock start (ignored as authoritative on
    /// handoff — kept only to demonstrate it is *not* carried forward).
    pub gen_start_ms: u64,
}

/// A demuxed broker→brain event.
#[derive(Debug)]
pub enum BrokerEvent {
    /// A contiguous chunk of a session's output (the cursor has advanced past it).
    Output { session_id: u64, seq: u64, bytes: Vec<u8> },
    /// A spawn succeeded.
    Spawned { session_id: u64, pid: Option<u32> },
    /// A session's child exited.
    Exit { session_id: u64, code: Option<i32> },
    /// The broker reported a command error.
    Error { message: String },
    /// A frame of an unrecognized kind (forward-compat: tolerated, surfaced).
    Other(Envelope),
}

/// A live brain attached to a broker.
pub struct Brain {
    conn: Stream,
    generation: u64,
    gen_start_ms: u64,
    session_id: Option<u64>,
    /// The next output sequence still needed (resume cursor / dedup floor).
    next_seq: u64,
}

impl Brain {
    /// Cold start: connect, handshake, fresh generation 0, `gen_start = now`.
    pub fn cold_start(name: &str, now_ms: u64) -> io::Result<Self> {
        let mut conn = LocalSocketTransport::connect(name)?;
        send_hello(&mut conn, Role::Brain)?;
        Ok(Brain { conn, generation: 0, gen_start_ms: now_ms, session_id: None, next_seq: 0 })
    }

    /// Handoff: connect, handshake, rehydrate the resume cursor + generation
    /// from `prior`, but stamp `gen_start = now()` (NOT `prior.gen_start_ms` —
    /// the 2.4 invariant), and re-subscribe to the session from the cursor for a
    /// gapless resume.
    pub fn handoff(name: &str, prior: BrainState, now_ms: u64) -> io::Result<Self> {
        let mut conn = LocalSocketTransport::connect(name)?;
        send_hello(&mut conn, Role::Brain)?;
        let mut brain = Brain {
            conn,
            generation: prior.generation + 1,
            gen_start_ms: now_ms, // fresh — prior.gen_start_ms is intentionally discarded
            session_id: Some(prior.session_id),
            next_seq: prior.next_seq,
        };
        brain.subscribe(prior.session_id, prior.next_seq)?;
        Ok(brain)
    }

    /// The wall-clock generation start (epoch ms) — always this generation's own
    /// start time, never a rehydrated value.
    pub fn gen_start_ms(&self) -> u64 {
        self.gen_start_ms
    }

    /// The generation counter (0 on cold start, +1 per handoff).
    pub fn generation(&self) -> u64 {
        self.generation
    }

    /// The next output sequence still needed (the resume cursor).
    pub fn next_seq(&self) -> u64 {
        self.next_seq
    }

    /// The session id the brain is driving, if any.
    pub fn session_id(&self) -> Option<u64> {
        self.session_id
    }

    /// Snapshot durable state for a future handoff.
    pub fn snapshot(&self) -> BrainState {
        BrainState {
            session_id: self.session_id.expect("snapshot before a session exists"),
            generation: self.generation,
            next_seq: self.next_seq,
            gen_start_ms: self.gen_start_ms,
        }
    }

    /// Spawn a session under a broker PTY; the broker auto-subscribes this
    /// connection. Reads until the `spawned` reply (consuming any output that
    /// races ahead).
    pub fn spawn_session(&mut self, req: SpawnReq) -> io::Result<u64> {
        self.send(KIND_SPAWN, serde_json::to_value(req).expect("SpawnReq serializes"))?;
        loop {
            match self.read_event()? {
                BrokerEvent::Spawned { session_id, .. } => {
                    self.session_id = Some(session_id);
                    return Ok(session_id);
                }
                BrokerEvent::Error { message } => {
                    return Err(io::Error::other(message));
                }
                _ => continue,
            }
        }
    }

    /// Inject input bytes into the current session (send-keys).
    pub fn send_input(&mut self, bytes: &[u8]) -> io::Result<()> {
        let sid = self.require_session()?;
        self.send(
            KIND_INPUT,
            serde_json::to_value(InputReq { session_id: sid, data_b64: encode_bytes(bytes) })
                .expect("InputReq serializes"),
        )
    }

    /// Resize the current session's PTY.
    pub fn resize(&mut self, rows: u16, cols: u16) -> io::Result<()> {
        let sid = self.require_session()?;
        self.send(
            KIND_RESIZE,
            serde_json::to_value(ResizeReq { session_id: sid, rows, cols })
                .expect("ResizeReq serializes"),
        )
    }

    /// Terminate the current session's child.
    pub fn kill_session(&mut self) -> io::Result<()> {
        let sid = self.require_session()?;
        self.send(
            KIND_KILL,
            serde_json::to_value(KillReq { session_id: sid }).expect("KillReq serializes"),
        )
    }

    /// Read output events until the accumulated bytes contain `needle`,
    /// appending all received output to `buf`. Returns the highest seq seen.
    /// A session `Exit` before the needle is found is an error (the child died).
    pub fn read_output_until(&mut self, needle: &[u8], buf: &mut Vec<u8>) -> io::Result<u64> {
        loop {
            if contains(buf, needle) {
                return Ok(self.next_seq.saturating_sub(1));
            }
            match self.read_event()? {
                BrokerEvent::Output { bytes, .. } => buf.extend_from_slice(&bytes),
                BrokerEvent::Exit { .. } => {
                    return Err(io::Error::new(
                        io::ErrorKind::UnexpectedEof,
                        "session exited before the expected output arrived",
                    ));
                }
                BrokerEvent::Error { message } => {
                    return Err(io::Error::other(message));
                }
                _ => {}
            }
        }
    }

    /// Read and demux one broker event, enforcing exactly-once contiguous output:
    /// accept the next `seq` (advancing the cursor), drop an already-seen `seq`
    /// (replay duplicate), and reject a forward gap (lost chunk).
    pub fn read_event(&mut self) -> io::Result<BrokerEvent> {
        loop {
            let env = read_frame(&mut self.conn)?;
            match env.kind.as_str() {
                KIND_OUTPUT => {
                    let ev: OutputEvent = serde_json::from_value(env.payload)
                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                    if ev.seq < self.next_seq {
                        continue; // already processed — a replay duplicate; dedup
                    }
                    if ev.seq > self.next_seq {
                        return Err(io::Error::new(
                            io::ErrorKind::InvalidData,
                            format!("output gap: got seq {} want {}", ev.seq, self.next_seq),
                        ));
                    }
                    self.next_seq = ev.seq + 1;
                    let bytes = decode_bytes(&ev.data_b64)
                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                    return Ok(BrokerEvent::Output { session_id: ev.session_id, seq: ev.seq, bytes });
                }
                KIND_SPAWNED => {
                    let ev: Spawned = serde_json::from_value(env.payload)
                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                    return Ok(BrokerEvent::Spawned { session_id: ev.session_id, pid: ev.pid });
                }
                KIND_EXIT => {
                    let ev: ExitEvent = serde_json::from_value(env.payload)
                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                    return Ok(BrokerEvent::Exit { session_id: ev.session_id, code: ev.code });
                }
                KIND_ERROR => {
                    let ev: ErrorEvent = serde_json::from_value(env.payload)
                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                    return Ok(BrokerEvent::Error { message: ev.message });
                }
                _ => return Ok(BrokerEvent::Other(env)),
            }
        }
    }

    /// Re-attach to a session's output from `from_seq` (handoff resume).
    fn subscribe(&mut self, session_id: u64, from_seq: u64) -> io::Result<()> {
        self.send(
            KIND_SUBSCRIBE,
            serde_json::to_value(SubscribeReq { session_id, from_seq })
                .expect("SubscribeReq serializes"),
        )
    }

    fn send(&mut self, kind: &str, payload: serde_json::Value) -> io::Result<()> {
        write_frame(&mut self.conn, &Envelope::new(kind, payload))
    }

    fn require_session(&self) -> io::Result<u64> {
        self.session_id
            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no active session"))
    }
}

/// Whether `haystack` contains the contiguous `needle`.
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
    if needle.is_empty() {
        return true;
    }
    haystack.windows(needle.len()).any(|w| w == needle)
}


1	//! `spt-daemon` — the per-machine broker/brain supervisor (M3b).
2	//!
3	//! ADR-0004 splits the logical daemon into two implementation layers so a
4	//! routine self-update swaps the logic without disturbing live endpoints:
5	//!
6	//! - a **broker** (stable kernel) holds *only* the un-transferable resources a
7	//!   live consumer would lose continuity on if the logic restarted — PTY
8	//!   masters (from [`spt_term`]), spawned harness children, accepted/listening
9	//!   sockets — and runs no logic;
10	//! - a **brain** (restartable logic) holds routing, registry, manifest parse,
11	//!   and the re-hosted [`spt_live`] lifecycle, attaching to the broker over a
12	//!   **versioned local IPC** so a *newer brain talks to an older broker*.
13	//!
14	//! **B0 (this commit)** lands the crate's layer position plus that versioned
15	//! IPC seam — the load-bearing contract everything else rides:
16	//!
17	//! - [`frame`] — the forward-compatible [`Envelope`] schema + the version
18	//!   [`Hello`] handshake (KNOWN-HAZARDS 2.3, [`REQ-HAZARD-HANDOFF-ARGV-COMPAT`]);
19	//! - [`codec`] — length-prefixed JSON framing over any [`std::io::Read`]/`Write`;
20	//! - [`transport`] — the OS-split carrier (named pipe / UDS) behind one
21	//!   [`DaemonTransport`] trait, mirroring `spt-term`'s `SessionSurface`.
22	//!
23	//! **B1 (this commit)** adds the **broker kernel** ([`broker`]) — the stable
24	//! half that hosts a real `spt-term` PTY child and relays its output/input over
25	//! the IPC, holding only the ADR-0004 §B resources and no logic — plus the
26	//! broker↔brain message catalog ([`msg`]).
27	//!
28	//! The brain + gapless handoff (B2) and the rest of the M3b task table build on
29	//! this seam. `spt-daemon` is internal supervisor machinery, **not** public SDK
30	//! (R-ARCH-2).
31	//!
32	//! [`Envelope`]: frame::Envelope
33	//! [`Hello`]: frame::Hello
34	//! [`DaemonTransport`]: transport::DaemonTransport
35
36	pub mod brain;
37	pub mod broker;
38	pub mod codec;
39	pub mod frame;
40	pub mod msg;
41	pub mod transport;
42
43	pub use brain::{Brain, BrainState, BrokerEvent};
44	pub use broker::Broker;
45	pub use frame::{
46	    accept_hello, Envelope, HandshakeError, Hello, Role, IPC_PROTOCOL_VERSION,
47	    MIN_COMPATIBLE_VERSION,
48	};
49	pub use transport::{DaemonTransport, LocalSocketTransport};
50

//! `spt-daemon` — the per-machine broker/brain supervisor (M3b).
//!
//! ADR-0004 splits the logical daemon into two implementation layers so a
//! routine self-update swaps the logic without disturbing live endpoints:
//!
//! - a **broker** (stable kernel) holds *only* the un-transferable resources a
//!   live consumer would lose continuity on if the logic restarted — PTY
//!   masters (from [`spt_term`]), spawned harness children, accepted/listening
//!   sockets — and runs no logic;
//! - a **brain** (restartable logic) holds routing, registry, manifest parse,
//!   and the re-hosted [`spt_live`] lifecycle, attaching to the broker over a
//!   **versioned local IPC** so a *newer brain talks to an older broker*.
//!
//! **B0 (this commit)** lands the crate's layer position plus that versioned
//! IPC seam — the load-bearing contract everything else rides:
//!
//! - [`frame`] — the forward-compatible [`Envelope`] schema + the version
//!   [`Hello`] handshake (KNOWN-HAZARDS 2.3, [`REQ-HAZARD-HANDOFF-ARGV-COMPAT`]);
//! - [`codec`] — length-prefixed JSON framing over any [`std::io::Read`]/`Write`;
//! - [`transport`] — the OS-split carrier (named pipe / UDS) behind one
//!   [`DaemonTransport`] trait, mirroring `spt-term`'s `SessionSurface`.
//!
//! **B1 (this commit)** adds the **broker kernel** ([`broker`]) — the stable
//! half that hosts a real `spt-term` PTY child and relays its output/input over
//! the IPC, holding only the ADR-0004 §B resources and no logic — plus the
//! broker↔brain message catalog ([`msg`]).
//!
//! The brain + gapless handoff (B2) and the rest of the M3b task table build on
//! this seam. `spt-daemon` is internal supervisor machinery, **not** public SDK
//! (R-ARCH-2).
//!
//! [`Envelope`]: frame::Envelope
//! [`Hello`]: frame::Hello
//! [`DaemonTransport`]: transport::DaemonTransport

pub mod brain;
pub mod broker;
pub mod codec;
pub mod frame;
pub mod msg;
pub mod transport;

pub use brain::{Brain, BrainState, BrokerEvent};
pub use broker::Broker;
pub use frame::{
    accept_hello, Envelope, HandshakeError, Hello, Role, IPC_PROTOCOL_VERSION,
    MIN_COMPATIBLE_VERSION,
};
pub use transport::{DaemonTransport, LocalSocketTransport};


Now read interim seams to consolidate: listen loop, LiveHost, pulse.