1	//! B8 integration: the **PTY-digest daemon-half**, end to end through the real
2	//! IPC (ADR-0008 / REQ-TERM-4).
3	//!
4	//! A [`Broker`] hosts a real PTY child; a [`Brain`] folds that child's live output
5	//! through a [`DigestEngine`] (the brain-side parse — never the broker, ADR-0004
6	//! §B) and publishes each change into the [`DigestHub`]; a separate client reads
7	//! it back over the real digest-control channel both ways ADR-0008 specifies — a
8	//! `spt digest` **snapshot pull** ([`pull_snapshot`]) and a **delta-stream**
9	//! ([`follow`]) — and the opt-in Path-B persistence appends settled turns to the
10	//! perch history store. This is the whole B8 contract over the actual named-pipe
11	//! / UDS transport, on both OSes.
12	//!
13	//! The child is the same cross-OS echo program the B1 broker test uses (`cat` /
14	//! `findstr "."`): bytes injected into its PTY round-trip back as output carrying
15	//! the digest boundary markers, so the engine has a real stream to parse. PTY
16	//! echo may duplicate a line; the assertions are therefore content-tolerant
17	//! (`any`), exactly as the digest parser's own repaint-tolerance contract expects.
18	// [int->REQ-TERM-4]
19
20	use std::sync::atomic::{AtomicU32, Ordering};
21	use std::sync::Arc;
22	use std::thread;
23	use std::time::{Duration, Instant};
24
25	use spt_daemon::msg::SpawnReq;
26	use spt_daemon::{
27	    follow, pull_snapshot, serve_digest_control, Brain, Broker, DigestEngine, DigestHub,
28	};
29	use spt_runtime::PtyDigest;
30	use spt_store::perch::{resolve_perch_path, ParentHint};
31
32	static SEQ: AtomicU32 = AtomicU32::new(0);
33	fn unique(prefix: &str) -> String {
34	    let n = SEQ.fetch_add(1, Ordering::Relaxed);
35	    format!("spt-daemon-b8-{prefix}-{}-{}.sock", std::process::id(), n)
36	}
37
38	/// The cross-OS echo child: reads stdin, writes each line to stdout.
39	fn echo_req() -> SpawnReq {
40	    #[cfg(unix)]
41	    let (program, args) = ("cat".to_string(), Vec::<String>::new());
42	    #[cfg(windows)]
43	    let (program, args) = ("findstr".to_string(), vec![".".to_string()]);
44	    SpawnReq { program, args, rows: 24, cols: 80 }
45	}
46
47	/// The adapter-supplied digest seam, with persistence opted in.
48	fn seam() -> PtyDigest {
49	    PtyDigest {
50	        input_pattern: r"\nUSER> ".to_string(),
51	        agent_pattern: r"\nAGENT> ".to_string(),
52	        tool_patterns: vec![r"(?P<name>Write)\((?P<arg>[^)]*)\)".to_string()],
53	        catchall_pattern: None,
54	        window_turns: Some(5),
55	        persist: true,
56	    }
57	}
58
59	fn contains(hay: &[u8], needle: &[u8]) -> bool {
60	    hay.windows(needle.len()).any(|w| w == needle)
61	}
62
63	/// Block up to `dur` for `cond` to hold, polling gently.
64	fn wait_until(dur: Duration, mut cond: impl FnMut() -> bool) -> bool {
65	    let start = Instant::now();
66	    while start.elapsed() < dur {
67	        if cond() {
68	            return true;
69	        }
70	        thread::sleep(Duration::from_millis(15));
71	    }
72	    cond()
73	}
74
75	#[test]
76	fn digest_daemon_half_end_to_end() {
77	    // Isolate on-disk state (the perch + Path-B history.log) to a temp home.
78	    let home = tempfile::tempdir().expect("tempdir");
79	    std::env::set_var("SPT_HOME", home.path());
80
81	    let broker_name = unique("broker");
82	    let digest_name = unique("digest");
83
84	    // The digest hub + its control channel (the `spt digest` access surface).
85	    let hub = Arc::new(DigestHub::new());
86	    {
87	        let hub = Arc::clone(&hub);
88	        let name = digest_name.clone();
89	        thread::spawn(move || {
90	            let _ = serve_digest_control(&name, hub);
91	        });
92	    }
93
94	    // The broker hosts PTYs; serve it on a background thread.
95	    let broker = Broker::bind(&broker_name).expect("bind broker");
96	    {
97	        let broker = Arc::clone(&broker);
98	        thread::spawn(move || {
99	            let _ = broker.serve();
100	        });
101	    }
102
103	    // One brain spawns the echo child, injects the marker stream, and folds the
104	    // echoed output through the engine (publishing each change to the hub).
105	    let mut brain = connect_brain(&broker_name);
106	    let _sid = brain.spawn_session(echo_req()).expect("spawn echo child");
107	    let mut engine = DigestEngine::from_manifest(&seam()).expect("compile patterns");
108
109	    // Inject TWO complete turns + a tool sprint, then a sentinel: the first turn
110	    // settles (→ persistence), the second is the active turn. Lines END IN `\r`
111	    // (the Enter key): a real PTY submits + echoes a line on `\r`, and the engine
112	    // normalizes the echoed `\r\n` back to `\n` so the `\nUSER> ` boundary matches.
113	    // Injecting bare `\n` would echo as a lone `\r` the parser strips — collapsing
114	    // the boundaries (the cross-OS PTY stream-contract gotcha, Spike #4/#5).
115	    let blob = b"\rUSER> first task\rAGENT> working on it\rWrite(src/a.rs) done\rUSER> second task\rAGENT> all set\r";
116	    brain.send_input(blob).expect("inject markers");
117
118	    // Read the echoed output, feeding the engine + publishing, until both turns
119	    // have round-tripped through the real PTY.
120	    let mut acc = Vec::<u8>::new();
121	    let mut guard = 0;
122	    loop {
123	        guard += 1;
124	        assert!(guard < 100_000, "echoed markers never arrived: {:?}", String::from_utf8_lossy(&acc));
125	        if contains(&acc, b"second task") && contains(&acc, b"first task") {
126	            break;
127	        }
128	        match brain.read_event().expect("read event") {
129	            spt_daemon::BrokerEvent::Output { bytes, .. } => {
130	                acc.extend_from_slice(&bytes);
131	                spt_daemon::ingest_chunk(&mut engine, &hub, "doyle", &bytes);
132	            }
133	            spt_daemon::BrokerEvent::Exit { .. } => panic!("child exited before echo"),
134	            _ => {}
135	        }
136	    }
137
138	    // ── Snapshot pull over the control channel ──────────────────────────────
139	    let (digest, version) = pull_snapshot(&digest_name, "doyle")
140	        .expect("pull ok")
141	        .expect("a live digest exists for the daemon-hosted session");
142	    assert!(version >= 1, "the digest advanced at least one version");
143	    assert!(
144	        digest.turns.iter().any(|t| t.input.as_deref() == Some("first task")),
145	        "the snapshot carries the parsed user turn: {digest:?}"
146	    );
147	    // The render is the glanceable view; a collapsed tool sprint shows as a
148	    // `used: …` line (avoids depending on the internal entry enum here).
149	    let rendered = spt_daemon::render_digest(&digest);
150	    assert!(
151	        rendered.contains("used: Write(src/a.rs)"),
152	        "the snapshot carries a collapsed tool sprint: {rendered}"
153	    );
154
155	    // ── Delta-stream: a subscriber receives only the new change ─────────────
156	    let base = version;
157	    let (tx, rx) = std::sync::mpsc::channel::<u64>();
158	    {
159	        let dname = digest_name.clone();
160	        thread::spawn(move || {
161	            // Skip the base (== base) and stop on the first true delta (> base).
162	            let _ = follow(&dname, "doyle", base, |up| {
163	                if up.version > base {
164	                    let _ = tx.send(up.version);
165	                    false
166	                } else {
167	                    true
168	                }
169	            });
170	        });
171	    }
172	    // Give the subscriber a moment to register, then drive a third turn.
173	    thread::sleep(Duration::from_millis(80));
174	    brain.send_input(b"\rUSER> third task\rAGENT> reflecting\r").expect("inject third");
175	    // Keep feeding until the third turn round-trips (publishing new versions).
176	    guard = 0;
177	    while !contains(&acc, b"third task") {
178	        guard += 1;
179	        assert!(guard < 100_000, "third turn never echoed");
180	        if let spt_daemon::BrokerEvent::Output { bytes, .. } =
181	            brain.read_event().expect("read event")
182	        {
183	            acc.extend_from_slice(&bytes);
184	            spt_daemon::ingest_chunk(&mut engine, &hub, "doyle", &bytes);
185	        }
186	    }
187	    let delta_version = rx
188	        .recv_timeout(Duration::from_secs(5))
189	        .expect("the subscriber received a delta beyond the base");
190	    assert!(delta_version > base, "the delta is a newer version than the base");
191
192	    // ── Opt-in Path-B persistence: the settled turn was logged ──────────────
193	    let history = resolve_perch_path("doyle", ParentHint::Infer).join("history.log");
194	    assert!(
195	        wait_until(Duration::from_secs(2), || history.exists()),
196	        "persistence created the Path-B history log"
197	    );
198	    let log = std::fs::read_to_string(&history).expect("read history.log");
199	    assert!(log.contains("digest_turn"), "a digest activity record was appended: {log}");
200	    assert!(log.contains("first task"), "the settled turn was persisted: {log}");
201
202	    brain.kill_session().expect("kill child");
203	}
204
205	/// Connect a brain to the broker, retrying briefly while `accept` lags the bind.
206	fn connect_brain(name: &str) -> Brain {
207	    for _ in 0..200 {
208	        if let Ok(b) = Brain::cold_start(name, 1) {
209	            return b;
210	        }
211	        thread::sleep(Duration::from_millis(10));
212	    }
213	    panic!("could not connect a brain to the broker");
214	}
215

//! B8 integration: the **PTY-digest daemon-half**, end to end through the real
//! IPC (ADR-0008 / REQ-TERM-4).
//!
//! A [`Broker`] hosts a real PTY child; a [`Brain`] folds that child's live output
//! through a [`DigestEngine`] (the brain-side parse — never the broker, ADR-0004
//! §B) and publishes each change into the [`DigestHub`]; a separate client reads
//! it back over the real digest-control channel both ways ADR-0008 specifies — a
//! `spt digest` **snapshot pull** ([`pull_snapshot`]) and a **delta-stream**
//! ([`follow`]) — and the opt-in Path-B persistence appends settled turns to the
//! perch history store. This is the whole B8 contract over the actual named-pipe
//! / UDS transport, on both OSes.
//!
//! The child is the same cross-OS echo program the B1 broker test uses (`cat` /
//! `findstr "."`): bytes injected into its PTY round-trip back as output carrying
//! the digest boundary markers, so the engine has a real stream to parse. PTY
//! echo may duplicate a line; the assertions are therefore content-tolerant
//! (`any`), exactly as the digest parser's own repaint-tolerance contract expects.
// [int->REQ-TERM-4]

use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};

use spt_daemon::msg::SpawnReq;
use spt_daemon::{
    follow, pull_snapshot, serve_digest_control, Brain, Broker, DigestEngine, DigestHub,
};
use spt_runtime::PtyDigest;
use spt_store::perch::{resolve_perch_path, ParentHint};

static SEQ: AtomicU32 = AtomicU32::new(0);
fn unique(prefix: &str) -> String {
    let n = SEQ.fetch_add(1, Ordering::Relaxed);
    format!("spt-daemon-b8-{prefix}-{}-{}.sock", std::process::id(), n)
}

/// The cross-OS echo child: reads stdin, writes each line to stdout.
fn echo_req() -> SpawnReq {
    #[cfg(unix)]
    let (program, args) = ("cat".to_string(), Vec::<String>::new());
    #[cfg(windows)]
    let (program, args) = ("findstr".to_string(), vec![".".to_string()]);
    SpawnReq { program, args, rows: 24, cols: 80 }
}

/// The adapter-supplied digest seam, with persistence opted in.
fn seam() -> PtyDigest {
    PtyDigest {
        input_pattern: r"\nUSER> ".to_string(),
        agent_pattern: r"\nAGENT> ".to_string(),
        tool_patterns: vec![r"(?P<name>Write)\((?P<arg>[^)]*)\)".to_string()],
        catchall_pattern: None,
        window_turns: Some(5),
        persist: true,
    }
}

fn contains(hay: &[u8], needle: &[u8]) -> bool {
    hay.windows(needle.len()).any(|w| w == needle)
}

/// Block up to `dur` for `cond` to hold, polling gently.
fn wait_until(dur: Duration, mut cond: impl FnMut() -> bool) -> bool {
    let start = Instant::now();
    while start.elapsed() < dur {
        if cond() {
            return true;
        }
        thread::sleep(Duration::from_millis(15));
    }
    cond()
}

#[test]
fn digest_daemon_half_end_to_end() {
    // Isolate on-disk state (the perch + Path-B history.log) to a temp home.
    let home = tempfile::tempdir().expect("tempdir");
    std::env::set_var("SPT_HOME", home.path());

    let broker_name = unique("broker");
    let digest_name = unique("digest");

    // The digest hub + its control channel (the `spt digest` access surface).
    let hub = Arc::new(DigestHub::new());
    {
        let hub = Arc::clone(&hub);
        let name = digest_name.clone();
        thread::spawn(move || {
            let _ = serve_digest_control(&name, hub);
        });
    }

    // The broker hosts PTYs; serve it on a background thread.
    let broker = Broker::bind(&broker_name).expect("bind broker");
    {
        let broker = Arc::clone(&broker);
        thread::spawn(move || {
            let _ = broker.serve();
        });
    }

    // One brain spawns the echo child, injects the marker stream, and folds the
    // echoed output through the engine (publishing each change to the hub).
    let mut brain = connect_brain(&broker_name);
    let _sid = brain.spawn_session(echo_req()).expect("spawn echo child");
    let mut engine = DigestEngine::from_manifest(&seam()).expect("compile patterns");

    // Inject TWO complete turns + a tool sprint, then a sentinel: the first turn
    // settles (→ persistence), the second is the active turn. Lines END IN `\r`
    // (the Enter key): a real PTY submits + echoes a line on `\r`, and the engine
    // normalizes the echoed `\r\n` back to `\n` so the `\nUSER> ` boundary matches.
    // Injecting bare `\n` would echo as a lone `\r` the parser strips — collapsing
    // the boundaries (the cross-OS PTY stream-contract gotcha, Spike #4/#5).
    let blob = b"\rUSER> first task\rAGENT> working on it\rWrite(src/a.rs) done\rUSER> second task\rAGENT> all set\r";
    brain.send_input(blob).expect("inject markers");

    // Read the echoed output, feeding the engine + publishing, until both turns
    // have round-tripped through the real PTY.
    let mut acc = Vec::<u8>::new();
    let mut guard = 0;
    loop {
        guard += 1;
        assert!(guard < 100_000, "echoed markers never arrived: {:?}", String::from_utf8_lossy(&acc));
        if contains(&acc, b"second task") && contains(&acc, b"first task") {
            break;
        }
        match brain.read_event().expect("read event") {
            spt_daemon::BrokerEvent::Output { bytes, .. } => {
                acc.extend_from_slice(&bytes);
                spt_daemon::ingest_chunk(&mut engine, &hub, "doyle", &bytes);
            }
            spt_daemon::BrokerEvent::Exit { .. } => panic!("child exited before echo"),
            _ => {}
        }
    }

    // ── Snapshot pull over the control channel ──────────────────────────────
    let (digest, version) = pull_snapshot(&digest_name, "doyle")
        .expect("pull ok")
        .expect("a live digest exists for the daemon-hosted session");
    assert!(version >= 1, "the digest advanced at least one version");
    assert!(
        digest.turns.iter().any(|t| t.input.as_deref() == Some("first task")),
        "the snapshot carries the parsed user turn: {digest:?}"
    );
    // The render is the glanceable view; a collapsed tool sprint shows as a
    // `used: …` line (avoids depending on the internal entry enum here).
    let rendered = spt_daemon::render_digest(&digest);
    assert!(
        rendered.contains("used: Write(src/a.rs)"),
        "the snapshot carries a collapsed tool sprint: {rendered}"
    );

    // ── Delta-stream: a subscriber receives only the new change ─────────────
    let base = version;
    let (tx, rx) = std::sync::mpsc::channel::<u64>();
    {
        let dname = digest_name.clone();
        thread::spawn(move || {
            // Skip the base (== base) and stop on the first true delta (> base).
            let _ = follow(&dname, "doyle", base, |up| {
                if up.version > base {
                    let _ = tx.send(up.version);
                    false
                } else {
                    true
                }
            });
        });
    }
    // Give the subscriber a moment to register, then drive a third turn.
    thread::sleep(Duration::from_millis(80));
    brain.send_input(b"\rUSER> third task\rAGENT> reflecting\r").expect("inject third");
    // Keep feeding until the third turn round-trips (publishing new versions).
    guard = 0;
    while !contains(&acc, b"third task") {
        guard += 1;
        assert!(guard < 100_000, "third turn never echoed");
        if let spt_daemon::BrokerEvent::Output { bytes, .. } =
            brain.read_event().expect("read event")
        {
            acc.extend_from_slice(&bytes);
            spt_daemon::ingest_chunk(&mut engine, &hub, "doyle", &bytes);
        }
    }
    let delta_version = rx
        .recv_timeout(Duration::from_secs(5))
        .expect("the subscriber received a delta beyond the base");
    assert!(delta_version > base, "the delta is a newer version than the base");

    // ── Opt-in Path-B persistence: the settled turn was logged ──────────────
    let history = resolve_perch_path("doyle", ParentHint::Infer).join("history.log");
    assert!(
        wait_until(Duration::from_secs(2), || history.exists()),
        "persistence created the Path-B history log"
    );
    let log = std::fs::read_to_string(&history).expect("read history.log");
    assert!(log.contains("digest_turn"), "a digest activity record was appended: {log}");
    assert!(log.contains("first task"), "the settled turn was persisted: {log}");

    brain.kill_session().expect("kill child");
}

/// Connect a brain to the broker, retrying briefly while `accept` lags the bind.
fn connect_brain(name: &str) -> Brain {
    for _ in 0..200 {
        if let Ok(b) = Brain::cold_start(name, 1) {
            return b;
        }
        thread::sleep(Duration::from_millis(10));
    }
    panic!("could not connect a brain to the broker");
}


All pieces understood. Integration tests set `SPT_HOME` via tempdir directly. Now write the B9 daemon E2E weaving PTY-restart (Brain/Broker) + lifecycle (BrainLifecycle: Psyche/commune/signoff). Track work first.


ToolSearch

Question: select:TaskCreate,TaskUpdate


Question: select:TaskCreate,TaskUpdate


NOTE FROM THEUSER: proceed with next steps.


TaskCreate

Write B9 daemon E2E test

tests/daemon_e2e.rs: spawn→Psyche-loop→commune→brain-restart-survives→graceful-signoff. int tags for DAEMON-1/2/4 + DAEMON-HOSTED-LIVENESS.


Task #1 created successfully: Write B9 daemon E2E test

Write B9 daemon E2E test


Activate M3b reqs in traceable-reqs.toml

Flip required_stages to add int for DAEMON-1, DAEMON-2, DAEMON-4, DAEMON-HOSTED-LIVENESS, RESTART-IDEMPOTENT. Run traceable-reqs check green.


Task #2 created successfully: Activate M3b reqs in traceable-reqs.toml


Verify workspace: cargo test + clippy + check

cargo test --workspace, clippy -D warnings, traceable-reqs check green. Linux via gravity-linux if reachable.


Task #3 created successfully: Verify workspace: cargo test + clippy + check


Amend ROADMAP + CONTEXT, author M3c stub

Mark M3b delivered in ROADMAP.md; amend CONTEXT.md; author M3c-PLAN.md stub (self-update).


Task #4 created successfully: Amend ROADMAP + CONTEXT, author M3c stub


Commit B9 with traceability trailer

Single B9 commit: E2E test + req activation + docs. Co-Authored-By trailer. Ensure CI matrix expectation noted.


Task #5 created successfully: Commit B9 with traceability trailer

Commit B9 with traceability trailer


Updated task #1 status


Write

//! B9 daemon end-to-end — the M3b milestone proof, driving the whole consolidated
//! architecture through the real IPC in one flow: a daemon hosts a LiveAgent's
//! lifecycle as in-process loops **and** survives a brain restart with its hosted
//! PTY child + output stream intact and gapless.
//!
//! Flow (the M3b done-definition, ADR-0004 / `M3b-PLAN.md` §B9):
//!
//! 1. **spawn** — a [`Broker`] hosts a real PTY child; a [`Brain`] cold-starts,
//!    spawns the session, and round-trips a marker through it (the session is live).
//! 2. **Psyche-loop** — a [`BrainLifecycle`] (the consolidated re-host of the
//!    interim `api listen` live half, [`REQ-DAEMON-1`]) spawns the Psyche companion;
//!    the daemon marks the hosted perch `online`, so its liveness is the daemon
//!    `status`, never the summarizer pid ([`REQ-HAZARD-DAEMON-HOSTED-LIVENESS`]).
//! 3. **commune** — a commune drop lands in the watched dir and a scheduled pulse
//!    tick folds it in (the daemon-hosted lifecycle loop, [`REQ-DAEMON-1`]).
//! 4. **brain-restart-survives** — the brain is dropped (a crash) and handed off
//!    repeatedly; across each handoff the child's pid is unchanged, exactly one
//!    session is hosted, the output stream replays gaplessly, and `gen_start`
//!    advances ([`REQ-DAEMON-2`] / [`REQ-HAZARD-GEN-START-NOW`]). The lifecycle is
//!    stateless on disk, so it keeps ingesting communes straight across the restart.
//! 5. **graceful-signoff** — the lifecycle's graceful teardown runs the load-bearing
//!    ordering — echo-commune BEFORE teardown (3.3) — and removes the Self's ready
//!    marker ([`REQ-DAEMON-4`]).
//!
//! The child is the same cross-OS echo program the other daemon tests use (`cat` /
//! `findstr "."`); lines END IN `\r` (the Enter key) so the real PTY submits +
//! echoes them, exactly as B8 documents (bare `\n` echoes a lone `\r` and collapses
//! boundaries — the Spike #4/#5 PTY stream-contract gotcha).
//!
//! Single `#[test]` on purpose: the flow owns the process-global `SPT_HOME`, so it
//! must not race a sibling test in this binary.
// [int->REQ-DAEMON-1]
// [int->REQ-DAEMON-2]
// [int->REQ-DAEMON-4]
// [int->REQ-HAZARD-DAEMON-HOSTED-LIVENESS]

use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::Duration;

use spt_daemon::brain::BrainState;
use spt_daemon::msg::SpawnReq;
use spt_daemon::{Brain, BrainLifecycle, Broker, DaemonConfig};
use spt_runtime::Manifest;
use spt_store::perch::{self, ParentHint};

static SEQ: AtomicU32 = AtomicU32::new(0);
fn unique(prefix: &str) -> String {
    let n = SEQ.fetch_add(1, Ordering::Relaxed);
    format!("spt-daemon-b9-{prefix}-{}-{}.sock", std::process::id(), n)
}

/// The cross-OS echo child: reads stdin, writes each line back to stdout.
fn echo_req() -> SpawnReq {
    #[cfg(unix)]
    let (program, args) = ("cat".to_string(), Vec::<String>::new());
    #[cfg(windows)]
    let (program, args) = ("findstr".to_string(), vec![".".to_string()]);
    SpawnReq { program, args, rows: 24, cols: 80 }
}

// A no-op Psyche summarizer command (we never depend on its output here; spawning
// it exercises the hosted-Psyche bind + the daemon online-stamp).
#[cfg(windows)]
const NOOP_CMD: &str = "cmd /C rem";
#[cfg(unix)]
const NOOP_CMD: &str = "true";

// A stdin→stdout passthrough so the echo-commune summarizer writes a real drop
// file — lets the signoff assert the echo (3.3) actually ran before teardown.
#[cfg(windows)]
const PASSTHROUGH: &str = "sort";
#[cfg(unix)]
const PASSTHROUGH: &str = "cat";

/// A live-capable manifest declaring a Psyche, a watched commune/signoff dir, and
/// an echo-commune summarizer (so `fire_echo` writes a real commune on signoff).
fn live_manifest(drops: &std::path::Path) -> Manifest {
    let dir = drops.to_string_lossy().replace('\\', "/");
    let toml = format!(
        "[adapter]\nname=\"mock\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
         [session]\ncommune_dir=\"{dir}\"\nsignoff_dir=\"{dir}\"\n\n\
         [session.psyche_init]\ncommand='{NOOP_CMD}'\ncwd=\"{{psyche_dir}}\"\nkeys=[]\n\n\
         [session.echo_commune]\ncommand='{PASSTHROUGH}'\n"
    );
    Manifest::from_toml_str(&toml).unwrap()
}

fn cold_retry(name: &str, now_ms: u64) -> Brain {
    for _ in 0..300 {
        if let Ok(b) = Brain::cold_start(name, now_ms) {
            return b;
        }
        thread::sleep(Duration::from_millis(10));
    }
    panic!("brain cold-start could not connect");
}

fn handoff_retry(name: &str, prior: BrainState, now_ms: u64) -> Brain {
    for _ in 0..300 {
        if let Ok(b) = Brain::handoff(name, prior.clone(), now_ms) {
            return b;
        }
        thread::sleep(Duration::from_millis(10));
    }
    panic!("brain handoff could not connect");
}

#[test]
fn daemon_hosts_lifecycle_and_survives_brain_restart() {
    // Isolate all on-disk perch state (the Self perch, the nested Psyche perch,
    // the commune drops) to a temp home.
    let home = tempfile::tempdir().expect("tempdir");
    std::env::set_var("SPT_HOME", home.path());

    let id = "doyle";
    let drops = tempfile::tempdir().expect("drops dir");
    let manifest = live_manifest(drops.path());
    // A short pulse keeps the test quick; proves the period is config-driven.
    let cfg = DaemonConfig {
        pulse_period: Duration::from_millis(15),
        crash_grace: Duration::from_millis(10),
        ..DaemonConfig::default()
    };
    let host = BrainLifecycle::with_config(&manifest, id, cfg).expect("live host");

    // ── 1. spawn: the broker hosts a real PTY child; a brain drives it ──────────
    let broker_name = unique("broker");
    let broker = Broker::bind(&broker_name).expect("bind broker");
    {
        let broker = Arc::clone(&broker);
        thread::spawn(move || {
            let _ = broker.serve();
        });
    }
    let mut now = 1_000_000u64;
    let mut brain = cold_retry(&broker_name, now);
    let sid = brain.spawn_session(echo_req()).expect("spawn echo child");
    let child_pid = broker.session_pid(sid);
    assert_eq!(brain.generation(), 0, "cold start is generation 0");

    // The session is live: a marker round-trips through the real PTY.
    let mut buf = Vec::<u8>::new();
    brain.send_input(b"HELLO_DAEMON\r").expect("inject");
    brain.read_output_until(b"HELLO_DAEMON", &mut buf).expect("marker round-trips");

    // ── 2. Psyche-loop: the daemon hosts the Psyche; liveness is the status ─────
    use spt_store::info::{read_info, write_info, PidValue};
    use spt_store::liveness::{is_perch_alive, STATUS_ONLINE};

    let psyche = host.spawn_psyche(Some("sid-1"), Some("pulse")).expect("spawn psyche");
    assert_eq!(
        read_info(&psyche.perch_path).expect("psyche info").status.as_deref(),
        Some(STATUS_ONLINE),
        "the daemon marked the hosted Psyche online"
    );
    // The summarizer subprocess exiting (its recorded pid now dead) must NOT flip
    // the daemon-hosted perch offline — liveness is the daemon status, not the pid.
    const DEAD_PID: u32 = 2_000_000_000;
    let mut rec = read_info(&psyche.perch_path).unwrap();
    rec.pid = PidValue::Numeric(DEAD_PID);
    write_info(&psyche.perch_path, &rec).unwrap();
    assert!(
        is_perch_alive(&psyche.perch_path),
        "a dead summarizer pid must NOT flip a daemon-hosted perch offline"
    );

    // ── 3. commune: a drop lands and a pulse tick folds it in ───────────────────
    std::fs::write(drops.path().join("doyle-commune.md"), "first brief").unwrap();
    let report = host.pulse_tick(Some("sid-1")).expect("pulse tick");
    assert_eq!(report.ingested.len(), 1, "the commune drop was ingested by the loop");
    assert!(!drops.path().join("doyle-commune.md").exists(), "drop consumed");

    // ── 4. brain-restart-survives: kill+restart the brain repeatedly ────────────
    const RESTARTS: u64 = 10;
    // Inject the first successor marker, then crash (drop the connection).
    brain.send_input(b"MARKER_1_DONE\r").expect("inject first marker");
    let mut state = brain.snapshot();
    drop(brain);

    for k in 1..=RESTARTS {
        now += 1; // advance wall clock so a fresh gen_start is strictly greater
        let prior_gen_start = state.gen_start_ms;
        let prior_generation = state.generation;
        let mut brain = handoff_retry(&broker_name, state.clone(), now);

        // 2.4 — gen_start is now(), never the rehydrated stale value.
        assert_eq!(brain.gen_start_ms(), now, "gen_start must be now()");
        assert_ne!(brain.gen_start_ms(), prior_gen_start, "gen_start not carried forward");
        assert_eq!(brain.generation(), prior_generation + 1, "generation increments");

        // The child + its PTY survived the restart untouched.
        assert_eq!(broker.session_count(), 1, "exactly one session across restarts");
        assert_eq!(broker.session_pid(sid), child_pid, "child pid stable across restart");

        // Gapless resume: the marker the previous brain injected (and died before
        // reading) arrives via the replayed buffer.
        let marker = format!("MARKER_{k}_DONE");
        brain
            .read_output_until(marker.as_bytes(), &mut buf)
            .unwrap_or_else(|e| panic!("gapless resume must replay {marker}: {e}"));

        // The daemon-hosted lifecycle is stateless on disk, so it keeps ingesting
        // straight across the brain restart: drop a commune, tick, confirm.
        std::fs::write(drops.path().join("doyle-commune.md"), format!("brief {k}")).unwrap();
        let rep = host.pulse_tick(Some("sid-1")).expect("pulse tick across restart");
        assert_eq!(rep.ingested.len(), 1, "lifecycle keeps ingesting across the brain restart");

        // Hand the next marker to our successor, then crash without reading it.
        if k < RESTARTS {
            brain
                .send_input(format!("MARKER_{}_DONE\r", k + 1).as_bytes())
                .expect("inject next marker");
        }
        state = brain.snapshot();
        drop(brain);
    }

    // ── 5. graceful-signoff: echo BEFORE teardown (3.3), ready marker removed ───
    // Seed the Self's ready marker (graceful teardown removes it).
    let ready = perch::resolve_ready_file(id, ParentHint::Infer);
    std::fs::create_dir_all(ready.parent().unwrap()).unwrap();
    std::fs::write(&ready, "").unwrap();

    let outcome = host.graceful_signoff(Some("sid-1")).expect("graceful signoff");
    assert!(outcome.echo_ran, "echo-before-signoff (3.3) must have run");
    assert!(outcome.signed_off, "graceful signoff tears the Self down");
    assert!(
        drops.path().join("doyle-commune.md").exists(),
        "the echo wrote a final-delta commune before teardown"
    );
    assert!(!ready.exists(), "teardown removed the Self's ready marker");

    // The hosted Psyche is marked offline on teardown (the online-stamp counterpart).
    BrainLifecycle::mark_offline(&psyche.perch_path);
    assert!(!is_perch_alive(&psyche.perch_path), "mark_offline flips the perch offline");

    // ── Reap: kill the child and confirm the broker drops the session ───────────
    now += 1;
    let mut brain = handoff_retry(&broker_name, state, now);
    brain.kill_session().expect("kill child");
    drop(brain);
    let mut reaped = false;
    for _ in 0..200 {
        if broker.session_count() == 0 {
            reaped = true;
            break;
        }
        thread::sleep(Duration::from_millis(10));
    }
    assert!(reaped, "child exit must reap the session from the broker table");
}


File created successfully at: C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\daemon_e2e.rs (file state is current in your context — no need to Read it back)

//! B9 daemon end-to-end — the M3b milestone proof, driving the whole consolidated
//! architecture through the real IPC in one flow: a daemon hosts a LiveAgent's
//! lifecycle as in-process loops **and** survives a brain restart with its hosted
//! PTY child + output stream intact and gapless.
//!
//! Flow (the M3b done-definition, ADR-0004 / `M3b-PLAN.md` §B9):
//!
//! 1. **spawn** — a [`Broker`] hosts a real PTY child; a [`Brain`] cold-starts,
//!    spawns the session, and round-trips a marker through it (the session is live).
//! 2. **Psyche-loop** — a [`BrainLifecycle`] (the consolidated re-host of the
//!    interim `api listen` live half, [`REQ-DAEMON-1`]) spawns the Psyche companion;
//!    the daemon marks the hosted perch `online`, so its liveness is the daemon
//!    `status`, never the summarizer pid ([`REQ-HAZARD-DAEMON-HOSTED-LIVENESS`]).
//! 3. **commune** — a commune drop lands in the watched dir and a scheduled pulse
//!    tick folds it in (the daemon-hosted lifecycle loop, [`REQ-DAEMON-1`]).
//! 4. **brain-restart-survives** — the brain is dropped (a crash) and handed off
//!    repeatedly; across each handoff the child's pid is unchanged, exactly one
//!    session is hosted, the output stream replays gaplessly, and `gen_start`
//!    advances ([`REQ-DAEMON-2`] / [`REQ-HAZARD-GEN-START-NOW`]). The lifecycle is
//!    stateless on disk, so it keeps ingesting communes straight across the restart.
//! 5. **graceful-signoff** — the lifecycle's graceful teardown runs the load-bearing
//!    ordering — echo-commune BEFORE teardown (3.3) — and removes the Self's ready
//!    marker ([`REQ-DAEMON-4`]).
//!
//! The child is the same cross-OS echo program the other daemon tests use (`cat` /
//! `findstr "."`); lines END IN `\r` (the Enter key) so the real PTY submits +
//! echoes them, exactly as B8 documents (bare `\n` echoes a lone `\r` and collapses
//! boundaries — the Spike #4/#5 PTY stream-contract gotcha).
//!
//! Single `#[test]` on purpose: the flow owns the process-global `SPT_HOME`, so it
//! must not race a sibling test in this binary.
// [int->REQ-DAEMON-1]
// [int->REQ-DAEMON-2]
// [int->REQ-DAEMON-4]
// [int->REQ-HAZARD-DAEMON-HOSTED-LIVENESS]

use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::Duration;

use spt_daemon::brain::BrainState;
use spt_daemon::msg::SpawnReq;
use spt_daemon::{Brain, BrainLifecycle, Broker, DaemonConfig};
use spt_runtime::Manifest;
use spt_store::perch::{self, ParentHint};

static SEQ: AtomicU32 = AtomicU32::new(0);
fn unique(prefix: &str) -> String {
    let n = SEQ.fetch_add(1, Ordering::Relaxed);
    format!("spt-daemon-b9-{prefix}-{}-{}.sock", std::process::id(), n)
}

/// The cross-OS echo child: reads stdin, writes each line back to stdout.
fn echo_req() -> SpawnReq {
    #[cfg(unix)]
    let (program, args) = ("cat".to_string(), Vec::<String>::new());
    #[cfg(windows)]
    let (program, args) = ("findstr".to_string(), vec![".".to_string()]);
    SpawnReq { program, args, rows: 24, cols: 80 }
}

// A no-op Psyche summarizer command (we never depend on its output here; spawning
// it exercises the hosted-Psyche bind + the daemon online-stamp).
#[cfg(windows)]
const NOOP_CMD: &str = "cmd /C rem";
#[cfg(unix)]
const NOOP_CMD: &str = "true";

// A stdin→stdout passthrough so the echo-commune summarizer writes a real drop
// file — lets the signoff assert the echo (3.3) actually ran before teardown.
#[cfg(windows)]
const PASSTHROUGH: &str = "sort";
#[cfg(unix)]
const PASSTHROUGH: &str = "cat";

/// A live-capable manifest declaring a Psyche, a watched commune/signoff dir, and
/// an echo-commune summarizer (so `fire_echo` writes a real commune on signoff).
fn live_manifest(drops: &std::path::Path) -> Manifest {
    let dir = drops.to_string_lossy().replace('\\', "/");
    let toml = format!(
        "[adapter]\nname=\"mock\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
         [session]\ncommune_dir=\"{dir}\"\nsignoff_dir=\"{dir}\"\n\n\
         [session.psyche_init]\ncommand='{NOOP_CMD}'\ncwd=\"{{psyche_dir}}\"\nkeys=[]\n\n\
         [session.echo_commune]\ncommand='{PASSTHROUGH}'\n"
    );
    Manifest::from_toml_str(&toml).unwrap()
}

fn cold_retry(name: &str, now_ms: u64) -> Brain {
    for _ in 0..300 {
        if let Ok(b) = Brain::cold_start(name, now_ms) {
            return b;
        }
        thread::sleep(Duration::from_millis(10));
    }
    panic!("brain cold-start could not connect");
}

fn handoff_retry(name: &str, prior: BrainState, now_ms: u64) -> Brain {
    for _ in 0..300 {
        if let Ok(b) = Brain::handoff(name, prior.clone(), now_ms) {
            return b;
        }
        thread::sleep(Duration::from_millis(10));
    }
    panic!("brain handoff could not connect");
}

#[test]
fn daemon_hosts_lifecycle_and_survives_brain_restart() {
    // Isolate all on-disk perch state (the Self perch, the nested Psyche perch,
    // the commune drops) to a temp home.
    let home = tempfile::tempdir().expect("tempdir");
    std::env::set_var("SPT_HOME", home.path());

    let id = "doyle";
    let drops = tempfile::tempdir().expect("drops dir");
    let manifest = live_manifest(drops.path());
    // A short pulse keeps the test quick; proves the period is config-driven.
    let cfg = DaemonConfig {
        pulse_period: Duration::from_millis(15),
        crash_grace: Duration::from_millis(10),
        ..DaemonConfig::default()
    };
    let host = BrainLifecycle::with_config(&manifest, id, cfg).expect("live host");

    // ── 1. spawn: the broker hosts a real PTY child; a brain drives it ──────────
    let broker_name = unique("broker");
    let broker = Broker::bind(&broker_name).expect("bind broker");
    {
        let broker = Arc::clone(&broker);
        thread::spawn(move || {
            let _ = broker.serve();
        });
    }
    let mut now = 1_000_000u64;
    let mut brain = cold_retry(&broker_name, now);
    let sid = brain.spawn_session(echo_req()).expect("spawn echo child");
    let child_pid = broker.session_pid(sid);
    assert_eq!(brain.generation(), 0, "cold start is generation 0");

    // The session is live: a marker round-trips through the real PTY.
    let mut buf = Vec::<u8>::new();
    brain.send_input(b"HELLO_DAEMON\r").expect("inject");
    brain.read_output_until(b"HELLO_DAEMON", &mut buf).expect("marker round-trips");

    // ── 2. Psyche-loop: the daemon hosts the Psyche; liveness is the status ─────
    use spt_store::info::{read_info, write_info, PidValue};
    use spt_store::liveness::{is_perch_alive, STATUS_ONLINE};

    let psyche = host.spawn_psyche(Some("sid-1"), Some("pulse")).expect("spawn psyche");
    assert_eq!(
        read_info(&psyche.perch_path).expect("psyche info").status.as_deref(),
        Some(STATUS_ONLINE),
        "the daemon marked the hosted Psyche online"
    );
    // The summarizer subprocess exiting (its recorded pid now dead) must NOT flip
    // the daemon-hosted perch offline — liveness is the daemon status, not the pid.
    const DEAD_PID: u32 = 2_000_000_000;
    let mut rec = read_info(&psyche.perch_path).unwrap();
    rec.pid = PidValue::Numeric(DEAD_PID);
    write_info(&psyche.perch_path, &rec).unwrap();
    assert!(
        is_perch_alive(&psyche.perch_path),
        "a dead summarizer pid must NOT flip a daemon-hosted perch offline"
    );

    // ── 3. commune: a drop lands and a pulse tick folds it in ───────────────────
    std::fs::write(drops.path().join("doyle-commune.md"), "first brief").unwrap();
    let report = host.pulse_tick(Some("sid-1")).expect("pulse tick");
    assert_eq!(report.ingested.len(), 1, "the commune drop was ingested by the loop");
    assert!(!drops.path().join("doyle-commune.md").exists(), "drop consumed");

    // ── 4. brain-restart-survives: kill+restart the brain repeatedly ────────────
    const RESTARTS: u64 = 10;
    // Inject the first successor marker, then crash (drop the connection).
    brain.send_input(b"MARKER_1_DONE\r").expect("inject first marker");
    let mut state = brain.snapshot();
    drop(brain);

    for k in 1..=RESTARTS {
        now += 1; // advance wall clock so a fresh gen_start is strictly greater
        let prior_gen_start = state.gen_start_ms;
        let prior_generation = state.generation;
        let mut brain = handoff_retry(&broker_name, state.clone(), now);

        // 2.4 — gen_start is now(), never the rehydrated stale value.
        assert_eq!(brain.gen_start_ms(), now, "gen_start must be now()");
        assert_ne!(brain.gen_start_ms(), prior_gen_start, "gen_start not carried forward");
        assert_eq!(brain.generation(), prior_generation + 1, "generation increments");

        // The child + its PTY survived the restart untouched.
        assert_eq!(broker.session_count(), 1, "exactly one session across restarts");
        assert_eq!(broker.session_pid(sid), child_pid, "child pid stable across restart");

        // Gapless resume: the marker the previous brain injected (and died before
        // reading) arrives via the replayed buffer.
        let marker = format!("MARKER_{k}_DONE");
        brain
            .read_output_until(marker.as_bytes(), &mut buf)
            .unwrap_or_else(|e| panic!("gapless resume must replay {marker}: {e}"));

        // The daemon-hosted lifecycle is stateless on disk, so it keeps ingesting
        // straight across the brain restart: drop a commune, tick, confirm.
        std::fs::write(drops.path().join("doyle-commune.md"), format!("brief {k}")).unwrap();
        let rep = host.pulse_tick(Some("sid-1")).expect("pulse tick across restart");
        assert_eq!(rep.ingested.len(), 1, "lifecycle keeps ingesting across the brain restart");

        // Hand the next marker to our successor, then crash without reading it.
        if k < RESTARTS {
            brain
                .send_input(format!("MARKER_{}_DONE\r", k + 1).as_bytes())
                .expect("inject next marker");
        }
        state = brain.snapshot();
        drop(brain);
    }

    // ── 5. graceful-signoff: echo BEFORE teardown (3.3), ready marker removed ───
    // Seed the Self's ready marker (graceful teardown removes it).
    let ready = perch::resolve_ready_file(id, ParentHint::Infer);
    std::fs::create_dir_all(ready.parent().unwrap()).unwrap();
    std::fs::write(&ready, "").unwrap();

    let outcome = host.graceful_signoff(Some("sid-1")).expect("graceful signoff");
    assert!(outcome.echo_ran, "echo-before-signoff (3.3) must have run");
    assert!(outcome.signed_off, "graceful signoff tears the Self down");
    assert!(
        drops.path().join("doyle-commune.md").exists(),
        "the echo wrote a final-delta commune before teardown"
    );
    assert!(!ready.exists(), "teardown removed the Self's ready marker");

    // The hosted Psyche is marked offline on teardown (the online-stamp counterpart).
    BrainLifecycle::mark_offline(&psyche.perch_path);
    assert!(!is_perch_alive(&psyche.perch_path), "mark_offline flips the perch offline");

    // ── Reap: kill the child and confirm the broker drops the session ───────────
    now += 1;
    let mut brain = handoff_retry(&broker_name, state, now);
    brain.kill_session().expect("kill child");
    drop(brain);
    let mut reaped = false;
    for _ in 0..200 {
        if broker.session_count() == 0 {
            reaped = true;
            break;
        }
        thread::sleep(Duration::from_millis(10));
    }
    assert!(reaped, "child exit must reap the session from the broker table");
}


Run the new E2E test.


Bash

cd C:/Users/decid/Documents/projects/spt-core && cargo test -p spt-daemon --test daemon_e2e 2>&1 | tail -40

Run B9 daemon E2E test


   Compiling spt-daemon v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.81s
     Running tests\daemon_e2e.rs (target\debug\deps\daemon_e2e-d2a93a42af8b9afc.exe)

running 1 test
test daemon_hosts_lifecycle_and_survives_brain_restart ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.24s


E2E green on Windows. Task #1 done. Activate reqs now.


Updated task #2 status


40	[[requirements]]
41	id = "REQ-DAEMON-1"
42	title = "One per-machine spt-daemon owning all per-machine state"
43	required_stages = ["impl", "unit"]  # activated M3b-B3: interim listen/pulse/Psyche loops consolidated into the brain (lifecycle/config/relay); int via the B9 daemon E2E
44	[[requirements]]
45	id = "REQ-DAEMON-2"
46	title = "Broker/brain split for seamless self-update"
47	required_stages = ["impl", "unit"]
48	[[requirements]]
49	id = "REQ-DAEMON-3"
50	title = "Any api invocation auto-starts the daemon if absent"
51	required_stages = ["impl", "unit", "int"]
52	[[requirements]]
53	id = "REQ-DAEMON-4"
54	title = "Honor every KNOWN-HAZARDS invariant"
55	required_stages = ["impl", "unit"]  # activated M3b-B7: orphan-watch carries the graceful ordering (echo 3.3 / grace 1.1 / stale-sweep 3.2) into the supervised-crash path (lifecycle: supervised_crash_signoff / run_orphan_watch / harness_owner_gone); int via the B9 daemon E2E

[[requirements]]
id = "REQ-DAEMON-1"
title = "One per-machine spt-daemon owning all per-machine state"
required_stages = ["impl", "unit"]  # activated M3b-B3: interim listen/pulse/Psyche loops consolidated into the brain (lifecycle/config/relay); int via the B9 daemon E2E
[[requirements]]
id = "REQ-DAEMON-2"
title = "Broker/brain split for seamless self-update"
required_stages = ["impl", "unit"]
[[requirements]]
id = "REQ-DAEMON-3"
title = "Any api invocation auto-starts the daemon if absent"
required_stages = ["impl", "unit", "int"]
[[requirements]]
id = "REQ-DAEMON-4"
title = "Honor every KNOWN-HAZARDS invariant"
required_stages = ["impl", "unit"]  # activated M3b-B7: orphan-watch carries the graceful ordering (echo 3.3 / grace 1.1 / stale-sweep 3.2) into the supervised-crash path (lifecycle: supervised_crash_signoff / run_orphan_watch / harness_owner_gone); int via the B9 daemon E2E


Edit

title = "One per-machine spt-daemon owning all per-machine state"
required_stages = ["impl", "unit"]  # activated M3b-B3: interim listen/pulse/Psyche loops consolidated into the brain (lifecycle/config/relay); int via the B9 daemon E2E

title = "One per-machine spt-daemon owning all per-machine state"
required_stages = ["impl", "unit", "int"]  # int activated M3b-B9: the daemon E2E (spawn→Psyche-loop→commune→brain-restart-survives→graceful-signoff) drives the consolidated daemon-hosted lifecycle through the real IPC


The file C:\Users\decid\Documents\projects\spt-core\traceable-reqs.toml has been updated successfully. (file state is current in your context — no need to Read it back)

title = "One per-machine spt-daemon owning all per-machine state"
required_stages = ["impl", "unit"]  # activated M3b-B3: interim listen/pulse/Psyche loops consolidated into the brain (lifecycle/config/relay); int via the B9 daemon E2E

title = "One per-machine spt-daemon owning all per-machine state"
required_stages = ["impl", "unit", "int"]  # int activated M3b-B9: the daemon E2E (spawn→Psyche-loop→commune→brain-restart-survives→graceful-signoff) drives the consolidated daemon-hosted lifecycle through the real IPC

-required_stages = ["impl", "unit"]  # activated M3b-B3: interim listen/pulse/Psyche loops consolidated into the brain (lifecycle/config/relay); int via the B9 daemon E2E

+required_stages = ["impl", "unit", "int"]  # int activated M3b-B9: the daemon E2E (spawn→Psyche-loop→commune→brain-restart-survives→graceful-signoff) drives the consolidated daemon-hosted lifecycle through the real IPC

title = "Broker/brain split for seamless self-update"


title = "Broker/brain split for seamless self-update"
required_stages = ["impl", "unit"]

title = "Broker/brain split for seamless self-update"
required_stages = ["impl", "unit", "int"]  # int activated M3b-B9: the daemon E2E proves a brain kill+restart leaves the broker's PTY child + output stream intact and gapless (cross-cut with handoff.rs)


title = "Broker/brain split for seamless self-update"
required_stages = ["impl", "unit"]

title = "Broker/brain split for seamless self-update"
required_stages = ["impl", "unit", "int"]  # int activated M3b-B9: the daemon E2E proves a brain kill+restart leaves the broker's PTY child + output stream intact and gapless (cross-cut with handoff.rs)

+required_stages = ["impl", "unit", "int"]  # int activated M3b-B9: the daemon E2E proves a brain kill+restart leaves the broker's PTY child + output stream intact and gapless (cross-cut with handoff.rs)

title = "Any api invocation auto-starts the daemon if absent"


title = "Honor every KNOWN-HAZARDS invariant"
required_stages = ["impl", "unit"]  # activated M3b-B7: orphan-watch carries the graceful ordering (echo 3.3 / grace 1.1 / stale-sweep 3.2) into the supervised-crash path (lifecycle: supervised_crash_signoff / run_orphan_watch / harness_owner_gone); int via the B9 daemon E2E

title = "Honor every KNOWN-HAZARDS invariant"
required_stages = ["impl", "unit", "int"]  # int activated M3b-B9: the daemon E2E proves graceful signoff runs the echo-before-teardown ordering (3.3) and removes the Self's ready marker in the consolidated daemon


title = "Honor every KNOWN-HAZARDS invariant"
required_stages = ["impl", "unit"]  # activated M3b-B7: orphan-watch carries the graceful ordering (echo 3.3 / grace 1.1 / stale-sweep 3.2) into the supervised-crash path (lifecycle: supervised_crash_signoff / run_orphan_watch / harness_owner_gone); int via the B9 daemon E2E

title = "Honor every KNOWN-HAZARDS invariant"
required_stages = ["impl", "unit", "int"]  # int activated M3b-B9: the daemon E2E proves graceful signoff runs the echo-before-teardown ordering (3.3) and removes the Self's ready marker in the consolidated daemon

-required_stages = ["impl", "unit"]  # activated M3b-B7: orphan-watch carries the graceful ordering (echo 3.3 / grace 1.1 / stale-sweep 3.2) into the supervised-crash path (lifecycle: supervised_crash_signoff / run_orphan_watch / harness_owner_gone); int via the B9 daemon E2E

+required_stages = ["impl", "unit", "int"]  # int activated M3b-B9: the daemon E2E proves graceful signoff runs the echo-before-teardown ordering (3.3) and removes the Self's ready marker in the consolidated daemon

# ───────────────────────────── Storage ──────────────────────────────────────

# git-KV BranchStore substrate (ADR-0011, 2026-06-02; implement M4, optional M3c)


Now the two hazards (DAEMON-HOSTED-LIVENESS has E2E int evidence; RESTART-IDEMPOTENT already tagged in idempotent.rs).


533	id = "REQ-HAZARD-RESTART-IDEMPOTENT"
534	title = "Idempotent/exactly-once delivery across brain restart at every broker boundary (codex #14)"
535	required_stages = ["impl", "unit"]  # M3b B5: broker-owned EffectJournal anchor + dedup-at-effect by durable id (Spike #6). int via the B9 daemon E2E.
536	[[requirements]]
537	id = "REQ-HAZARD-UPDATE-ROLLBACK"
538	title = "Self-update rejects version rollback; metadata expiry + adapter content signing (codex #5)"
539	required_stages = []
540	[[requirements]]
541	id = "REQ-HAZARD-DAEMON-HOSTED-LIVENESS"
542	title = "Daemon-hosted perches (Psyche, spt-hosted Self) derive liveness from the daemon endpoint table + info.json status, never is_process_alive(info.pid) (2.5)"
543	required_stages = ["impl", "unit"]  # M3b B4: single liveness resolver + info.json status, daemon marks hosted perches. int via the B9 daemon E2E.
544

id = "REQ-HAZARD-RESTART-IDEMPOTENT"
title = "Idempotent/exactly-once delivery across brain restart at every broker boundary (codex #14)"
required_stages = ["impl", "unit"]  # M3b B5: broker-owned EffectJournal anchor + dedup-at-effect by durable id (Spike #6). int via the B9 daemon E2E.
[[requirements]]
id = "REQ-HAZARD-UPDATE-ROLLBACK"
title = "Self-update rejects version rollback; metadata expiry + adapter content signing (codex #5)"
required_stages = []
[[requirements]]
id = "REQ-HAZARD-DAEMON-HOSTED-LIVENESS"
title = "Daemon-hosted perches (Psyche, spt-hosted Self) derive liveness from the daemon endpoint table + info.json status, never is_process_alive(info.pid) (2.5)"
required_stages = ["impl", "unit"]  # M3b B4: single liveness resolver + info.json status, daemon marks hosted perches. int via the B9 daemon E2E.


title = "Idempotent/exactly-once delivery across brain restart at every broker boundary (codex #14)"
required_stages = ["impl", "unit"]  # M3b B5: broker-owned EffectJournal anchor + dedup-at-effect by durable id (Spike #6). int via the B9 daemon E2E.

title = "Idempotent/exactly-once delivery across brain restart at every broker boundary (codex #14)"
required_stages = ["impl", "unit", "int"]  # int activated M3b-B9: tests/idempotent.rs crashes the brain before-intent/before-effect/after-effect at the PTY-write boundary → exactly-once (Spike #6)


title = "Idempotent/exactly-once delivery across brain restart at every broker boundary (codex #14)"
required_stages = ["impl", "unit"]  # M3b B5: broker-owned EffectJournal anchor + dedup-at-effect by durable id (Spike #6). int via the B9 daemon E2E.

title = "Idempotent/exactly-once delivery across brain restart at every broker boundary (codex #14)"
required_stages = ["impl", "unit", "int"]  # int activated M3b-B9: tests/idempotent.rs crashes the brain before-intent/before-effect/after-effect at the PTY-write boundary → exactly-once (Spike #6)

-required_stages = ["impl", "unit"]  # M3b B5: broker-owned EffectJournal anchor + dedup-at-effect by durable id (Spike #6). int via the B9 daemon E2E.

+required_stages = ["impl", "unit", "int"]  # int activated M3b-B9: tests/idempotent.rs crashes the brain before-intent/before-effect/after-effect at the PTY-write boundary → exactly-once (Spike #6)

title = "Self-update rejects version rollback; metadata expiry + adapter content signing (codex #5)"


title = "Daemon-hosted perches (Psyche, spt-hosted Self) derive liveness from the daemon endpoint table + info.json status, never is_process_alive(info.pid) (2.5)"
required_stages = ["impl", "unit"]  # M3b B4: single liveness resolver + info.json status, daemon marks hosted perches. int via the B9 daemon E2E.

title = "Daemon-hosted perches (Psyche, spt-hosted Self) derive liveness from the daemon endpoint table + info.json status, never is_process_alive(info.pid) (2.5)"
required_stages = ["impl", "unit", "int"]  # int activated M3b-B9: the daemon E2E spawns the hosted Psyche, then proves a dead summarizer pid does NOT flip the daemon-online perch offline


title = "Daemon-hosted perches (Psyche, spt-hosted Self) derive liveness from the daemon endpoint table + info.json status, never is_process_alive(info.pid) (2.5)"
required_stages = ["impl", "unit"]  # M3b B4: single liveness resolver + info.json status, daemon marks hosted perches. int via the B9 daemon E2E.

title = "Daemon-hosted perches (Psyche, spt-hosted Self) derive liveness from the daemon endpoint table + info.json status, never is_process_alive(info.pid) (2.5)"
required_stages = ["impl", "unit", "int"]  # int activated M3b-B9: the daemon E2E spawns the hosted Psyche, then proves a dead summarizer pid does NOT flip the daemon-online perch offline

-required_stages = ["impl", "unit"]  # M3b B4: single liveness resolver + info.json status, daemon marks hosted perches. int via the B9 daemon E2E.

+required_stages = ["impl", "unit", "int"]  # int activated M3b-B9: the daemon E2E spawns the hosted Psyche, then proves a dead summarizer pid does NOT flip the daemon-online perch offline


Bash

cd C:/Users/decid/Documents/projects/spt-core && traceable-reqs check 2>&1 | tail -8 && echo "===EXIT $?==="

Run traceability check after activation


  [OK] REQ-UPD-2  required: []  stages: -doc -impl -unit -int
       All binaries signature-verified before handoff
  [OK] REQ-UPD-3  required: []  stages: -doc -impl -unit -int
       No endpoint process terminates/suspends during self-update
  [OK] REQ-UPD-4  required: []  stages: -doc -impl -unit -int
       Update gated on user confirmation by default; opt-in full-auto
  [OK] REQ-UPD-5  required: []  stages: -doc -impl -unit -int
       spt-core ripple-updates registered adapters
===EXIT 0===


cd C:/Users/decid/Documents/projects/spt-core && traceable-reqs check 2>&1 | grep -E 'REQ-(DAEMON|HAZARD-(DAEMON-HOSTED|RESTART))' | head

Confirm daemon req int activation


  [OK] REQ-DAEMON-1  required: [impl, unit, int]  stages: -doc +impl +unit +int
  [OK] REQ-DAEMON-2  required: [impl, unit, int]  stages: -doc +impl +unit +int
  [OK] REQ-DAEMON-3  required: [impl, unit, int]  stages: -doc +impl +unit +int
  [OK] REQ-DAEMON-4  required: [impl, unit, int]  stages: -doc +impl +unit +int
  [OK] REQ-HAZARD-DAEMON-HOSTED-LIVENESS  required: [impl, unit, int]  stages: -doc +impl +unit +int
  [OK] REQ-HAZARD-RESTART-IDEMPOTENT  required: [impl, unit, int]  stages: -doc +impl +unit +int


Write B9 daemon E2E test

tests/daemon_e2e.rs: spawn→Psyche-loop→commune→brain-restart-survives→graceful-signoff. int tags for DAEMON-1/2/4 + DAEMON-HOSTED-LIVENESS.

Flip required_stages to add int for DAEMON-1, DAEMON-2, DAEMON-4, DAEMON-HOSTED-LIVENESS, RESTART-IDEMPOTENT. Run traceable-reqs check green.

cargo test --workspace, clippy -D warnings, traceable-reqs check green. Linux via gravity-linux if reachable.

Mark M3b delivered in ROADMAP.md; amend CONTEXT.md; author M3c-PLAN.md stub (self-update).

Commit B9 with traceability trailer

Single B9 commit: E2E test + req activation + docs. Co-Authored-By trailer. Ensure CI matrix expectation noted.


All M3b reqs at `[impl,unit,int]`, check green. Task #2 done. Run full workspace verify.


Updated task #3 status


cd C:/Users/decid/Documents/projects/spt-core && cargo clippy --workspace --all-targets -- -D warnings 2>&1 | tail -15

Clippy workspace with -D warnings


    Checking spt-daemon v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.34s


cd C:/Users/decid/Documents/projects/spt-core && cargo test --workspace 2>&1 | grep -E 'Running|test result|error\[|FAILED|warning:' | tail -60

Run full workspace test suite


     Running unittests src\main.rs (target\debug\deps\mock_session-057ab68cd4a1dc81.exe)
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
     Running tests\manifest_valid.rs (target\debug\deps\manifest_valid-493bd3b46616c18e.exe)
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
     Running unittests src\main.rs (target\debug\deps\spt-ef14f4bd9631b6ec.exe)
test result: ok. 41 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.28s
     Running tests\contract_e2e.rs (target\debug\deps\contract_e2e-5d7013e1d4264c89.exe)
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.26s
     Running unittests src\lib.rs (target\debug\deps\spt_daemon-6a7220dde4e62d53.exe)
test result: ok. 48 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.39s
     Running tests\broker.rs (target\debug\deps\broker-cbc145a32c1e9b85.exe)
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s
     Running tests\daemon_e2e.rs (target\debug\deps\daemon_e2e-580abe58a4f5770d.exe)
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.23s
     Running tests\digest.rs (target\debug\deps\digest-9f19ab6cc2c359ec.exe)
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.15s
     Running tests\handoff.rs (target\debug\deps\handoff-03ae7e61a03aeec0.exe)
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.20s
     Running tests\idempotent.rs (target\debug\deps\idempotent-d73aa25c5b2b5631.exe)
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.08s
     Running unittests src\lib.rs (target\debug\deps\spt_live-05605b7918bd90a7.exe)
test result: ok. 35 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.31s
     Running unittests src\lib.rs (target\debug\deps\spt_msg-2380ff08cca80cc7.exe)
test result: ok. 22 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.37s
     Running tests\killer_quickstart.rs (target\debug\deps\killer_quickstart-cbdb2a396ec983ec.exe)
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.21s
     Running unittests src\lib.rs (target\debug\deps\spt_proto-151dd96e47c9f83c.exe)
test result: ok. 49 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s
     Running unittests src\lib.rs (target\debug\deps\spt_runtime-02b505711840dc63.exe)
test result: ok. 20 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.31s
     Running unittests src\lib.rs (target\debug\deps\spt_store-7f8ef5ed5a775913.exe)
test result: ok. 42 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.72s
     Running unittests src\lib.rs (target\debug\deps\spt_term-4bf162e7406ae12c.exe)
test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
     Running tests\digest.rs (target\debug\deps\digest-dd11ad5e1d83b0d1.exe)
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.05s
     Running tests\dsr.rs (target\debug\deps\dsr-bf7afeae5d3de6b1.exe)
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s
     Running tests\inject.rs (target\debug\deps\inject-b94392081b9e2213.exe)
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.05s
     Running tests\stream.rs (target\debug\deps\stream-3ec500fc2f9c7914.exe)
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.01s
     Running tests\surface.rs (target\debug\deps\surface-efbd6b452e946c18.exe)
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s


Windows: clippy clean, all tests pass, check green. Linux validation → CI matrix (ubuntu) on push. Task #3 (local) done. Onto docs.


Updated task #4 status


Update ROADMAP M3b line to delivered. Read CONTEXT + M3-PLAN for M3c context.


cd C:/Users/decid/Documents/projects/spt-core && grep -nE 'M3b|M3c|M3a' CONTEXT.md M3-PLAN.md 2>/dev/null | head -40

Find M3b/M3c references in CONTEXT and M3-PLAN


CONTEXT.md:273:The multiplatform terminal wrapper is the backbone for hosting agent sessions. Supersedes the sister project's planned "capsule" (which leaned on psmux). Architecturally a **process supervisor** with an abstract session surface. **The session-surface *mechanism* is delivered in M3a (`crates/spt-term`); the supervisor *process* (term-daemon) is M3b (`spt-daemon`).**
CONTEXT.md:276:A long-lived, one-per-machine daemon that owns the PTYs for all hosted sessions, multiplexes them (sessions addressable by name, tmux-style), and accepts attach/detach from frontends. A hosted agent session (a LiveAgent's Self, a ReadyAgent, etc.) runs *inside* a supervised PTY. **Headed** = a frontend is attached and rendering/driving the PTY; **headless** = the session runs with no frontend attached. The daemon's persistence is what lets a session keep running while unwatched and be re-attached "on a whim." *(M3b — hosts many M3a `spt-term` surfaces behind versioned IPC; `spt-term` itself is deliberately single-surface-per-handle.)*
CONTEXT.md:279:The trait spt-core exposes over "anything you can write input to and read output from" — delivered in M3a as `spt_term::SessionSurface` (`write_input` + `resize`, with `send_keys`/`send_line` as first-class injection). Native PTY (`spt_term::PtySession`: ConPTY on Windows 10+, `forkpty(3)` on Unix, via the `portable-pty` crate) is the day-one implementation. The abstraction generalizes so future implementations — network-attached surfaces (for `spt-node` remote control), GUI text panes, etc. — are uniform to consumers. Pre-Windows-10 (winpty) is explicitly dropped. **OS-neutral by contract (Spike #4/#5):** the trait bakes in neither `forkpty`'s raw-ordered-pipe nor ConPTY's repaint-on-resize screen-buffer semantics; output is a plain byte stream (`spt_term::OutputStream`, bounded backpressure) with OS-independent replay.
M3-PLAN.md:13:> - **M3a — `spt-term`**: the broker's PTY/session-surface half (REQ-TERM-*).
M3-PLAN.md:14:> - **M3b — `spt-daemon`**: the broker/brain split + consolidation; re-hosts the
M3-PLAN.md:16:> - **M3c — self-update**: the gated, signed, ripple update engine (REQ-UPD-*,
M3-PLAN.md:56:> `docs/spikes/SPIKE-0{3,4,5,6}-*.md`; ADR-0004 §E updated. M3a + M3b unblocked.
M3-PLAN.md:76:**Gate rule:** M3b does not start until #3 and #6 return a verdict. #4/#5 may run
M3-PLAN.md:77:in parallel with M3a. A spike that *falsifies* ADR-0004 §B re-opens the ADR
M3-PLAN.md:80:### M3a — `spt-term` (the broker's PTY / session-surface half)
M3-PLAN.md:82:> **Detailed JIT plan: [`M3a-PLAN.md`](./M3a-PLAN.md).** Folds in **REQ-TERM-4**
M3-PLAN.md:83:> (live activity buffer / PTY digest, ADR-0008 — the parser primitive is M3a; its
M3-PLAN.md:84:> manifest-sourcing + `spt digest` + delta-stream + persistence are M3b).
M3-PLAN.md:98:**Out:** the broker *process* + IPC (that's M3b — `spt-term` provides the PTY
M3-PLAN.md:101:### M3b — `spt-daemon` (broker/brain split + consolidation)
M3-PLAN.md:133:### M3c — self-update (gated, signed, ripple)
M3-PLAN.md:153:  layer. M3c ships the update *engine* with **self-fetch / out-of-band** delivery
M3-PLAN.md:198:| S3 | **Spike #3 — QUIC + file-transfer survival across brain restart** | ADR-0004 §E.1 (FATAL #2) | blocks M3b | an active QUIC stream + in-flight transfer in the broker survive a brain restart; or ADR-0004 §B re-opened |
M3-PLAN.md:199:| S4 | **Spike #4 — Linux `forkpty` parity** | ADR-0004 §E.2 | blocks M3a close | broker PTY handoff passes on Linux `forkpty` (mirror Spike #1) |
M3-PLAN.md:200:| S5 | **Spike #5 — 100× restart + resize-under-load** | ADR-0004 §E.3 | blocks M3b close | 100 brain restarts + concurrent resizes, no leak/hang/lost-byte |
M3-PLAN.md:201:| S6 | **Spike #6 — idempotent broker↔brain delivery** | ADR-0004 §E.4 | blocks M3b idempotency task | durable-ID + replay rule prototype: crash before/after each side-effect, no dup/drop |
M3-PLAN.md:203:## Tasks — M3a `spt-term`
M3-PLAN.md:214:## Tasks — M3b `spt-daemon`
M3-PLAN.md:229:## Tasks — M3c self-update
M3-PLAN.md:274:  leap. **Do not start M3b until the spike-gate (Phase 0) returns** — a falsified


133	### M3c — self-update (gated, signed, ripple)
134
135	**In:**
136	- the **update-class taxonomy** (ADR-0004 §A): **brain-only** (no endpoint
137	  terminates — `REQ-UPD-3`, absolute, the routine case, proven E2E),
138	  **broker-compatible** (hot-swap behind the versioned IPC), **broker-breaking**
139	  (planned endpoint-cycle, may suspend with consent + scheduling);
140	- **signature verification before handoff** (`REQ-UPD-2`) — spt-core's release
141	  key, on every binary regardless of source;
142	- **update hardening** (`REQ-HAZARD-UPDATE-ROLLBACK`, ADR-0004 §D) — monotonic
143	  version (rollback rejection), release-metadata expiry, channel pinning, key
144	  rotation/revocation list, **adapter content signing**;
145	- **consent gating** (`REQ-UPD-4`) — gated on user confirmation delivered to the
146	  most-recently-active live session; opt-in full-auto;
147	- **adapter ripple-update** (`REQ-UPD-5` + `REQ-SEAM-UPDATE`) — spt-core conducts
148	  the stack: self first, then each registered adapter via its manifest `[update]`
149	  avenue (file-pull / delegated command), with adapter content signing.
150
151	**Out:**
152	- **Peer-propagation transport over P2P** (`REQ-UPD-1`) — needs the M4 network
153	  layer. M3c ships the update *engine* with **self-fetch / out-of-band** delivery
154	  (ADR-0004: "layered on self-fetch, out-of-band still supported"); peer-propagation
155	  is the M4 transport plugged into the same engine. `REQ-UPD-1` activates at M4.
156
157	## Clean-room posture (validated against the sister)
158
159	- **`spt-term` is brand-new** — the sister never hosted ConPTY/PTY directly (it
160	  shelled out to `claude`). Clean-room the whole crate; the only copied artifact
161	  is the **ConPTY-DSR mechanism** proven in Spike #1 (harness-orthogonal).
162	- **`spt-daemon` consolidation** — copy the sister's hard-won **ordering /
163	  liveness invariants** (orphan grace 1.1, echo 3.3, stale-sweep 3.2, stable-PID
164	  anchor 5.1, gen_start 2.4) and clean-room the process model (the sister's
165	  separate listener + wrapper processes collapse into the one daemon — ADR-0004).
166	- **self-update** — clean-room (the sister used a GitHub remote + `gh`; spt-core
167	  self-fetches + verifies signatures, P2P transport at M4 — ADR-0002/0004).
168
169	## New requirements to register first (TRACEABILITY rule 3)
170
171	**Assessment — none.** Every M3 deliverable maps onto already-registered reqs:
172	- daemon → `REQ-DAEMON-1/2/3/4`; terminal → `REQ-TERM-1/2/3`; startup →
173	  `REQ-START-3`; update → `REQ-UPD-2/3/4/5`, `REQ-SEAM-UPDATE`; `REQ-UPD-1`
174	  (peer-propagation) stays `[]` until M4.
175	- hazards → `REQ-HAZARD-CONPTY-DSR` (5.5), `REQ-HAZARD-DAEMON-HOSTED-LIVENESS`
176	  (2.5), `REQ-HAZARD-RESTART-IDEMPOTENT` (7.2), `REQ-HAZARD-HANDOFF-ARGV-COMPAT`
177	  (2.3), `REQ-HAZARD-GEN-START-NOW` (2.4), `REQ-HAZARD-UPDATE-ROLLBACK` — all
178	  registered; activate per task.
179	- `REQ-DAEMON-4` ("honor every KNOWN-HAZARDS invariant") is the conformance
180	  umbrella — activate last (M3 close), evidenced by the hazard suite being green.
181
182	## Sequencing rationale
183
184	Spike-gate first (§E gaps falsify-or-confirm the broker shape *before* code).
185	Then `spt-term` (the PTY mechanism the broker hosts — buildable in parallel with
186	spikes #4/#5). Then `spt-daemon`: broker skeleton (host `spt-term` PTYs +
187	versioned IPC) → brain skeleton (rehydrate + re-attach) → **re-host the M2b
188	lifecycle** (the load-bearing consolidation) → daemon liveness → idempotent
189	boundary → orphan-watch → spt-hosted startup → auto-start. Self-update last (it
190	rests on the broker/brain split being real): update-class engine → signature +
191	rollback hardening → consent gating → adapter ripple. Each task tags evidence +
192	activates its reqs in the same commit; `traceable-reqs check` green before the next.
193
194	## Tasks — Phase 0 spike-gate (`spt-spikes/`)
195
196	| # | Task | Source | Gate | Acceptance |
197	|---|------|--------|------|------------|
198	| S3 | **Spike #3 — QUIC + file-transfer survival across brain restart** | ADR-0004 §E.1 (FATAL #2) | blocks M3b | an active QUIC stream + in-flight transfer in the broker survive a brain restart; or ADR-0004 §B re-opened |
199	| S4 | **Spike #4 — Linux `forkpty` parity** | ADR-0004 §E.2 | blocks M3a close | broker PTY handoff passes on Linux `forkpty` (mirror Spike #1) |
200	| S5 | **Spike #5 — 100× restart + resize-under-load** | ADR-0004 §E.3 | blocks M3b close | 100 brain restarts + concurrent resizes, no leak/hang/lost-byte |
201	| S6 | **Spike #6 — idempotent broker↔brain delivery** | ADR-0004 §E.4 | blocks M3b idempotency task | durable-ID + replay rule prototype: crash before/after each side-effect, no dup/drop |
202
203	## Tasks — M3a `spt-term`
204
205	| # | Task | Reqs / hazards | Acceptance |
206	|---|------|----------------|------------|
207	| A0 | Scaffold `crates/spt-term` (sibling of spt-runtime, above spt-msg); deps proto/store/msg | — | compiles; layering acyclic; `check` green |

### M3c — self-update (gated, signed, ripple)

**In:**
- the **update-class taxonomy** (ADR-0004 §A): **brain-only** (no endpoint
  terminates — `REQ-UPD-3`, absolute, the routine case, proven E2E),
  **broker-compatible** (hot-swap behind the versioned IPC), **broker-breaking**
  (planned endpoint-cycle, may suspend with consent + scheduling);
- **signature verification before handoff** (`REQ-UPD-2`) — spt-core's release
  key, on every binary regardless of source;
- **update hardening** (`REQ-HAZARD-UPDATE-ROLLBACK`, ADR-0004 §D) — monotonic
  version (rollback rejection), release-metadata expiry, channel pinning, key
  rotation/revocation list, **adapter content signing**;
- **consent gating** (`REQ-UPD-4`) — gated on user confirmation delivered to the
  most-recently-active live session; opt-in full-auto;
- **adapter ripple-update** (`REQ-UPD-5` + `REQ-SEAM-UPDATE`) — spt-core conducts
  the stack: self first, then each registered adapter via its manifest `[update]`
  avenue (file-pull / delegated command), with adapter content signing.

**Out:**
- **Peer-propagation transport over P2P** (`REQ-UPD-1`) — needs the M4 network
  layer. M3c ships the update *engine* with **self-fetch / out-of-band** delivery
  (ADR-0004: "layered on self-fetch, out-of-band still supported"); peer-propagation
  is the M4 transport plugged into the same engine. `REQ-UPD-1` activates at M4.

## Clean-room posture (validated against the sister)

- **`spt-term` is brand-new** — the sister never hosted ConPTY/PTY directly (it
  shelled out to `claude`). Clean-room the whole crate; the only copied artifact
  is the **ConPTY-DSR mechanism** proven in Spike #1 (harness-orthogonal).
- **`spt-daemon` consolidation** — copy the sister's hard-won **ordering /
  liveness invariants** (orphan grace 1.1, echo 3.3, stale-sweep 3.2, stable-PID
  anchor 5.1, gen_start 2.4) and clean-room the process model (the sister's
  separate listener + wrapper processes collapse into the one daemon — ADR-0004).
- **self-update** — clean-room (the sister used a GitHub remote + `gh`; spt-core
  self-fetches + verifies signatures, P2P transport at M4 — ADR-0002/0004).

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

**Assessment — none.** Every M3 deliverable maps onto already-registered reqs:
- daemon → `REQ-DAEMON-1/2/3/4`; terminal → `REQ-TERM-1/2/3`; startup →
  `REQ-START-3`; update → `REQ-UPD-2/3/4/5`, `REQ-SEAM-UPDATE`; `REQ-UPD-1`
  (peer-propagation) stays `[]` until M4.
- hazards → `REQ-HAZARD-CONPTY-DSR` (5.5), `REQ-HAZARD-DAEMON-HOSTED-LIVENESS`
  (2.5), `REQ-HAZARD-RESTART-IDEMPOTENT` (7.2), `REQ-HAZARD-HANDOFF-ARGV-COMPAT`
  (2.3), `REQ-HAZARD-GEN-START-NOW` (2.4), `REQ-HAZARD-UPDATE-ROLLBACK` — all
  registered; activate per task.
- `REQ-DAEMON-4` ("honor every KNOWN-HAZARDS invariant") is the conformance
  umbrella — activate last (M3 close), evidenced by the hazard suite being green.

## Sequencing rationale

Spike-gate first (§E gaps falsify-or-confirm the broker shape *before* code).
Then `spt-term` (the PTY mechanism the broker hosts — buildable in parallel with
spikes #4/#5). Then `spt-daemon`: broker skeleton (host `spt-term` PTYs +
versioned IPC) → brain skeleton (rehydrate + re-attach) → **re-host the M2b
lifecycle** (the load-bearing consolidation) → daemon liveness → idempotent
boundary → orphan-watch → spt-hosted startup → auto-start. Self-update last (it
rests on the broker/brain split being real): update-class engine → signature +
rollback hardening → consent gating → adapter ripple. Each task tags evidence +
activates its reqs in the same commit; `traceable-reqs check` green before the next.

## Tasks — Phase 0 spike-gate (`spt-spikes/`)

| # | Task | Source | Gate | Acceptance |
|---|------|--------|------|------------|
| S3 | **Spike #3 — QUIC + file-transfer survival across brain restart** | ADR-0004 §E.1 (FATAL #2) | blocks M3b | an active QUIC stream + in-flight transfer in the broker survive a brain restart; or ADR-0004 §B re-opened |
| S4 | **Spike #4 — Linux `forkpty` parity** | ADR-0004 §E.2 | blocks M3a close | broker PTY handoff passes on Linux `forkpty` (mirror Spike #1) |
| S5 | **Spike #5 — 100× restart + resize-under-load** | ADR-0004 §E.3 | blocks M3b close | 100 brain restarts + concurrent resizes, no leak/hang/lost-byte |
| S6 | **Spike #6 — idempotent broker↔brain delivery** | ADR-0004 §E.4 | blocks M3b idempotency task | durable-ID + replay rule prototype: crash before/after each side-effect, no dup/drop |

## Tasks — M3a `spt-term`

| # | Task | Reqs / hazards | Acceptance |
|---|------|----------------|------------|
| A0 | Scaffold `crates/spt-term` (sibling of spt-runtime, above spt-msg); deps proto/store/msg | — | compiles; layering acyclic; `check` green |


Now amend ROADMAP M3b → delivered, M3c → NEXT.


Edit

  - **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.**

  - **M3b — `spt-daemon` broker/brain process. ✅ delivered (2026-06-03).** The seventh crate (`…→spt-live→spt-daemon→spt`) hosts many `spt-term` surfaces behind a **versioned local IPC** (named-pipe/UDS behind one `DaemonTransport` trait, forward-compatible frame schema): a **broker** (stable kernel — PTY masters, harness children, sockets; no logic) + a **brain** (restartable logic) so a brain kill/restart leaves the hosted PTY child + its output stream **intact and gapless** (Spike #1 made real; `gen_start = now()` each handoff). Consolidates the interim no-daemon model — the `api listen` pulse/Psyche/drop-ingest loops fold into the brain as config-paced in-process loops (only a thin stateless relay residue) · daemon-authoritative liveness (`info.json` `status` supersedes `is_process_alive` for hosted perches) · exactly-once broker↔brain boundary (broker-owned `EffectJournal` + dedup-at-effect, Spike #6) · auto-start + in-memory seed (the seed file is dead) · supervised-crash orphan-watch (carries the M2b graceful ordering) · the **digest daemon-half** (manifest `pty_digest` seam → `DigestParser` over the broker PTY, `spt digest <id>` pull + delta-stream + opt-in Path-B persistence → REQ-TERM-4 `int`). All M3b reqs activated + green; daemon E2E (spawn→Psyche-loop→commune→brain-restart-survives→graceful-signoff) on the CI matrix (ubuntu + windows). Task breakdown: [`M3b-PLAN.md`](./M3b-PLAN.md).
  - **M3c — peer-propagated signed self-update** (NEXT). Stub: [`M3c-PLAN.md`](./M3c-PLAN.md).


The file C:\Users\decid\Documents\projects\spt-core\ROADMAP.md has been updated successfully. (file state is current in your context — no need to Read it back)

  - **M3b — `spt-daemon` broker/brain process. ✅ delivered (2026-06-03).** The seventh crate (`…→spt-live→spt-daemon→spt`) hosts many `spt-term` surfaces behind a **versioned local IPC** (named-pipe/UDS behind one `DaemonTransport` trait, forward-compatible frame schema): a **broker** (stable kernel — PTY masters, harness children, sockets; no logic) + a **brain** (restartable logic) so a brain kill/restart leaves the hosted PTY child + its output stream **intact and gapless** (Spike #1 made real; `gen_start = now()` each handoff). Consolidates the interim no-daemon model — the `api listen` pulse/Psyche/drop-ingest loops fold into the brain as config-paced in-process loops (only a thin stateless relay residue) · daemon-authoritative liveness (`info.json` `status` supersedes `is_process_alive` for hosted perches) · exactly-once broker↔brain boundary (broker-owned `EffectJournal` + dedup-at-effect, Spike #6) · auto-start + in-memory seed (the seed file is dead) · supervised-crash orphan-watch (carries the M2b graceful ordering) · the **digest daemon-half** (manifest `pty_digest` seam → `DigestParser` over the broker PTY, `spt digest <id>` pull + delta-stream + opt-in Path-B persistence → REQ-TERM-4 `int`). All M3b reqs activated + green; daemon E2E (spawn→Psyche-loop→commune→brain-restart-survives→graceful-signoff) on the CI matrix (ubuntu + windows). Task breakdown: [`M3b-PLAN.md`](./M3b-PLAN.md).
  - **M3c — peer-propagated signed self-update** (NEXT). Stub: [`M3c-PLAN.md`](./M3c-PLAN.md).

# 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.

-  - **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.**

+  - **M3b — `spt-daemon` broker/brain process. ✅ delivered (2026-06-03).** The seventh crate (`…→spt-live→spt-daemon→spt`) hosts many `spt-term` surfaces behind a **versioned local IPC** (named-pipe/UDS behind one `DaemonTransport` trait, forward-compatible frame schema): a **broker** (stable kernel — PTY masters, harness children, sockets; no logic) + a **brain** (restartable logic) so a brain kill/restart leaves the hosted PTY child + its output stream **intact and gapless** (Spike #1 made real; `gen_start = now()` each handoff). Consolidates the interim no-daemon model — the `api listen` pulse/Psyche/drop-ingest loops fold into the brain as config-paced in-process loops (only a thin stateless relay residue) · daemon-authoritative liveness (`info.json` `status` supersedes `is_process_alive` for hosted perches) · exactly-once broker↔brain boundary (broker-owned `EffectJournal` + dedup-at-effect, Spike #6) · auto-start + in-memory seed (the seed file is dead) · supervised-crash orphan-watch (carries the M2b graceful ordering) · the **digest daemon-half** (manifest `pty_digest` seam → `DigestParser` over the broker PTY, `spt digest <id>` pull + delta-stream + opt-in Path-B persistence → REQ-TERM-4 `int`). All M3b reqs activated + green; daemon E2E (spawn→Psyche-loop→commune→brain-restart-survives→graceful-signoff) on the CI matrix (ubuntu + windows). Task breakdown: [`M3b-PLAN.md`](./M3b-PLAN.md).

+  - **M3c — peer-propagated signed self-update** (NEXT). Stub: [`M3c-PLAN.md`](./M3c-PLAN.md).


Update CONTEXT.md M3b-as-future phrasing → delivered.


271	## Terminal wrapper
272
273	The multiplatform terminal wrapper is the backbone for hosting agent sessions. Supersedes the sister project's planned "capsule" (which leaned on psmux). Architecturally a **process supervisor** with an abstract session surface. **The session-surface *mechanism* is delivered in M3a (`crates/spt-term`); the supervisor *process* (term-daemon) is M3b (`spt-daemon`).**
274
275	**term-daemon** (process supervisor):
276	A long-lived, one-per-machine daemon that owns the PTYs for all hosted sessions, multiplexes them (sessions addressable by name, tmux-style), and accepts attach/detach from frontends. A hosted agent session (a LiveAgent's Self, a ReadyAgent, etc.) runs *inside* a supervised PTY. **Headed** = a frontend is attached and rendering/driving the PTY; **headless** = the session runs with no frontend attached. The daemon's persistence is what lets a session keep running while unwatched and be re-attached "on a whim." *(M3b — hosts many M3a `spt-term` surfaces behind versioned IPC; `spt-term` itself is deliberately single-surface-per-handle.)*
277
278	**session surface** (library abstraction):
279	The trait spt-core exposes over "anything you can write input to and read output from" — delivered in M3a as `spt_term::SessionSurface` (`write_input` + `resize`, with `send_keys`/`send_line` as first-class injection). Native PTY (`spt_term::PtySession`: ConPTY on Windows 10+, `forkpty(3)` on Unix, via the `portable-pty` crate) is the day-one implementation. The abstraction generalizes so future implementations — network-attached surfaces (for `spt-node` remote control), GUI text panes, etc. — are uniform to consumers. Pre-Windows-10 (winpty) is explicitly dropped. **OS-neutral by contract (Spike #4/#5):** the trait bakes in neither `forkpty`'s raw-ordered-pipe nor ConPTY's repaint-on-resize screen-buffer semantics; output is a plain byte stream (`spt_term::OutputStream`, bounded backpressure) with OS-independent replay.
280

## Terminal wrapper

The multiplatform terminal wrapper is the backbone for hosting agent sessions. Supersedes the sister project's planned "capsule" (which leaned on psmux). Architecturally a **process supervisor** with an abstract session surface. **The session-surface *mechanism* is delivered in M3a (`crates/spt-term`); the supervisor *process* (term-daemon) is M3b (`spt-daemon`).**

**term-daemon** (process supervisor):
A long-lived, one-per-machine daemon that owns the PTYs for all hosted sessions, multiplexes them (sessions addressable by name, tmux-style), and accepts attach/detach from frontends. A hosted agent session (a LiveAgent's Self, a ReadyAgent, etc.) runs *inside* a supervised PTY. **Headed** = a frontend is attached and rendering/driving the PTY; **headless** = the session runs with no frontend attached. The daemon's persistence is what lets a session keep running while unwatched and be re-attached "on a whim." *(M3b — hosts many M3a `spt-term` surfaces behind versioned IPC; `spt-term` itself is deliberately single-surface-per-handle.)*

**session surface** (library abstraction):
The trait spt-core exposes over "anything you can write input to and read output from" — delivered in M3a as `spt_term::SessionSurface` (`write_input` + `resize`, with `send_keys`/`send_line` as first-class injection). Native PTY (`spt_term::PtySession`: ConPTY on Windows 10+, `forkpty(3)` on Unix, via the `portable-pty` crate) is the day-one implementation. The abstraction generalizes so future implementations — network-attached surfaces (for `spt-node` remote control), GUI text panes, etc. — are uniform to consumers. Pre-Windows-10 (winpty) is explicitly dropped. **OS-neutral by contract (Spike #4/#5):** the trait bakes in neither `forkpty`'s raw-ordered-pipe nor ConPTY's repaint-on-resize screen-buffer semantics; output is a plain byte stream (`spt_term::OutputStream`, bounded backpressure) with OS-independent replay.


**The session-surface *mechanism* is delivered in M3a (`crates/spt-term`); the supervisor *process* (term-daemon) is M3b (`spt-daemon`).**

**The session-surface *mechanism* is delivered in M3a (`crates/spt-term`); the supervisor *process* (term-daemon) is delivered in M3b (`crates/spt-daemon`, 2026-06-03).**


The file C:\Users\decid\Documents\projects\spt-core\CONTEXT.md has been updated successfully. (file state is current in your context — no need to Read it back)

-The multiplatform terminal wrapper is the backbone for hosting agent sessions. Supersedes the sister project's planned "capsule" (which leaned on psmux). Architecturally a **process supervisor** with an abstract session surface. **The session-surface *mechanism* is delivered in M3a (`crates/spt-term`); the supervisor *process* (term-daemon) is M3b (`spt-daemon`).**

+The multiplatform terminal wrapper is the backbone for hosting agent sessions. Supersedes the sister project's planned "capsule" (which leaned on psmux). Architecturally a **process supervisor** with an abstract session surface. **The session-surface *mechanism* is delivered in M3a (`crates/spt-term`); the supervisor *process* (term-daemon) is delivered in M3b (`crates/spt-daemon`, 2026-06-03).**

A long-lived, one-per-machine daemon that owns the PTYs for all hosted sessions, multiplexes them (sessions addressable by name, tmux-style), and accepts attach/detach from frontends. A hosted agent session (a LiveAgent's Self, a ReadyAgent, etc.) runs *inside* a supervised PTY. **Headed** = a frontend is attached and rendering/driving the PTY; **headless** = the session runs with no frontend attached. The daemon's persistence is what lets a session keep running while unwatched and be re-attached "on a whim." *(M3b — hosts many M3a `spt-term` surfaces behind versioned IPC; `spt-term` itself is deliberately single-surface-per-handle.)*


*(M3b — hosts many M3a `spt-term` surfaces behind versioned IPC; `spt-term` itself is deliberately single-surface-per-handle.)*

*(M3b ✅ — `spt-daemon` hosts many M3a `spt-term` surfaces behind a versioned local IPC, split into a stable **broker** (holds the PTYs/children/sockets) + a restartable **brain** (all logic) so a brain swap leaves hosted sessions gapless; `spt-term` itself is deliberately single-surface-per-handle.)*