1	//! The broker kernel (B1 + B2) — the stable half of the broker/brain split.
2	//!
3	//! Per ADR-0004 §B the broker holds **only** the resources a live consumer
4	//! would lose continuity on if the logic restarted: the PTY master+writer and
5	//! the spawned child (both via [`spt_term::PtySession`]), and the accepted
6	//! client / listening sockets. It runs **no logic** — no routing, registry,
7	//! manifest parse, or lifecycle. It is a relay: spawn a child under a PTY,
8	//! forward its output to the brain, inject the brain's input, report exit.
9	//!
10	//! ## Gapless handoff (B2 — the load-bearing invariant)
11	//!
12	//! A brain may be killed and restarted at any moment (a routine self-update, a
13	//! crash). The broker — and every hosted child — must survive that untouched,
14	//! and the new brain must re-attach to the output stream **gaplessly and
15	//! exactly-once** (Spike #1 made real). Two mechanisms:
16	//!
17	//! - **Sequenced, buffered output.** Each session's output is appended to an
18	//!   [`OutputLog`]: every chunk gets a monotonic per-session `seq` and is held
19	//!   in a bounded ring. The current subscriber (a brain connection) receives
20	//!   chunks live; all subscriber writes happen *under the log lock*, so frames
21	//!   reach the brain in strict `seq` order with no interleave.
22	//! - **Resume from a cursor.** A reconnecting brain sends `subscribe` with its
23	//!   last-seen `seq + 1`; the broker replays the ring from there, then streams
24	//!   live. The brain dedups by `seq`. Output produced *while no brain was
25	//!   attached* sits in the ring and is replayed on the next subscribe — so a
26	//!   brain restart loses nothing.
27	//!
28	//! Sessions are therefore **broker-owned and outlive any one connection**: a
29	//! brain disconnect only detaches the subscriber; the child, its PTY, and the
30	//! output log persist. A session leaves the table only when its child exits
31	//! (reaped by the exit waiter) or is explicitly killed.
32	// [impl->REQ-DAEMON-2]
33
34	use std::collections::{HashMap, VecDeque};
35	use std::io;
36	use std::sync::atomic::{AtomicU64, Ordering};
37	use std::sync::{Arc, Mutex};
38	use std::thread::{self, JoinHandle};
39
40	use interprocess::local_socket::{prelude::*, SendHalf, Stream};
41	use spt_term::{Drain, PtySession, SessionSurface, SurfaceSize};
42
43	use crate::codec::{read_frame, write_frame};
44	use crate::effect::{EffectJournal, EffectKind, Outcome};
45	use crate::frame::{Envelope, Role};
46	use crate::msg::{
47	    applied_envelope, decode_bytes, output_envelope, ErrorEvent, ExitEvent, InputReq, KillReq,
48	    ResizeReq, SpawnReq, Spawned, SubscribeReq, KIND_ERROR, KIND_EXIT, KIND_INPUT, KIND_KILL,
49	    KIND_RESIZE, KIND_SPAWN, KIND_SPAWNED, KIND_SUBSCRIBE,
50	};
51	use crate::transport::{recv_hello, DaemonTransport, LocalSocketTransport};
52
53	/// The shared, serialized send side of one brain connection. Output frames,
54	/// replay frames, command acks, and exit/error events all write through this.
55	type SharedSend = Arc<Mutex<SendHalf>>;
56
57	/// Default bounded output-log depth (chunks). Large enough that a brain restart
58	/// window never evicts unreplayed output in practice; the bound is what keeps a
59	/// disconnected session from buffering without limit.
60	const DEFAULT_LOG_CHUNKS: usize = 4096;
61
62	/// A bounded, sequenced output log for one session — the gapless-resume buffer.
63	///
64	/// The session's single drain thread is the only producer ([`OutputLog::append`]);
65	/// a brain connection attaches as the subscriber ([`OutputLog::attach`]). Every
66	/// send to the subscriber happens while the enclosing `Mutex<OutputLog>` is
67	/// held, so the live stream and a replay can never interleave out of `seq`
68	/// order.
69	struct OutputLog {
70	    session_id: u64,
71	    next_seq: u64,
72	    ring: VecDeque<(u64, Vec<u8>)>,
73	    cap_chunks: usize,
74	    subscriber: Option<SharedSend>,
75	}
76
77	impl OutputLog {
78	    fn new(session_id: u64, cap_chunks: usize) -> Self {
79	        OutputLog {
80	            session_id,
81	            next_seq: 0,
82	            ring: VecDeque::new(),
83	            cap_chunks,
84	            subscriber: None,
85	        }
86	    }
87
88	    /// Append a freshly-produced output chunk: assign the next `seq`, retain it
89	    /// in the bounded ring, and live-send it to the current subscriber (if any).
90	    /// Called only from the session's drain thread.
91	    fn append(&mut self, bytes: &[u8]) {
92	        let seq = self.next_seq;
93	        self.next_seq += 1;
94	        self.ring.push_back((seq, bytes.to_vec()));
95	        while self.ring.len() > self.cap_chunks {
96	            self.ring.pop_front();
97	        }
98	        if let Some(sub) = &self.subscriber {
99	            let frame = output_envelope(self.session_id, seq, bytes);
100	            if let Ok(mut s) = sub.lock() {
101	                let _ = write_frame(&mut *s, &frame);
102	            }
103	        }
104	    }
105
106	    /// Attach `sub` as the subscriber and replay every retained chunk with
107	    /// `seq >= from_seq` before live streaming resumes. Replaces any prior
108	    /// subscriber (a dead brain).
109	    fn attach(&mut self, sub: SharedSend, from_seq: u64) {
110	        self.subscriber = Some(Arc::clone(&sub));
111	        for (seq, bytes) in self.ring.iter().filter(|(s, _)| *s >= from_seq) {
112	            let frame = output_envelope(self.session_id, *seq, bytes);
113	            if let Ok(mut s) = sub.lock() {
114	                let _ = write_frame(&mut *s, &frame);
115	            }
116	        }
117	    }
118
119	    /// Detach `sub` *iff* it is the current subscriber. A no-op if a newer brain
120	    /// has already taken over (so a late disconnect can't unsubscribe the live
121	    /// brain).
122	    fn detach_if(&mut self, sub: &SharedSend) {
123	        if let Some(cur) = &self.subscriber {
124	            if Arc::ptr_eq(cur, sub) {
125	                self.subscriber = None;
126	            }
127	        }
128	    }
129	}
130
131	/// A child the broker hosts under a PTY — the §B-owned resources for one
132	/// session, plus its gapless-resume output log.
133	struct HostedSession {
134	    /// The PTY + child (PTY master/writer + spawned child process).
135	    session: Arc<PtySession>,
136	    /// The output drain pump (kept so teardown can stop it).
137	    #[allow(dead_code)] // held for ownership/teardown; not read directly
138	    drain: Drain,
139	    /// The sequenced, buffered output + current subscriber.
140	    log: Arc<Mutex<OutputLog>>,
141	}
142
143	/// The broker process kernel. Owns the listening socket, the table of hosted
144	/// sessions, and the broker-owned idempotency anchor (B5); holds no logic state
145	/// (ADR-0004 §B). The [`EffectJournal`] is a §B resource — the durable
146	/// recovery anchor a brain crash must not lose, so it lives in the broker, not
147	/// the brain (Spike #6 constraint 1).
148	pub struct Broker {
149	    listener: LocalSocketTransport,
150	    sessions: Arc<Mutex<HashMap<u64, HostedSession>>>,
151	    next_id: AtomicU64,
152	    log_cap_chunks: usize,
153	    /// The exactly-once anchor for idempotent (`op_id`-carrying) effects.
154	    journal: Arc<EffectJournal>,
155	}
156
157	impl Broker {
158	    /// Bind the broker's listening endpoint at the namespaced `name`, with the
159	    /// effect journal at the default path (`<spt_home>/daemon-effects.log`).
160	    pub fn bind(name: &str) -> io::Result<Arc<Self>> {
161	        Self::bind_in(name, default_journal_path())
162	    }
163
164	    /// Bind with an explicit effect-journal path — the testable seam (tests point
165	    /// the broker-owned anchor at a temp file rather than the shared home).
166	    pub fn bind_in(name: &str, journal_path: std::path::PathBuf) -> io::Result<Arc<Self>> {
167	        Ok(Arc::new(Broker {
168	            listener: LocalSocketTransport::bind(name)?,
169	            sessions: Arc::new(Mutex::new(HashMap::new())),
170	            next_id: AtomicU64::new(1),
171	            log_cap_chunks: DEFAULT_LOG_CHUNKS,
172	            journal: Arc::new(EffectJournal::open(journal_path)?),
173	        }))
174	    }
175
176	    /// The broker-owned exactly-once anchor (for introspection / tests).
177	    pub fn journal(&self) -> &Arc<EffectJournal> {
178	        &self.journal
179	    }
180
181	    /// Accept connections forever, handling each on its own thread. The broker
182	    /// is the long-lived process; this is its main loop.
183	    pub fn serve(self: &Arc<Self>) -> io::Result<()> {
184	        loop {
185	            let conn = self.listener.accept()?;
186	            let me = Arc::clone(self);
187	            thread::spawn(move || {
188	                let _ = me.handle_conn(conn);
189	            });
190	        }
191	    }
192
193	    /// Accept exactly one connection and handle it on a spawned thread, returning
194	    /// that thread's handle. The single-brain entry point the tests exercise.
195	    pub fn serve_once(self: &Arc<Self>) -> io::Result<JoinHandle<()>> {
196	        let conn = self.listener.accept()?;
197	        let me = Arc::clone(self);
198	        Ok(thread::spawn(move || {
199	            let _ = me.handle_conn(conn);
200	        }))
201	    }
202
203	    /// Number of currently hosted sessions (for tests / introspection).
204	    pub fn session_count(&self) -> usize {
205	        self.sessions.lock().unwrap().len()
206	    }
207
208	    /// The child pid of a hosted session, if present (tests assert it is stable
209	    /// across brain restarts).
210	    pub fn session_pid(&self, id: u64) -> Option<u32> {
211	        self.sessions.lock().unwrap().get(&id).and_then(|h| h.session.process_id())
212	    }
213
214	    /// Drive one brain connection: handshake, then dispatch its command frames
215	    /// until it disconnects. On disconnect, detach this connection from any
216	    /// sessions it subscribed to — the sessions themselves persist (B2: a brain
217	    /// restart must not disturb the hosted children).
218	    fn handle_conn(self: &Arc<Self>, conn: Stream) -> io::Result<()> {
219	        let (mut recv, send) = conn.split();
220	        let send: SharedSend = Arc::new(Mutex::new(send));
221
222	        // Version handshake: this connection must be a brain (2.3).
223	        recv_hello(&mut recv, Role::Brain)?;
224
225	        let mut my_subs: Vec<u64> = Vec::new();
226
227	        loop {
228	            let env = match read_frame(&mut recv) {
229	                Ok(e) => e,
230	                // Any read failure (EOF included) ends the connection.
231	                Err(_) => break,
232	            };
233	            match env.kind.as_str() {
234	                KIND_SPAWN => match self.dispatch_spawn(env, &send) {
235	                    Ok(id) => my_subs.push(id),
236	                    Err(msg) => send_error(&send, &msg),
237	                },
238	                KIND_SUBSCRIBE => match self.dispatch_subscribe(env, &send) {
239	                    Ok(id) => my_subs.push(id),
240	                    Err(msg) => send_error(&send, &msg),
241	                },
242	                KIND_INPUT => {
243	                    if let Err(msg) = self.dispatch_input(env, &send) {
244	                        send_error(&send, &msg);
245	                    }
246	                }
247	                KIND_RESIZE => {
248	                    if let Err(msg) = self.dispatch_resize(env) {
249	                        send_error(&send, &msg);
250	                    }
251	                }
252	                KIND_KILL => {
253	                    if let Err(msg) = self.dispatch_kill(env) {
254	                        send_error(&send, &msg);
255	                    }
256	                }
257	                // An unknown kind is tolerated (forward-compat): report, don't die.
258	                other => send_error(&send, &format!("unknown command kind {other:?}")),
259	            }
260	        }
261
262	        // Brain gone: detach as subscriber but leave the sessions running.
263	        let sessions = self.sessions.lock().unwrap();
264	        for id in &my_subs {
265	            if let Some(h) = sessions.get(id) {
266	                h.log.lock().unwrap().detach_if(&send);
267	            }
268	        }
269	        Ok(())
270	    }
271
272	    /// Spawn a child under a fresh PTY, register it, auto-subscribe the spawning
273	    /// connection, start its output drain and exit waiter, and reply `spawned`.
274	    fn dispatch_spawn(&self, env: Envelope, send: &SharedSend) -> Result<u64, String> {
275	        let req: SpawnReq = serde_json::from_value(env.payload)
276	            .map_err(|e| format!("bad spawn payload: {e}"))?;
277	        let session = PtySession::spawn_program(
278	            &req.program,
279	            &req.args,
280	            SurfaceSize::new(req.rows, req.cols),
281	        )
282	        .map_err(|e| format!("spawn failed: {e}"))?;
283	        let session = Arc::new(session);
284	        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
285	        let pid = session.process_id();
286
287	        // The output log, with the spawning connection pre-attached so no chunk
288	        // is missed before an explicit subscribe.
289	        let log = Arc::new(Mutex::new(OutputLog::new(id, self.log_cap_chunks)));
290	        log.lock().unwrap().attach(Arc::clone(send), 0);
291
292	        // Output drain → appends to the log (which live-sends to the subscriber).
293	        let log_drain = Arc::clone(&log);
294	        let drain = session
295	            .drain(move |chunk| {
296	                log_drain.lock().unwrap().append(chunk);
297	            })
298	            .map_err(|e| format!("drain failed: {e}"))?;
299
300	        // Exit waiter → one `exit` frame when the child is reaped, then the dead
301	        // session is removed from the table. Runs on its own thread; `wait`
302	        // holds the child lock but `kill` uses a split killer.
303	        let sessions_exit = Arc::clone(&self.sessions);
304	        let log_exit = Arc::clone(&log);
305	        let waiter = Arc::clone(&session);
306	        thread::spawn(move || {
307	            if let Ok(status) = waiter.wait() {
308	                let ev = ExitEvent { session_id: id, code: Some(status.exit_code() as i32) };
309	                let frame = Envelope::new(
310	                    KIND_EXIT,
311	                    serde_json::to_value(ev).expect("ExitEvent serializes"),
312	                );
313	                // Send to whoever is currently subscribed (under the log lock).
314	                if let Some(sub) = log_exit.lock().unwrap().subscriber.clone() {
315	                    if let Ok(mut s) = sub.lock() {
316	                        let _ = write_frame(&mut *s, &frame);
317	                    }
318	                }
319	            }
320	            // Reap the dead session from the table.
321	            sessions_exit.lock().unwrap().remove(&id);
322	        });
323
324	        self.sessions.lock().unwrap().insert(id, HostedSession { session, drain, log });
325
326	        let resp = Spawned { session_id: id, pid };
327	        let frame =
328	            Envelope::new(KIND_SPAWNED, serde_json::to_value(resp).expect("Spawned serializes"));
329	        send_frame(send, &frame);
330	        Ok(id)
331	    }
332
333	    /// Re-attach a (handed-off) brain to a session's output, replaying from
334	    /// `from_seq` for gapless, exactly-once resume.
335	    fn dispatch_subscribe(&self, env: Envelope, send: &SharedSend) -> Result<u64, String> {
336	        let req: SubscribeReq = serde_json::from_value(env.payload)
337	            .map_err(|e| format!("bad subscribe payload: {e}"))?;
338	        // Clone the log Arc out from under the sessions lock, then replay under
339	        // the log lock — never hold both locks at once.
340	        let log = {
341	            let sessions = self.sessions.lock().unwrap();
342	            let h = sessions
343	                .get(&req.session_id)
344	                .ok_or_else(|| format!("no such session {}", req.session_id))?;
345	            Arc::clone(&h.log)
346	        };
347	        log.lock().unwrap().attach(Arc::clone(send), req.from_seq);
348	        Ok(req.session_id)
349	    }
350
351	    /// Inject input bytes into a session's PTY.
352	    ///
353	    /// With an `op_id` the write is routed through the broker-owned
354	    /// [`EffectJournal`] for exactly-once delivery: a replay of the same
355	    /// `(session_id, op_id)` after a brain crash is deduped (the PTY is not
356	    /// written twice), and either outcome — fresh apply or dedup — is acked back
357	    /// with an [`crate::msg::KIND_APPLIED`] frame so the brain can retire the
358	    /// effect (B5 / KNOWN-HAZARDS 7.2). Without an `op_id` it is the legacy
359	    /// direct write (no dedup, no ack).
360	    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
361	    fn dispatch_input(&self, env: Envelope, send: &SharedSend) -> Result<(), String> {
362	        let req: InputReq = serde_json::from_value(env.payload)
363	            .map_err(|e| format!("bad input payload: {e}"))?;
364	        let bytes = decode_bytes(&req.data_b64).map_err(|e| format!("bad input base64: {e}"))?;
365	        // Clone the session Arc out from under the sessions lock so the PTY write
366	        // + fsync happen without holding the table lock (mirrors dispatch_subscribe).
367	        let session = {
368	            let sessions = self.sessions.lock().unwrap();
369	            let h = sessions
370	                .get(&req.session_id)
371	                .ok_or_else(|| format!("no such session {}", req.session_id))?;
372	            Arc::clone(&h.session)
373	        };
374	        match req.op_id {
375	            Some(op) => {
376	                let key = (req.session_id, op);
377	                let outcome = self
378	                    .journal
379	                    .apply_once(key, EffectKind::PtyWrite, || {
380	                        session.write_input(&bytes).map_err(|e| io::Error::other(e.to_string()))
381	                    })
382	                    .map_err(|e| format!("write_input failed: {e}"))?;
383	                // Ack exactly-once: both Applied and Deduped mean "durably landed".
384	                send_frame(send, &applied_envelope(req.session_id, op, outcome == Outcome::Applied));
385	                Ok(())
386	            }
387	            None => session.write_input(&bytes).map_err(|e| format!("write_input failed: {e}")),
388	        }
389	    }
390
391	    /// Resize a session's PTY.
392	    fn dispatch_resize(&self, env: Envelope) -> Result<(), String> {
393	        let req: ResizeReq = serde_json::from_value(env.payload)
394	            .map_err(|e| format!("bad resize payload: {e}"))?;
395	        let sessions = self.sessions.lock().unwrap();
396	        let h = sessions
397	            .get(&req.session_id)
398	            .ok_or_else(|| format!("no such session {}", req.session_id))?;
399	        h.session
400	            .resize(SurfaceSize::new(req.rows, req.cols))
401	            .map_err(|e| format!("resize failed: {e}"))
402	    }
403
404	    /// Terminate a session's child. The exit waiter emits the `exit` frame and
405	    /// reaps the session once the child is dead.
406	    fn dispatch_kill(&self, env: Envelope) -> Result<(), String> {
407	        let req: KillReq = serde_json::from_value(env.payload)
408	            .map_err(|e| format!("bad kill payload: {e}"))?;
409	        let sessions = self.sessions.lock().unwrap();
410	        let h = sessions
411	            .get(&req.session_id)
412	            .ok_or_else(|| format!("no such session {}", req.session_id))?;
413	        h.session.kill().map_err(|e| format!("kill failed: {e}"))
414	    }
415	}
416
417	/// The default broker-owned effect-journal path: `<spt_home>/daemon-effects.log`.
418	fn default_journal_path() -> std::path::PathBuf {
419	    spt_store::perch::spt_home().join("daemon-effects.log")
420	}
421
422	/// Write a frame to the brain, ignoring a broken-pipe failure (brain gone).
423	fn send_frame(send: &SharedSend, frame: &Envelope) {
424	    if let Ok(mut s) = send.lock() {
425	        let _ = write_frame(&mut *s, frame);
426	    }
427	}
428
429	/// Send an `error` event to the brain (best-effort).
430	fn send_error(send: &SharedSend, message: &str) {
431	    let frame = Envelope::new(
432	        KIND_ERROR,
433	        serde_json::to_value(ErrorEvent { message: message.to_string() })
434	            .expect("ErrorEvent serializes"),
435	    );
436	    send_frame(send, &frame);
437	}
438

//! The broker kernel (B1 + B2) — the stable half of the broker/brain split.
//!
//! Per ADR-0004 §B the broker holds **only** the resources a live consumer
//! would lose continuity on if the logic restarted: the PTY master+writer and
//! the spawned child (both via [`spt_term::PtySession`]), and the accepted
//! client / listening sockets. It runs **no logic** — no routing, registry,
//! manifest parse, or lifecycle. It is a relay: spawn a child under a PTY,
//! forward its output to the brain, inject the brain's input, report exit.
//!
//! ## Gapless handoff (B2 — the load-bearing invariant)
//!
//! A brain may be killed and restarted at any moment (a routine self-update, a
//! crash). The broker — and every hosted child — must survive that untouched,
//! and the new brain must re-attach to the output stream **gaplessly and
//! exactly-once** (Spike #1 made real). Two mechanisms:
//!
//! - **Sequenced, buffered output.** Each session's output is appended to an
//!   [`OutputLog`]: every chunk gets a monotonic per-session `seq` and is held
//!   in a bounded ring. The current subscriber (a brain connection) receives
//!   chunks live; all subscriber writes happen *under the log lock*, so frames
//!   reach the brain in strict `seq` order with no interleave.
//! - **Resume from a cursor.** A reconnecting brain sends `subscribe` with its
//!   last-seen `seq + 1`; the broker replays the ring from there, then streams
//!   live. The brain dedups by `seq`. Output produced *while no brain was
//!   attached* sits in the ring and is replayed on the next subscribe — so a
//!   brain restart loses nothing.
//!
//! Sessions are therefore **broker-owned and outlive any one connection**: a
//! brain disconnect only detaches the subscriber; the child, its PTY, and the
//! output log persist. A session leaves the table only when its child exits
//! (reaped by the exit waiter) or is explicitly killed.
// [impl->REQ-DAEMON-2]

use std::collections::{HashMap, VecDeque};
use std::io;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};

use interprocess::local_socket::{prelude::*, SendHalf, Stream};
use spt_term::{Drain, PtySession, SessionSurface, SurfaceSize};

use crate::codec::{read_frame, write_frame};
use crate::effect::{EffectJournal, EffectKind, Outcome};
use crate::frame::{Envelope, Role};
use crate::msg::{
    applied_envelope, decode_bytes, output_envelope, ErrorEvent, ExitEvent, InputReq, KillReq,
    ResizeReq, SpawnReq, Spawned, SubscribeReq, KIND_ERROR, KIND_EXIT, KIND_INPUT, KIND_KILL,
    KIND_RESIZE, KIND_SPAWN, KIND_SPAWNED, KIND_SUBSCRIBE,
};
use crate::transport::{recv_hello, DaemonTransport, LocalSocketTransport};

/// The shared, serialized send side of one brain connection. Output frames,
/// replay frames, command acks, and exit/error events all write through this.
type SharedSend = Arc<Mutex<SendHalf>>;

/// Default bounded output-log depth (chunks). Large enough that a brain restart
/// window never evicts unreplayed output in practice; the bound is what keeps a
/// disconnected session from buffering without limit.
const DEFAULT_LOG_CHUNKS: usize = 4096;

/// A bounded, sequenced output log for one session — the gapless-resume buffer.
///
/// The session's single drain thread is the only producer ([`OutputLog::append`]);
/// a brain connection attaches as the subscriber ([`OutputLog::attach`]). Every
/// send to the subscriber happens while the enclosing `Mutex<OutputLog>` is
/// held, so the live stream and a replay can never interleave out of `seq`
/// order.
struct OutputLog {
    session_id: u64,
    next_seq: u64,
    ring: VecDeque<(u64, Vec<u8>)>,
    cap_chunks: usize,
    subscriber: Option<SharedSend>,
}

impl OutputLog {
    fn new(session_id: u64, cap_chunks: usize) -> Self {
        OutputLog {
            session_id,
            next_seq: 0,
            ring: VecDeque::new(),
            cap_chunks,
            subscriber: None,
        }
    }

    /// Append a freshly-produced output chunk: assign the next `seq`, retain it
    /// in the bounded ring, and live-send it to the current subscriber (if any).
    /// Called only from the session's drain thread.
    fn append(&mut self, bytes: &[u8]) {
        let seq = self.next_seq;
        self.next_seq += 1;
        self.ring.push_back((seq, bytes.to_vec()));
        while self.ring.len() > self.cap_chunks {
            self.ring.pop_front();
        }
        if let Some(sub) = &self.subscriber {
            let frame = output_envelope(self.session_id, seq, bytes);
            if let Ok(mut s) = sub.lock() {
                let _ = write_frame(&mut *s, &frame);
            }
        }
    }

    /// Attach `sub` as the subscriber and replay every retained chunk with
    /// `seq >= from_seq` before live streaming resumes. Replaces any prior
    /// subscriber (a dead brain).
    fn attach(&mut self, sub: SharedSend, from_seq: u64) {
        self.subscriber = Some(Arc::clone(&sub));
        for (seq, bytes) in self.ring.iter().filter(|(s, _)| *s >= from_seq) {
            let frame = output_envelope(self.session_id, *seq, bytes);
            if let Ok(mut s) = sub.lock() {
                let _ = write_frame(&mut *s, &frame);
            }
        }
    }

    /// Detach `sub` *iff* it is the current subscriber. A no-op if a newer brain
    /// has already taken over (so a late disconnect can't unsubscribe the live
    /// brain).
    fn detach_if(&mut self, sub: &SharedSend) {
        if let Some(cur) = &self.subscriber {
            if Arc::ptr_eq(cur, sub) {
                self.subscriber = None;
            }
        }
    }
}

/// A child the broker hosts under a PTY — the §B-owned resources for one
/// session, plus its gapless-resume output log.
struct HostedSession {
    /// The PTY + child (PTY master/writer + spawned child process).
    session: Arc<PtySession>,
    /// The output drain pump (kept so teardown can stop it).
    #[allow(dead_code)] // held for ownership/teardown; not read directly
    drain: Drain,
    /// The sequenced, buffered output + current subscriber.
    log: Arc<Mutex<OutputLog>>,
}

/// The broker process kernel. Owns the listening socket, the table of hosted
/// sessions, and the broker-owned idempotency anchor (B5); holds no logic state
/// (ADR-0004 §B). The [`EffectJournal`] is a §B resource — the durable
/// recovery anchor a brain crash must not lose, so it lives in the broker, not
/// the brain (Spike #6 constraint 1).
pub struct Broker {
    listener: LocalSocketTransport,
    sessions: Arc<Mutex<HashMap<u64, HostedSession>>>,
    next_id: AtomicU64,
    log_cap_chunks: usize,
    /// The exactly-once anchor for idempotent (`op_id`-carrying) effects.
    journal: Arc<EffectJournal>,
}

impl Broker {
    /// Bind the broker's listening endpoint at the namespaced `name`, with the
    /// effect journal at the default path (`<spt_home>/daemon-effects.log`).
    pub fn bind(name: &str) -> io::Result<Arc<Self>> {
        Self::bind_in(name, default_journal_path())
    }

    /// Bind with an explicit effect-journal path — the testable seam (tests point
    /// the broker-owned anchor at a temp file rather than the shared home).
    pub fn bind_in(name: &str, journal_path: std::path::PathBuf) -> io::Result<Arc<Self>> {
        Ok(Arc::new(Broker {
            listener: LocalSocketTransport::bind(name)?,
            sessions: Arc::new(Mutex::new(HashMap::new())),
            next_id: AtomicU64::new(1),
            log_cap_chunks: DEFAULT_LOG_CHUNKS,
            journal: Arc::new(EffectJournal::open(journal_path)?),
        }))
    }

    /// The broker-owned exactly-once anchor (for introspection / tests).
    pub fn journal(&self) -> &Arc<EffectJournal> {
        &self.journal
    }

    /// Accept connections forever, handling each on its own thread. The broker
    /// is the long-lived process; this is its main loop.
    pub fn serve(self: &Arc<Self>) -> io::Result<()> {
        loop {
            let conn = self.listener.accept()?;
            let me = Arc::clone(self);
            thread::spawn(move || {
                let _ = me.handle_conn(conn);
            });
        }
    }

    /// Accept exactly one connection and handle it on a spawned thread, returning
    /// that thread's handle. The single-brain entry point the tests exercise.
    pub fn serve_once(self: &Arc<Self>) -> io::Result<JoinHandle<()>> {
        let conn = self.listener.accept()?;
        let me = Arc::clone(self);
        Ok(thread::spawn(move || {
            let _ = me.handle_conn(conn);
        }))
    }

    /// Number of currently hosted sessions (for tests / introspection).
    pub fn session_count(&self) -> usize {
        self.sessions.lock().unwrap().len()
    }

    /// The child pid of a hosted session, if present (tests assert it is stable
    /// across brain restarts).
    pub fn session_pid(&self, id: u64) -> Option<u32> {
        self.sessions.lock().unwrap().get(&id).and_then(|h| h.session.process_id())
    }

    /// Drive one brain connection: handshake, then dispatch its command frames
    /// until it disconnects. On disconnect, detach this connection from any
    /// sessions it subscribed to — the sessions themselves persist (B2: a brain
    /// restart must not disturb the hosted children).
    fn handle_conn(self: &Arc<Self>, conn: Stream) -> io::Result<()> {
        let (mut recv, send) = conn.split();
        let send: SharedSend = Arc::new(Mutex::new(send));

        // Version handshake: this connection must be a brain (2.3).
        recv_hello(&mut recv, Role::Brain)?;

        let mut my_subs: Vec<u64> = Vec::new();

        loop {
            let env = match read_frame(&mut recv) {
                Ok(e) => e,
                // Any read failure (EOF included) ends the connection.
                Err(_) => break,
            };
            match env.kind.as_str() {
                KIND_SPAWN => match self.dispatch_spawn(env, &send) {
                    Ok(id) => my_subs.push(id),
                    Err(msg) => send_error(&send, &msg),
                },
                KIND_SUBSCRIBE => match self.dispatch_subscribe(env, &send) {
                    Ok(id) => my_subs.push(id),
                    Err(msg) => send_error(&send, &msg),
                },
                KIND_INPUT => {
                    if let Err(msg) = self.dispatch_input(env, &send) {
                        send_error(&send, &msg);
                    }
                }
                KIND_RESIZE => {
                    if let Err(msg) = self.dispatch_resize(env) {
                        send_error(&send, &msg);
                    }
                }
                KIND_KILL => {
                    if let Err(msg) = self.dispatch_kill(env) {
                        send_error(&send, &msg);
                    }
                }
                // An unknown kind is tolerated (forward-compat): report, don't die.
                other => send_error(&send, &format!("unknown command kind {other:?}")),
            }
        }

        // Brain gone: detach as subscriber but leave the sessions running.
        let sessions = self.sessions.lock().unwrap();
        for id in &my_subs {
            if let Some(h) = sessions.get(id) {
                h.log.lock().unwrap().detach_if(&send);
            }
        }
        Ok(())
    }

    /// Spawn a child under a fresh PTY, register it, auto-subscribe the spawning
    /// connection, start its output drain and exit waiter, and reply `spawned`.
    fn dispatch_spawn(&self, env: Envelope, send: &SharedSend) -> Result<u64, String> {
        let req: SpawnReq = serde_json::from_value(env.payload)
            .map_err(|e| format!("bad spawn payload: {e}"))?;
        let session = PtySession::spawn_program(
            &req.program,
            &req.args,
            SurfaceSize::new(req.rows, req.cols),
        )
        .map_err(|e| format!("spawn failed: {e}"))?;
        let session = Arc::new(session);
        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
        let pid = session.process_id();

        // The output log, with the spawning connection pre-attached so no chunk
        // is missed before an explicit subscribe.
        let log = Arc::new(Mutex::new(OutputLog::new(id, self.log_cap_chunks)));
        log.lock().unwrap().attach(Arc::clone(send), 0);

        // Output drain → appends to the log (which live-sends to the subscriber).
        let log_drain = Arc::clone(&log);
        let drain = session
            .drain(move |chunk| {
                log_drain.lock().unwrap().append(chunk);
            })
            .map_err(|e| format!("drain failed: {e}"))?;

        // Exit waiter → one `exit` frame when the child is reaped, then the dead
        // session is removed from the table. Runs on its own thread; `wait`
        // holds the child lock but `kill` uses a split killer.
        let sessions_exit = Arc::clone(&self.sessions);
        let log_exit = Arc::clone(&log);
        let waiter = Arc::clone(&session);
        thread::spawn(move || {
            if let Ok(status) = waiter.wait() {
                let ev = ExitEvent { session_id: id, code: Some(status.exit_code() as i32) };
                let frame = Envelope::new(
                    KIND_EXIT,
                    serde_json::to_value(ev).expect("ExitEvent serializes"),
                );
                // Send to whoever is currently subscribed (under the log lock).
                if let Some(sub) = log_exit.lock().unwrap().subscriber.clone() {
                    if let Ok(mut s) = sub.lock() {
                        let _ = write_frame(&mut *s, &frame);
                    }
                }
            }
            // Reap the dead session from the table.
            sessions_exit.lock().unwrap().remove(&id);
        });

        self.sessions.lock().unwrap().insert(id, HostedSession { session, drain, log });

        let resp = Spawned { session_id: id, pid };
        let frame =
            Envelope::new(KIND_SPAWNED, serde_json::to_value(resp).expect("Spawned serializes"));
        send_frame(send, &frame);
        Ok(id)
    }

    /// Re-attach a (handed-off) brain to a session's output, replaying from
    /// `from_seq` for gapless, exactly-once resume.
    fn dispatch_subscribe(&self, env: Envelope, send: &SharedSend) -> Result<u64, String> {
        let req: SubscribeReq = serde_json::from_value(env.payload)
            .map_err(|e| format!("bad subscribe payload: {e}"))?;
        // Clone the log Arc out from under the sessions lock, then replay under
        // the log lock — never hold both locks at once.
        let log = {
            let sessions = self.sessions.lock().unwrap();
            let h = sessions
                .get(&req.session_id)
                .ok_or_else(|| format!("no such session {}", req.session_id))?;
            Arc::clone(&h.log)
        };
        log.lock().unwrap().attach(Arc::clone(send), req.from_seq);
        Ok(req.session_id)
    }

    /// Inject input bytes into a session's PTY.
    ///
    /// With an `op_id` the write is routed through the broker-owned
    /// [`EffectJournal`] for exactly-once delivery: a replay of the same
    /// `(session_id, op_id)` after a brain crash is deduped (the PTY is not
    /// written twice), and either outcome — fresh apply or dedup — is acked back
    /// with an [`crate::msg::KIND_APPLIED`] frame so the brain can retire the
    /// effect (B5 / KNOWN-HAZARDS 7.2). Without an `op_id` it is the legacy
    /// direct write (no dedup, no ack).
    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
    fn dispatch_input(&self, env: Envelope, send: &SharedSend) -> Result<(), String> {
        let req: InputReq = serde_json::from_value(env.payload)
            .map_err(|e| format!("bad input payload: {e}"))?;
        let bytes = decode_bytes(&req.data_b64).map_err(|e| format!("bad input base64: {e}"))?;
        // Clone the session Arc out from under the sessions lock so the PTY write
        // + fsync happen without holding the table lock (mirrors dispatch_subscribe).
        let session = {
            let sessions = self.sessions.lock().unwrap();
            let h = sessions
                .get(&req.session_id)
                .ok_or_else(|| format!("no such session {}", req.session_id))?;
            Arc::clone(&h.session)
        };
        match req.op_id {
            Some(op) => {
                let key = (req.session_id, op);
                let outcome = self
                    .journal
                    .apply_once(key, EffectKind::PtyWrite, || {
                        session.write_input(&bytes).map_err(|e| io::Error::other(e.to_string()))
                    })
                    .map_err(|e| format!("write_input failed: {e}"))?;
                // Ack exactly-once: both Applied and Deduped mean "durably landed".
                send_frame(send, &applied_envelope(req.session_id, op, outcome == Outcome::Applied));
                Ok(())
            }
            None => session.write_input(&bytes).map_err(|e| format!("write_input failed: {e}")),
        }
    }

    /// Resize a session's PTY.
    fn dispatch_resize(&self, env: Envelope) -> Result<(), String> {
        let req: ResizeReq = serde_json::from_value(env.payload)
            .map_err(|e| format!("bad resize payload: {e}"))?;
        let sessions = self.sessions.lock().unwrap();
        let h = sessions
            .get(&req.session_id)
            .ok_or_else(|| format!("no such session {}", req.session_id))?;
        h.session
            .resize(SurfaceSize::new(req.rows, req.cols))
            .map_err(|e| format!("resize failed: {e}"))
    }

    /// Terminate a session's child. The exit waiter emits the `exit` frame and
    /// reaps the session once the child is dead.
    fn dispatch_kill(&self, env: Envelope) -> Result<(), String> {
        let req: KillReq = serde_json::from_value(env.payload)
            .map_err(|e| format!("bad kill payload: {e}"))?;
        let sessions = self.sessions.lock().unwrap();
        let h = sessions
            .get(&req.session_id)
            .ok_or_else(|| format!("no such session {}", req.session_id))?;
        h.session.kill().map_err(|e| format!("kill failed: {e}"))
    }
}

/// The default broker-owned effect-journal path: `<spt_home>/daemon-effects.log`.
fn default_journal_path() -> std::path::PathBuf {
    spt_store::perch::spt_home().join("daemon-effects.log")
}

/// Write a frame to the brain, ignoring a broken-pipe failure (brain gone).
fn send_frame(send: &SharedSend, frame: &Envelope) {
    if let Ok(mut s) = send.lock() {
        let _ = write_frame(&mut *s, frame);
    }
}

/// Send an `error` event to the brain (best-effort).
fn send_error(send: &SharedSend, message: &str) {
    let frame = Envelope::new(
        KIND_ERROR,
        serde_json::to_value(ErrorEvent { message: message.to_string() })
            .expect("ErrorEvent serializes"),
    );
    send_frame(send, &frame);
}


NOTE fROM THE USER: proceed with next steps.


1	//! The broker↔brain message catalog: the kind discriminators and payload
2	//! shapes that ride inside the forward-compatible [`Envelope`].
3	//!
4	//! Split out from [`crate::frame`] (which owns the *envelope mechanics* and the
5	//! version handshake) so the message *vocabulary* can grow additively without
6	//! touching the wire-compat machinery. Every payload is a plain serde struct
7	//! with serde's default unknown-field tolerance, so a newer brain may add a
8	//! field and an older broker still decodes the message (KNOWN-HAZARDS 2.3).
9	//!
10	//! Bytes (PTY input/output) ride as base64 strings — JSON has no native byte
11	//! type, and base64 keeps the single envelope/codec path rather than forking a
12	//! binary frame format.
13	// [impl->REQ-DAEMON-2]
14
15	use base64::engine::general_purpose::STANDARD as B64;
16	use base64::Engine;
17	use serde::{Deserialize, Serialize};
18
19	use crate::frame::Envelope;
20
21	/// Brain→broker: spawn a child under a fresh broker-owned PTY.
22	pub const KIND_SPAWN: &str = "spawn";
23	/// Broker→brain: a spawn succeeded; carries the assigned session id + child pid.
24	pub const KIND_SPAWNED: &str = "spawned";
25	/// Brain→broker: (re)subscribe to a session's output from a given sequence.
26	pub const KIND_SUBSCRIBE: &str = "subscribe";
27	/// Brain→broker: write raw bytes to a session's input (send-keys).
28	pub const KIND_INPUT: &str = "input";
29	/// Brain→broker: resize a session's PTY.
30	pub const KIND_RESIZE: &str = "resize";
31	/// Brain→broker: terminate a session's child.
32	pub const KIND_KILL: &str = "kill";
33	/// Broker→brain: a chunk of a session's output (pushed as the child produces it).
34	pub const KIND_OUTPUT: &str = "output";
35	/// Broker→brain: a session's child exited.
36	pub const KIND_EXIT: &str = "exit";
37	/// Broker→brain: an idempotent effect (an `input` carrying an `op_id`) is durably
38	/// applied **or** was already applied (deduped). The exactly-once ack: a brain
39	/// learns its effect landed, and a replay after a crash is acknowledged the same
40	/// way so the brain can retire it (B5 / KNOWN-HAZARDS 7.2).
41	pub const KIND_APPLIED: &str = "applied";
42	/// Broker→brain: a command failed (carries an operator-facing message).
43	pub const KIND_ERROR: &str = "error";
44
45	/// `spawn` payload — the program + args + initial geometry. The broker spawns
46	/// it under a PTY (no shell interpretation; argv is passed through).
47	#[derive(Debug, Clone, Serialize, Deserialize)]
48	pub struct SpawnReq {
49	    /// The program to exec (resolved on `PATH` by the OS spawn).
50	    pub program: String,
51	    /// Its arguments, verbatim.
52	    #[serde(default)]
53	    pub args: Vec<String>,
54	    /// Initial PTY rows.
55	    pub rows: u16,
56	    /// Initial PTY cols.
57	    pub cols: u16,
58	}
59
60	/// `spawned` payload — the broker-minted session id + the child's OS pid.
61	#[derive(Debug, Clone, Serialize, Deserialize)]
62	pub struct Spawned {
63	    /// The id the brain uses to address this session in later commands.
64	    pub session_id: u64,
65	    /// The child's OS pid, if the backend exposes one.
66	    #[serde(default)]
67	    pub pid: Option<u32>,
68	}
69
70	/// `subscribe` payload — attach this connection as a session's output
71	/// subscriber, replaying buffered chunks with `seq >= from_seq` before
72	/// streaming live ones. A reconnecting (handed-off) brain passes its
73	/// last-seen `seq + 1` here for gapless, exactly-once resume (2.3 / Spike #1).
74	#[derive(Debug, Clone, Serialize, Deserialize)]
75	pub struct SubscribeReq {
76	    /// Target session.
77	    pub session_id: u64,
78	    /// Lowest output sequence the subscriber still needs (replay floor).
79	    pub from_seq: u64,
80	}
81
82	/// `input` payload — bytes to inject into a session's stdin (base64).
83	#[derive(Debug, Clone, Serialize, Deserialize)]
84	pub struct InputReq {
85	    /// Target session.
86	    pub session_id: u64,
87	    /// The raw input bytes, base64-encoded.
88	    pub data_b64: String,
89	    /// The durable op id for exactly-once delivery (B5). `Some(id)` routes the
90	    /// write through the broker's [`crate::effect::EffectJournal`]: a replay of
91	    /// the same `(session_id, id)` after a brain crash is deduped, and the broker
92	    /// replies [`KIND_APPLIED`]. `None` is the legacy direct write (no dedup) —
93	    /// kept for callers that don't carry a durable source id. Defaulted so an
94	    /// older brain's `input` (no `op_id`) still decodes (2.3 forward-compat).
95	    #[serde(default, skip_serializing_if = "Option::is_none")]
96	    pub op_id: Option<u64>,
97	}
98
99	/// `resize` payload.
100	#[derive(Debug, Clone, Serialize, Deserialize)]
101	pub struct ResizeReq {
102	    /// Target session.
103	    pub session_id: u64,
104	    /// New rows.
105	    pub rows: u16,
106	    /// New cols.
107	    pub cols: u16,
108	}
109
110	/// `kill` payload.
111	#[derive(Debug, Clone, Serialize, Deserialize)]
112	pub struct KillReq {
113	    /// Target session.
114	    pub session_id: u64,
115	}
116
117	/// `output` event — a chunk of a session's PTY output (base64).
118	#[derive(Debug, Clone, Serialize, Deserialize)]
119	pub struct OutputEvent {
120	    /// Originating session.
121	    pub session_id: u64,
122	    /// The broker-assigned, per-session monotonic sequence of this chunk. The
123	    /// resume cursor: a reconnecting brain subscribes from its last seen
124	    /// `seq + 1`, and dedups by ignoring any `seq` it has already processed.
125	    pub seq: u64,
126	    /// The raw output bytes, base64-encoded.
127	    pub data_b64: String,
128	}
129
130	/// `exit` event — a session's child has been reaped.
131	#[derive(Debug, Clone, Serialize, Deserialize)]
132	pub struct ExitEvent {
133	    /// Originating session.
134	    pub session_id: u64,
135	    /// The child's exit code, if known.
136	    #[serde(default)]
137	    pub code: Option<i32>,
138	}
139
140	/// `applied` event — an idempotent effect is durably applied or was already
141	/// applied. Carries the durable id so the brain can correlate it to the effect
142	/// it issued (or replayed). The exactly-once ack (B5).
143	#[derive(Debug, Clone, Serialize, Deserialize)]
144	pub struct AppliedEvent {
145	    /// The session the effect targeted.
146	    pub session_id: u64,
147	    /// The durable op id of the effect.
148	    pub op_id: u64,
149	    /// Whether the effect ran on this delivery (`true`) or was deduped because it
150	    /// had already landed (`false`). Informational — both mean "durably applied".
151	    #[serde(default)]
152	    pub applied_now: bool,
153	}
154
155	/// `error` event — a command the broker could not satisfy.
156	#[derive(Debug, Clone, Serialize, Deserialize)]
157	pub struct ErrorEvent {
158	    /// Operator-facing description.
159	    pub message: String,
160	}
161
162	/// Encode raw bytes for an `input`/`output` payload.
163	pub fn encode_bytes(bytes: &[u8]) -> String {
164	    B64.encode(bytes)
165	}
166
167	/// Decode an `input`/`output` payload's base64 back to raw bytes.
168	pub fn decode_bytes(s: &str) -> Result<Vec<u8>, base64::DecodeError> {
169	    B64.decode(s)
170	}
171
172	/// Build an output event envelope for `session_id` / `seq` carrying `bytes`.
173	pub fn output_envelope(session_id: u64, seq: u64, bytes: &[u8]) -> Envelope {
174	    Envelope::new(
175	        KIND_OUTPUT,
176	        serde_json::to_value(OutputEvent { session_id, seq, data_b64: encode_bytes(bytes) })
177	            .expect("OutputEvent serializes"),
178	    )
179	}
180
181	/// Build an `applied` ack envelope for `session_id` / `op_id`; `applied_now`
182	/// distinguishes a fresh apply from a deduped replay.
183	pub fn applied_envelope(session_id: u64, op_id: u64, applied_now: bool) -> Envelope {
184	    Envelope::new(
185	        KIND_APPLIED,
186	        serde_json::to_value(AppliedEvent { session_id, op_id, applied_now })
187	            .expect("AppliedEvent serializes"),
188	    )
189	}
190
191	#[cfg(test)]
192	mod tests {
193	    use super::*;
194	    use serde_json::json;
195
196	    // [unit->REQ-DAEMON-2] bytes round-trip through the base64 payload encoding.
197	    #[test]
198	    fn bytes_round_trip_through_payload() {
199	        let raw = b"\x1b[6n binary\x00\xff bytes";
200	        let enc = encode_bytes(raw);
201	        assert_eq!(decode_bytes(&enc).unwrap(), raw);
202	    }
203
204	    // [unit->REQ-DAEMON-2] a spawn request decodes; a newer brain's extra field
205	    // is tolerated (forward-compat carries into the message catalog, not just
206	    // the envelope).
207	    #[test]
208	    fn spawn_req_tolerates_unknown_field() {
209	        let wire = json!({
210	            "program": "cat",
211	            "args": [],
212	            "rows": 24,
213	            "cols": 80,
214	            "env_added_in_a_future_release": { "TERM": "xterm" }
215	        });
216	        let req: SpawnReq = serde_json::from_value(wire).unwrap();
217	        assert_eq!(req.program, "cat");
218	        assert_eq!(req.rows, 24);
219	    }
220
221	    // [unit->REQ-DAEMON-2] the output-envelope helper produces a decodable event.
222	    #[test]
223	    fn output_envelope_round_trips() {
224	        let env = output_envelope(7, 42, b"hello");
225	        assert_eq!(env.kind, KIND_OUTPUT);
226	        let ev: OutputEvent = serde_json::from_value(env.payload).unwrap();
227	        assert_eq!(ev.session_id, 7);
228	        assert_eq!(ev.seq, 42);
229	        assert_eq!(decode_bytes(&ev.data_b64).unwrap(), b"hello");
230	    }
231	}
232

//! The broker↔brain message catalog: the kind discriminators and payload
//! shapes that ride inside the forward-compatible [`Envelope`].
//!
//! Split out from [`crate::frame`] (which owns the *envelope mechanics* and the
//! version handshake) so the message *vocabulary* can grow additively without
//! touching the wire-compat machinery. Every payload is a plain serde struct
//! with serde's default unknown-field tolerance, so a newer brain may add a
//! field and an older broker still decodes the message (KNOWN-HAZARDS 2.3).
//!
//! Bytes (PTY input/output) ride as base64 strings — JSON has no native byte
//! type, and base64 keeps the single envelope/codec path rather than forking a
//! binary frame format.
// [impl->REQ-DAEMON-2]

use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine;
use serde::{Deserialize, Serialize};

use crate::frame::Envelope;

/// Brain→broker: spawn a child under a fresh broker-owned PTY.
pub const KIND_SPAWN: &str = "spawn";
/// Broker→brain: a spawn succeeded; carries the assigned session id + child pid.
pub const KIND_SPAWNED: &str = "spawned";
/// Brain→broker: (re)subscribe to a session's output from a given sequence.
pub const KIND_SUBSCRIBE: &str = "subscribe";
/// Brain→broker: write raw bytes to a session's input (send-keys).
pub const KIND_INPUT: &str = "input";
/// Brain→broker: resize a session's PTY.
pub const KIND_RESIZE: &str = "resize";
/// Brain→broker: terminate a session's child.
pub const KIND_KILL: &str = "kill";
/// Broker→brain: a chunk of a session's output (pushed as the child produces it).
pub const KIND_OUTPUT: &str = "output";
/// Broker→brain: a session's child exited.
pub const KIND_EXIT: &str = "exit";
/// Broker→brain: an idempotent effect (an `input` carrying an `op_id`) is durably
/// applied **or** was already applied (deduped). The exactly-once ack: a brain
/// learns its effect landed, and a replay after a crash is acknowledged the same
/// way so the brain can retire it (B5 / KNOWN-HAZARDS 7.2).
pub const KIND_APPLIED: &str = "applied";
/// Broker→brain: a command failed (carries an operator-facing message).
pub const KIND_ERROR: &str = "error";

/// `spawn` payload — the program + args + initial geometry. The broker spawns
/// it under a PTY (no shell interpretation; argv is passed through).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpawnReq {
    /// The program to exec (resolved on `PATH` by the OS spawn).
    pub program: String,
    /// Its arguments, verbatim.
    #[serde(default)]
    pub args: Vec<String>,
    /// Initial PTY rows.
    pub rows: u16,
    /// Initial PTY cols.
    pub cols: u16,
}

/// `spawned` payload — the broker-minted session id + the child's OS pid.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Spawned {
    /// The id the brain uses to address this session in later commands.
    pub session_id: u64,
    /// The child's OS pid, if the backend exposes one.
    #[serde(default)]
    pub pid: Option<u32>,
}

/// `subscribe` payload — attach this connection as a session's output
/// subscriber, replaying buffered chunks with `seq >= from_seq` before
/// streaming live ones. A reconnecting (handed-off) brain passes its
/// last-seen `seq + 1` here for gapless, exactly-once resume (2.3 / Spike #1).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SubscribeReq {
    /// Target session.
    pub session_id: u64,
    /// Lowest output sequence the subscriber still needs (replay floor).
    pub from_seq: u64,
}

/// `input` payload — bytes to inject into a session's stdin (base64).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InputReq {
    /// Target session.
    pub session_id: u64,
    /// The raw input bytes, base64-encoded.
    pub data_b64: String,
    /// The durable op id for exactly-once delivery (B5). `Some(id)` routes the
    /// write through the broker's [`crate::effect::EffectJournal`]: a replay of
    /// the same `(session_id, id)` after a brain crash is deduped, and the broker
    /// replies [`KIND_APPLIED`]. `None` is the legacy direct write (no dedup) —
    /// kept for callers that don't carry a durable source id. Defaulted so an
    /// older brain's `input` (no `op_id`) still decodes (2.3 forward-compat).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub op_id: Option<u64>,
}

/// `resize` payload.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResizeReq {
    /// Target session.
    pub session_id: u64,
    /// New rows.
    pub rows: u16,
    /// New cols.
    pub cols: u16,
}

/// `kill` payload.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KillReq {
    /// Target session.
    pub session_id: u64,
}

/// `output` event — a chunk of a session's PTY output (base64).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OutputEvent {
    /// Originating session.
    pub session_id: u64,
    /// The broker-assigned, per-session monotonic sequence of this chunk. The
    /// resume cursor: a reconnecting brain subscribes from its last seen
    /// `seq + 1`, and dedups by ignoring any `seq` it has already processed.
    pub seq: u64,
    /// The raw output bytes, base64-encoded.
    pub data_b64: String,
}

/// `exit` event — a session's child has been reaped.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExitEvent {
    /// Originating session.
    pub session_id: u64,
    /// The child's exit code, if known.
    #[serde(default)]
    pub code: Option<i32>,
}

/// `applied` event — an idempotent effect is durably applied or was already
/// applied. Carries the durable id so the brain can correlate it to the effect
/// it issued (or replayed). The exactly-once ack (B5).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppliedEvent {
    /// The session the effect targeted.
    pub session_id: u64,
    /// The durable op id of the effect.
    pub op_id: u64,
    /// Whether the effect ran on this delivery (`true`) or was deduped because it
    /// had already landed (`false`). Informational — both mean "durably applied".
    #[serde(default)]
    pub applied_now: bool,
}

/// `error` event — a command the broker could not satisfy.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ErrorEvent {
    /// Operator-facing description.
    pub message: String,
}

/// Encode raw bytes for an `input`/`output` payload.
pub fn encode_bytes(bytes: &[u8]) -> String {
    B64.encode(bytes)
}

/// Decode an `input`/`output` payload's base64 back to raw bytes.
pub fn decode_bytes(s: &str) -> Result<Vec<u8>, base64::DecodeError> {
    B64.decode(s)
}

/// Build an output event envelope for `session_id` / `seq` carrying `bytes`.
pub fn output_envelope(session_id: u64, seq: u64, bytes: &[u8]) -> Envelope {
    Envelope::new(
        KIND_OUTPUT,
        serde_json::to_value(OutputEvent { session_id, seq, data_b64: encode_bytes(bytes) })
            .expect("OutputEvent serializes"),
    )
}

/// Build an `applied` ack envelope for `session_id` / `op_id`; `applied_now`
/// distinguishes a fresh apply from a deduped replay.
pub fn applied_envelope(session_id: u64, op_id: u64, applied_now: bool) -> Envelope {
    Envelope::new(
        KIND_APPLIED,
        serde_json::to_value(AppliedEvent { session_id, op_id, applied_now })
            .expect("AppliedEvent serializes"),
    )
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    // [unit->REQ-DAEMON-2] bytes round-trip through the base64 payload encoding.
    #[test]
    fn bytes_round_trip_through_payload() {
        let raw = b"\x1b[6n binary\x00\xff bytes";
        let enc = encode_bytes(raw);
        assert_eq!(decode_bytes(&enc).unwrap(), raw);
    }

    // [unit->REQ-DAEMON-2] a spawn request decodes; a newer brain's extra field
    // is tolerated (forward-compat carries into the message catalog, not just
    // the envelope).
    #[test]
    fn spawn_req_tolerates_unknown_field() {
        let wire = json!({
            "program": "cat",
            "args": [],
            "rows": 24,
            "cols": 80,
            "env_added_in_a_future_release": { "TERM": "xterm" }
        });
        let req: SpawnReq = serde_json::from_value(wire).unwrap();
        assert_eq!(req.program, "cat");
        assert_eq!(req.rows, 24);
    }

    // [unit->REQ-DAEMON-2] the output-envelope helper produces a decodable event.
    #[test]
    fn output_envelope_round_trips() {
        let env = output_envelope(7, 42, b"hello");
        assert_eq!(env.kind, KIND_OUTPUT);
        let ev: OutputEvent = serde_json::from_value(env.payload).unwrap();
        assert_eq!(ev.session_id, 7);
        assert_eq!(ev.seq, 42);
        assert_eq!(decode_bytes(&ev.data_b64).unwrap(), b"hello");
    }
}


Now read daemon.rs, config.rs, lib.rs, and the manifest/runtime seam. Also the `spt` CLI structure.


1	//! The daemon process entrypoint + auto-start (B6).
2	//!
3	//! [`Daemon::run`] is the body of the hidden `spt daemon` subcommand: it binds
4	//! the broker (B1/B2 PTY hosting) and the seed-control channel (B6) under the
5	//! canonical per-home names ([`crate::endpoint`]) and serves both for the life
6	//! of the process.
7	//!
8	//! [`ensure_running`] is the auto-start seam (REQ-DAEMON-3): any `api`
9	//! invocation calls it; if no daemon answers the seed-control ping it spawns
10	//! `spt daemon` detached and waits (bounded) for it to come up. The reliable
11	//! anchor is the seed channel — bound for the whole daemon lifetime, so a ping
12	//! against it is a true liveness test (`listen` is the interim model's anchor;
13	//! here the seed channel plays that role for cold-start detection).
14	// [impl->REQ-DAEMON-3]
15
16	use std::io;
17	use std::process::{Command, Stdio};
18	use std::sync::Arc;
19	use std::thread;
20	use std::time::Duration;
21
22	use crate::broker::Broker;
23	use crate::endpoint::{broker_socket_name, daemon_pid_path, seed_socket_name};
24	use crate::seedmap::{self, SeedRegistry};
25
26	/// How long auto-start waits for a freshly-spawned daemon to bind its channel.
27	const START_TIMEOUT: Duration = Duration::from_secs(5);
28	/// Poll cadence while waiting for the spawned daemon to come up.
29	const START_POLL: Duration = Duration::from_millis(25);
30
31	/// The per-machine broker/brain daemon process.
32	pub struct Daemon;
33
34	impl Daemon {
35	    /// Run the daemon: bind the broker (served on a background thread) + the
36	    /// seed-control channel (the foreground loop + liveness anchor) and serve
37	    /// until the process is killed. The body of `spt daemon`.
38	    pub fn run() -> io::Result<()> {
39	        let reg = Arc::new(SeedRegistry::new());
40
41	        // The broker hosts PTYs (B1/B2). Serve it on a background thread; a bind
42	        // failure there must not stop the seed channel (the B6 surface) — the
43	        // PTY-hosting consumers arrive in B7/B8.
44	        match Broker::bind(&broker_socket_name()) {
45	            Ok(broker) => {
46	                thread::spawn(move || {
47	                    let _ = broker.serve();
48	                });
49	            }
50	            Err(e) => eprintln!("BROKER_BIND_FAIL: {e}"),
51	        }
52
53	        // Best-effort breadcrumb: record our pid for management/cleanup. Liveness
54	        // is the socket ping, never this file, so a stale value is harmless.
55	        let _ = std::fs::write(daemon_pid_path(), std::process::id().to_string());
56
57	        // The seed-control channel is the foreground loop. Binding it is what
58	        // arbitrates the single-daemon-per-home invariant: a second daemon racing
59	        // to start fails this bind and exits, leaving the winner serving.
60	        seedmap::serve_seed_control(&seed_socket_name(), reg)
61	    }
62	}
63
64	/// Whether a daemon is already serving the seed-control channel for this home.
65	pub fn is_running() -> bool {
66	    seedmap::ping(&seed_socket_name()).is_ok()
67	}
68
69	/// Auto-start (REQ-DAEMON-3): ensure a daemon is up for this home. A no-op if one
70	/// already answers; otherwise spawn `spt daemon` detached and wait (bounded) for
71	/// it to bind. Idempotent + race-tolerant: if two callers spawn at once, one
72	/// wins the bind and the other exits — and this check passes either way.
73	pub fn ensure_running() -> io::Result<()> {
74	    if is_running() {
75	        return Ok(());
76	    }
77	    spawn_detached()?;
78	    let mut waited = Duration::ZERO;
79	    while waited < START_TIMEOUT {
80	        if is_running() {
81	            return Ok(());
82	        }
83	        thread::sleep(START_POLL);
84	        waited += START_POLL;
85	    }
86	    Err(io::Error::new(
87	        io::ErrorKind::TimedOut,
88	        "daemon did not come up within the start window",
89	    ))
90	}
91
92	/// Spawn `spt daemon` as a detached background process. Inherits the environment
93	/// — so the child resolves the **same** `SPT_HOME`, hence the same canonical
94	/// socket names — but drops the std handles so it outlives the launching CLI
95	/// call. The child is independent (not killed when the parent exits): on unix it
96	/// is reparented to init when the launcher exits; on Windows a child is not in
97	/// the parent's job by default.
98	///
99	/// **Windows handle-inheritance guard.** A caller may capture this process's
100	/// stdout/stderr through a pipe (`Command::output()`, a harness hook reading the
101	/// command's output). On Windows `CreateProcess` is invoked with
102	/// `bInheritHandles = TRUE`, so the long-lived daemon would inherit that pipe's
103	/// write end and hold it open forever — the capturing reader never sees EOF and
104	/// hangs. Unix is immune (Rust marks pipe fds `CLOEXEC`). So before spawning we
105	/// strip `HANDLE_FLAG_INHERIT` from this process's std handles; the daemon then
106	/// cannot inherit them, the pipe closes when *this* process exits, and the
107	/// caller's read completes. Stripping the flag does not affect this process's own
108	/// use of its std handles — only what a child inherits.
109	fn spawn_detached() -> io::Result<()> {
110	    #[cfg(windows)]
111	    block_std_handle_inheritance();
112	    let exe = std::env::current_exe()?;
113	    let mut cmd = Command::new(exe);
114	    cmd.arg("daemon")
115	        .stdin(Stdio::null())
116	        .stdout(Stdio::null())
117	        .stderr(Stdio::null());
118	    #[cfg(windows)]
119	    {
120	        use std::os::windows::process::CommandExt;
121	        // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW: no
122	        // inherited console, no popup window, its own signal group.
123	        cmd.creation_flags(0x0000_0008 | 0x0000_0200 | 0x0800_0000);
124	    }
125	    cmd.spawn()?;
126	    Ok(())
127	}
128
129	/// Clear `HANDLE_FLAG_INHERIT` on this process's std handles so a spawned child
130	/// (the daemon) cannot inherit a pipe a caller is capturing. Declared inline to
131	/// avoid a winapi dependency for these two calls.
132	#[cfg(windows)]
133	fn block_std_handle_inheritance() {
134	    extern "system" {
135	        fn GetStdHandle(nStdHandle: u32) -> isize;
136	        fn SetHandleInformation(hObject: isize, dwMask: u32, dwFlags: u32) -> i32;
137	    }
138	    const STD_INPUT_HANDLE: u32 = 0xFFFF_FFF6; // -10
139	    const STD_OUTPUT_HANDLE: u32 = 0xFFFF_FFF5; // -11
140	    const STD_ERROR_HANDLE: u32 = 0xFFFF_FFF4; // -12
141	    const HANDLE_FLAG_INHERIT: u32 = 0x1;
142	    const INVALID: isize = -1;
143	    unsafe {
144	        for which in [STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE] {
145	            let h = GetStdHandle(which);
146	            if h != 0 && h != INVALID {
147	                // Best-effort: a console handle that rejects the call is harmless.
148	                SetHandleInformation(h, HANDLE_FLAG_INHERIT, 0);
149	            }
150	        }
151	    }
152	}
153
154	#[cfg(test)]
155	mod tests {
156	    use super::*;
157	    use crate::seedmap::{serve_seed_control, SeedRegistry};
158	    use crate::test_home::with_home;
159
160	    // [unit->REQ-DAEMON-3] with no daemon up, is_running() is false; once a
161	    // seed-control server answers, it flips true and ensure_running() is a no-op
162	    // (the spawn branch — which would need the real `spt` binary — is not taken).
163	    #[test]
164	    fn is_running_tracks_the_seed_channel() {
165	        with_home(|_| {
166	            assert!(!is_running(), "no daemon bound yet");
167
168	            let reg = Arc::new(SeedRegistry::new());
169	            let name = seed_socket_name();
170	            let srv = Arc::clone(&reg);
171	            thread::spawn(move || {
172	                let _ = serve_seed_control(&name, srv);
173	            });
174	            for _ in 0..400 {
175	                if is_running() {
176	                    break;
177	                }
178	                thread::sleep(Duration::from_millis(5));
179	            }
180	            assert!(is_running(), "the seed channel answers → daemon is up");
181	            // ensure_running short-circuits (no real process spawn).
182	            ensure_running().expect("ensure is a no-op when already up");
183	        });
184	    }
185	}
186

//! The daemon process entrypoint + auto-start (B6).
//!
//! [`Daemon::run`] is the body of the hidden `spt daemon` subcommand: it binds
//! the broker (B1/B2 PTY hosting) and the seed-control channel (B6) under the
//! canonical per-home names ([`crate::endpoint`]) and serves both for the life
//! of the process.
//!
//! [`ensure_running`] is the auto-start seam (REQ-DAEMON-3): any `api`
//! invocation calls it; if no daemon answers the seed-control ping it spawns
//! `spt daemon` detached and waits (bounded) for it to come up. The reliable
//! anchor is the seed channel — bound for the whole daemon lifetime, so a ping
//! against it is a true liveness test (`listen` is the interim model's anchor;
//! here the seed channel plays that role for cold-start detection).
// [impl->REQ-DAEMON-3]

use std::io;
use std::process::{Command, Stdio};
use std::sync::Arc;
use std::thread;
use std::time::Duration;

use crate::broker::Broker;
use crate::endpoint::{broker_socket_name, daemon_pid_path, seed_socket_name};
use crate::seedmap::{self, SeedRegistry};

/// How long auto-start waits for a freshly-spawned daemon to bind its channel.
const START_TIMEOUT: Duration = Duration::from_secs(5);
/// Poll cadence while waiting for the spawned daemon to come up.
const START_POLL: Duration = Duration::from_millis(25);

/// The per-machine broker/brain daemon process.
pub struct Daemon;

impl Daemon {
    /// Run the daemon: bind the broker (served on a background thread) + the
    /// seed-control channel (the foreground loop + liveness anchor) and serve
    /// until the process is killed. The body of `spt daemon`.
    pub fn run() -> io::Result<()> {
        let reg = Arc::new(SeedRegistry::new());

        // The broker hosts PTYs (B1/B2). Serve it on a background thread; a bind
        // failure there must not stop the seed channel (the B6 surface) — the
        // PTY-hosting consumers arrive in B7/B8.
        match Broker::bind(&broker_socket_name()) {
            Ok(broker) => {
                thread::spawn(move || {
                    let _ = broker.serve();
                });
            }
            Err(e) => eprintln!("BROKER_BIND_FAIL: {e}"),
        }

        // Best-effort breadcrumb: record our pid for management/cleanup. Liveness
        // is the socket ping, never this file, so a stale value is harmless.
        let _ = std::fs::write(daemon_pid_path(), std::process::id().to_string());

        // The seed-control channel is the foreground loop. Binding it is what
        // arbitrates the single-daemon-per-home invariant: a second daemon racing
        // to start fails this bind and exits, leaving the winner serving.
        seedmap::serve_seed_control(&seed_socket_name(), reg)
    }
}

/// Whether a daemon is already serving the seed-control channel for this home.
pub fn is_running() -> bool {
    seedmap::ping(&seed_socket_name()).is_ok()
}

/// Auto-start (REQ-DAEMON-3): ensure a daemon is up for this home. A no-op if one
/// already answers; otherwise spawn `spt daemon` detached and wait (bounded) for
/// it to bind. Idempotent + race-tolerant: if two callers spawn at once, one
/// wins the bind and the other exits — and this check passes either way.
pub fn ensure_running() -> io::Result<()> {
    if is_running() {
        return Ok(());
    }
    spawn_detached()?;
    let mut waited = Duration::ZERO;
    while waited < START_TIMEOUT {
        if is_running() {
            return Ok(());
        }
        thread::sleep(START_POLL);
        waited += START_POLL;
    }
    Err(io::Error::new(
        io::ErrorKind::TimedOut,
        "daemon did not come up within the start window",
    ))
}

/// Spawn `spt daemon` as a detached background process. Inherits the environment
/// — so the child resolves the **same** `SPT_HOME`, hence the same canonical
/// socket names — but drops the std handles so it outlives the launching CLI
/// call. The child is independent (not killed when the parent exits): on unix it
/// is reparented to init when the launcher exits; on Windows a child is not in
/// the parent's job by default.
///
/// **Windows handle-inheritance guard.** A caller may capture this process's
/// stdout/stderr through a pipe (`Command::output()`, a harness hook reading the
/// command's output). On Windows `CreateProcess` is invoked with
/// `bInheritHandles = TRUE`, so the long-lived daemon would inherit that pipe's
/// write end and hold it open forever — the capturing reader never sees EOF and
/// hangs. Unix is immune (Rust marks pipe fds `CLOEXEC`). So before spawning we
/// strip `HANDLE_FLAG_INHERIT` from this process's std handles; the daemon then
/// cannot inherit them, the pipe closes when *this* process exits, and the
/// caller's read completes. Stripping the flag does not affect this process's own
/// use of its std handles — only what a child inherits.
fn spawn_detached() -> io::Result<()> {
    #[cfg(windows)]
    block_std_handle_inheritance();
    let exe = std::env::current_exe()?;
    let mut cmd = Command::new(exe);
    cmd.arg("daemon")
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::null());
    #[cfg(windows)]
    {
        use std::os::windows::process::CommandExt;
        // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW: no
        // inherited console, no popup window, its own signal group.
        cmd.creation_flags(0x0000_0008 | 0x0000_0200 | 0x0800_0000);
    }
    cmd.spawn()?;
    Ok(())
}

/// Clear `HANDLE_FLAG_INHERIT` on this process's std handles so a spawned child
/// (the daemon) cannot inherit a pipe a caller is capturing. Declared inline to
/// avoid a winapi dependency for these two calls.
#[cfg(windows)]
fn block_std_handle_inheritance() {
    extern "system" {
        fn GetStdHandle(nStdHandle: u32) -> isize;
        fn SetHandleInformation(hObject: isize, dwMask: u32, dwFlags: u32) -> i32;
    }
    const STD_INPUT_HANDLE: u32 = 0xFFFF_FFF6; // -10
    const STD_OUTPUT_HANDLE: u32 = 0xFFFF_FFF5; // -11
    const STD_ERROR_HANDLE: u32 = 0xFFFF_FFF4; // -12
    const HANDLE_FLAG_INHERIT: u32 = 0x1;
    const INVALID: isize = -1;
    unsafe {
        for which in [STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE] {
            let h = GetStdHandle(which);
            if h != 0 && h != INVALID {
                // Best-effort: a console handle that rejects the call is harmless.
                SetHandleInformation(h, HANDLE_FLAG_INHERIT, 0);
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::seedmap::{serve_seed_control, SeedRegistry};
    use crate::test_home::with_home;

    // [unit->REQ-DAEMON-3] with no daemon up, is_running() is false; once a
    // seed-control server answers, it flips true and ensure_running() is a no-op
    // (the spawn branch — which would need the real `spt` binary — is not taken).
    #[test]
    fn is_running_tracks_the_seed_channel() {
        with_home(|_| {
            assert!(!is_running(), "no daemon bound yet");

            let reg = Arc::new(SeedRegistry::new());
            let name = seed_socket_name();
            let srv = Arc::clone(&reg);
            thread::spawn(move || {
                let _ = serve_seed_control(&name, srv);
            });
            for _ in 0..400 {
                if is_running() {
                    break;
                }
                thread::sleep(Duration::from_millis(5));
            }
            assert!(is_running(), "the seed channel answers → daemon is up");
            // ensure_running short-circuits (no real process spawn).
            ensure_running().expect("ensure is a no-op when already up");
        });
    }
}


1	//! Durable daemon configuration (B3) — the configurable pulse period that
2	//! replaces the interim 5s constant.
3	//!
4	//! In the no-daemon interim model the pulse cadence was a hard-coded
5	//! `spt_live::pulse::DEFAULT_PULSE_PERIOD` (5s). Consolidation (REQ-DAEMON-1)
6	//! folds the pulse loop into the brain as a *scheduled* in-process loop whose
7	//! period is **durable and configurable**, sourced from a small JSON file under
8	//! the spt home (`<spt_home>/daemon.json`) and falling back to the default when
9	//! absent. Kept deliberately tiny — one struct, JSON via the crate's existing
10	//! `serde_json` dep (no new TOML edge) — so the brain's loop reads its cadence
11	//! from config, never a constant.
12	// [impl->REQ-DAEMON-1]
13
14	use std::path::{Path, PathBuf};
15	use std::time::Duration;
16
17	use serde::{Deserialize, Serialize};
18
19	/// The interim default pulse period carried forward as the config default. The
20	/// daemon now treats this as a *fallback*, not a constant: `daemon.json` may
21	/// override it per machine.
22	pub const DEFAULT_PULSE_PERIOD: Duration = Duration::from_secs(5);
23
24	/// Default drop-file precedence protection window (epoch-ms), mirroring the
25	/// interim `spt_live` guard. Surfaced here so the one config source owns it.
26	pub const DEFAULT_PROTECTION_WINDOW_MS: u64 = 2_000;
27
28	/// Default orphan-watch grace window (B7): how long the supervised-crash teardown
29	/// waits after observing a hosted Self's harness owner gone before it rechecks
30	/// liveness and signs off (KNOWN-HAZARDS 1.1). Sized to cover a transient recovery
31	/// — a binary handoff relaunch or a brief stale poll — without delaying a real
32	/// teardown for long. Configurable per machine via `crash_grace_ms`.
33	pub const DEFAULT_CRASH_GRACE: Duration = Duration::from_secs(2);
34
35	/// The on-disk shape (`<spt_home>/daemon.json`). Every field is optional so a
36	/// partial file still loads (forward/back-compat) — a missing field takes the
37	/// default. Stored as plain integers (ms) to keep the file human-editable.
38	#[derive(Debug, Clone, Default, Serialize, Deserialize)]
39	struct DaemonConfigFile {
40	    #[serde(default, skip_serializing_if = "Option::is_none")]
41	    pulse_period_ms: Option<u64>,
42	    #[serde(default, skip_serializing_if = "Option::is_none")]
43	    protection_window_ms: Option<u64>,
44	    #[serde(default, skip_serializing_if = "Option::is_none")]
45	    crash_grace_ms: Option<u64>,
46	}
47
48	/// Resolved daemon configuration. The brain's scheduled loops read their cadence
49	/// from `pulse_period`; the drop-ingest guard reads `protection_window_ms`.
50	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51	pub struct DaemonConfig {
52	    /// The pulse loop period (config-driven; defaults to [`DEFAULT_PULSE_PERIOD`]).
53	    pub pulse_period: Duration,
54	    /// The drop-file precedence protection window in epoch-ms (6.5).
55	    pub protection_window_ms: u64,
56	    /// The orphan-watch grace window (B7): the recovery wait before a
57	    /// supervised-crash teardown rechecks liveness and signs off (1.1).
58	    pub crash_grace: Duration,
59	}
60
61	impl Default for DaemonConfig {
62	    fn default() -> Self {
63	        DaemonConfig {
64	            pulse_period: DEFAULT_PULSE_PERIOD,
65	            protection_window_ms: DEFAULT_PROTECTION_WINDOW_MS,
66	            crash_grace: DEFAULT_CRASH_GRACE,
67	        }
68	    }
69	}
70
71	impl DaemonConfig {
72	    /// The canonical config path: `<spt_home>/daemon.json`.
73	    pub fn default_path() -> PathBuf {
74	        spt_store::perch::spt_home().join("daemon.json")
75	    }
76
77	    /// Load the daemon config from the canonical path, falling back to the
78	    /// default if the file is absent or unreadable.
79	    pub fn load() -> Self {
80	        Self::load_from(&Self::default_path())
81	    }
82
83	    /// Load from an explicit path. A missing file → default; a present-but-partial
84	    /// file fills only the named fields and defaults the rest; a malformed file
85	    /// degrades to the default (a broken config must never wedge the daemon).
86	    pub fn load_from(path: &Path) -> Self {
87	        let Ok(raw) = std::fs::read_to_string(path) else {
88	            return Self::default();
89	        };
90	        let file: DaemonConfigFile = match serde_json::from_str(&raw) {
91	            Ok(f) => f,
92	            Err(_) => return Self::default(),
93	        };
94	        Self::from_file(file)
95	    }
96
97	    fn from_file(file: DaemonConfigFile) -> Self {
98	        let d = DaemonConfig::default();
99	        DaemonConfig {
100	            pulse_period: file
101	                .pulse_period_ms
102	                .map(Duration::from_millis)
103	                .unwrap_or(d.pulse_period),
104	            protection_window_ms: file.protection_window_ms.unwrap_or(d.protection_window_ms),
105	            crash_grace: file
106	                .crash_grace_ms
107	                .map(Duration::from_millis)
108	                .unwrap_or(d.crash_grace),
109	        }
110	    }
111
112	    /// Persist this config to an explicit path (for `spt` to write the knob).
113	    pub fn save_to(&self, path: &Path) -> std::io::Result<()> {
114	        let file = DaemonConfigFile {
115	            pulse_period_ms: Some(self.pulse_period.as_millis() as u64),
116	            protection_window_ms: Some(self.protection_window_ms),
117	            crash_grace_ms: Some(self.crash_grace.as_millis() as u64),
118	        };
119	        let json = serde_json::to_string_pretty(&file)
120	            .map_err(|e| std::io::Error::other(e.to_string()))?;
121	        std::fs::write(path, json)
122	    }
123	}
124
125	#[cfg(test)]
126	mod tests {
127	    use super::*;
128
129	    #[test] // [unit->REQ-DAEMON-1] an absent config file yields the default period.
130	    fn missing_file_is_default() {
131	        let dir = tempfile::tempdir().unwrap();
132	        let cfg = DaemonConfig::load_from(&dir.path().join("nope.json"));
133	        assert_eq!(cfg, DaemonConfig::default());
134	        assert_eq!(cfg.pulse_period, DEFAULT_PULSE_PERIOD);
135	    }
136
137	    #[test] // [unit->REQ-DAEMON-1] the pulse period is config-driven, not a constant.
138	    fn pulse_period_is_config_driven() {
139	        let dir = tempfile::tempdir().unwrap();
140	        let path = dir.path().join("daemon.json");
141	        std::fs::write(&path, r#"{"pulse_period_ms": 250}"#).unwrap();
142
143	        let cfg = DaemonConfig::load_from(&path);
144	        assert_eq!(cfg.pulse_period, Duration::from_millis(250));
145	        assert_ne!(cfg.pulse_period, DEFAULT_PULSE_PERIOD, "must override the default");
146	        // The unspecified field keeps its default.
147	        assert_eq!(cfg.protection_window_ms, DEFAULT_PROTECTION_WINDOW_MS);
148	    }
149
150	    #[test] // [unit->REQ-DAEMON-1] a malformed config degrades to default, never wedges.
151	    fn malformed_file_degrades_to_default() {
152	        let dir = tempfile::tempdir().unwrap();
153	        let path = dir.path().join("daemon.json");
154	        std::fs::write(&path, "not json {{{").unwrap();
155	        assert_eq!(DaemonConfig::load_from(&path), DaemonConfig::default());
156	    }
157
158	    #[test] // [unit->REQ-DAEMON-1] save then load round-trips the configured period.
159	    fn save_load_roundtrip() {
160	        let dir = tempfile::tempdir().unwrap();
161	        let path = dir.path().join("daemon.json");
162	        let cfg = DaemonConfig {
163	            pulse_period: Duration::from_millis(750),
164	            protection_window_ms: 99,
165	            crash_grace: Duration::from_millis(1234),
166	        };
167	        cfg.save_to(&path).unwrap();
168	        assert_eq!(DaemonConfig::load_from(&path), cfg);
169	    }
170
171	    #[test] // [unit->REQ-DAEMON-4] the orphan-watch grace is config-driven, default otherwise.
172	    fn crash_grace_is_config_driven() {
173	        let dir = tempfile::tempdir().unwrap();
174	        let path = dir.path().join("daemon.json");
175	        std::fs::write(&path, r#"{"crash_grace_ms": 500}"#).unwrap();
176
177	        let cfg = DaemonConfig::load_from(&path);
178	        assert_eq!(cfg.crash_grace, Duration::from_millis(500));
179	        assert_ne!(cfg.crash_grace, DEFAULT_CRASH_GRACE, "must override the default");
180	        // Unspecified fields keep their defaults.
181	        assert_eq!(cfg.pulse_period, DEFAULT_PULSE_PERIOD);
182	        // An absent file yields the default grace.
183	        assert_eq!(
184	            DaemonConfig::load_from(&dir.path().join("nope.json")).crash_grace,
185	            DEFAULT_CRASH_GRACE
186	        );
187	    }
188	}
189

//! Durable daemon configuration (B3) — the configurable pulse period that
//! replaces the interim 5s constant.
//!
//! In the no-daemon interim model the pulse cadence was a hard-coded
//! `spt_live::pulse::DEFAULT_PULSE_PERIOD` (5s). Consolidation (REQ-DAEMON-1)
//! folds the pulse loop into the brain as a *scheduled* in-process loop whose
//! period is **durable and configurable**, sourced from a small JSON file under
//! the spt home (`<spt_home>/daemon.json`) and falling back to the default when
//! absent. Kept deliberately tiny — one struct, JSON via the crate's existing
//! `serde_json` dep (no new TOML edge) — so the brain's loop reads its cadence
//! from config, never a constant.
// [impl->REQ-DAEMON-1]

use std::path::{Path, PathBuf};
use std::time::Duration;

use serde::{Deserialize, Serialize};

/// The interim default pulse period carried forward as the config default. The
/// daemon now treats this as a *fallback*, not a constant: `daemon.json` may
/// override it per machine.
pub const DEFAULT_PULSE_PERIOD: Duration = Duration::from_secs(5);

/// Default drop-file precedence protection window (epoch-ms), mirroring the
/// interim `spt_live` guard. Surfaced here so the one config source owns it.
pub const DEFAULT_PROTECTION_WINDOW_MS: u64 = 2_000;

/// Default orphan-watch grace window (B7): how long the supervised-crash teardown
/// waits after observing a hosted Self's harness owner gone before it rechecks
/// liveness and signs off (KNOWN-HAZARDS 1.1). Sized to cover a transient recovery
/// — a binary handoff relaunch or a brief stale poll — without delaying a real
/// teardown for long. Configurable per machine via `crash_grace_ms`.
pub const DEFAULT_CRASH_GRACE: Duration = Duration::from_secs(2);

/// The on-disk shape (`<spt_home>/daemon.json`). Every field is optional so a
/// partial file still loads (forward/back-compat) — a missing field takes the
/// default. Stored as plain integers (ms) to keep the file human-editable.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct DaemonConfigFile {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pulse_period_ms: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    protection_window_ms: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    crash_grace_ms: Option<u64>,
}

/// Resolved daemon configuration. The brain's scheduled loops read their cadence
/// from `pulse_period`; the drop-ingest guard reads `protection_window_ms`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DaemonConfig {
    /// The pulse loop period (config-driven; defaults to [`DEFAULT_PULSE_PERIOD`]).
    pub pulse_period: Duration,
    /// The drop-file precedence protection window in epoch-ms (6.5).
    pub protection_window_ms: u64,
    /// The orphan-watch grace window (B7): the recovery wait before a
    /// supervised-crash teardown rechecks liveness and signs off (1.1).
    pub crash_grace: Duration,
}

impl Default for DaemonConfig {
    fn default() -> Self {
        DaemonConfig {
            pulse_period: DEFAULT_PULSE_PERIOD,
            protection_window_ms: DEFAULT_PROTECTION_WINDOW_MS,
            crash_grace: DEFAULT_CRASH_GRACE,
        }
    }
}

impl DaemonConfig {
    /// The canonical config path: `<spt_home>/daemon.json`.
    pub fn default_path() -> PathBuf {
        spt_store::perch::spt_home().join("daemon.json")
    }

    /// Load the daemon config from the canonical path, falling back to the
    /// default if the file is absent or unreadable.
    pub fn load() -> Self {
        Self::load_from(&Self::default_path())
    }

    /// Load from an explicit path. A missing file → default; a present-but-partial
    /// file fills only the named fields and defaults the rest; a malformed file
    /// degrades to the default (a broken config must never wedge the daemon).
    pub fn load_from(path: &Path) -> Self {
        let Ok(raw) = std::fs::read_to_string(path) else {
            return Self::default();
        };
        let file: DaemonConfigFile = match serde_json::from_str(&raw) {
            Ok(f) => f,
            Err(_) => return Self::default(),
        };
        Self::from_file(file)
    }

    fn from_file(file: DaemonConfigFile) -> Self {
        let d = DaemonConfig::default();
        DaemonConfig {
            pulse_period: file
                .pulse_period_ms
                .map(Duration::from_millis)
                .unwrap_or(d.pulse_period),
            protection_window_ms: file.protection_window_ms.unwrap_or(d.protection_window_ms),
            crash_grace: file
                .crash_grace_ms
                .map(Duration::from_millis)
                .unwrap_or(d.crash_grace),
        }
    }

    /// Persist this config to an explicit path (for `spt` to write the knob).
    pub fn save_to(&self, path: &Path) -> std::io::Result<()> {
        let file = DaemonConfigFile {
            pulse_period_ms: Some(self.pulse_period.as_millis() as u64),
            protection_window_ms: Some(self.protection_window_ms),
            crash_grace_ms: Some(self.crash_grace.as_millis() as u64),
        };
        let json = serde_json::to_string_pretty(&file)
            .map_err(|e| std::io::Error::other(e.to_string()))?;
        std::fs::write(path, json)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test] // [unit->REQ-DAEMON-1] an absent config file yields the default period.
    fn missing_file_is_default() {
        let dir = tempfile::tempdir().unwrap();
        let cfg = DaemonConfig::load_from(&dir.path().join("nope.json"));
        assert_eq!(cfg, DaemonConfig::default());
        assert_eq!(cfg.pulse_period, DEFAULT_PULSE_PERIOD);
    }

    #[test] // [unit->REQ-DAEMON-1] the pulse period is config-driven, not a constant.
    fn pulse_period_is_config_driven() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("daemon.json");
        std::fs::write(&path, r#"{"pulse_period_ms": 250}"#).unwrap();

        let cfg = DaemonConfig::load_from(&path);
        assert_eq!(cfg.pulse_period, Duration::from_millis(250));
        assert_ne!(cfg.pulse_period, DEFAULT_PULSE_PERIOD, "must override the default");
        // The unspecified field keeps its default.
        assert_eq!(cfg.protection_window_ms, DEFAULT_PROTECTION_WINDOW_MS);
    }

    #[test] // [unit->REQ-DAEMON-1] a malformed config degrades to default, never wedges.
    fn malformed_file_degrades_to_default() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("daemon.json");
        std::fs::write(&path, "not json {{{").unwrap();
        assert_eq!(DaemonConfig::load_from(&path), DaemonConfig::default());
    }

    #[test] // [unit->REQ-DAEMON-1] save then load round-trips the configured period.
    fn save_load_roundtrip() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("daemon.json");
        let cfg = DaemonConfig {
            pulse_period: Duration::from_millis(750),
            protection_window_ms: 99,
            crash_grace: Duration::from_millis(1234),
        };
        cfg.save_to(&path).unwrap();
        assert_eq!(DaemonConfig::load_from(&path), cfg);
    }

    #[test] // [unit->REQ-DAEMON-4] the orphan-watch grace is config-driven, default otherwise.
    fn crash_grace_is_config_driven() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("daemon.json");
        std::fs::write(&path, r#"{"crash_grace_ms": 500}"#).unwrap();

        let cfg = DaemonConfig::load_from(&path);
        assert_eq!(cfg.crash_grace, Duration::from_millis(500));
        assert_ne!(cfg.crash_grace, DEFAULT_CRASH_GRACE, "must override the default");
        // Unspecified fields keep their defaults.
        assert_eq!(cfg.pulse_period, DEFAULT_PULSE_PERIOD);
        // An absent file yields the default grace.
        assert_eq!(
            DaemonConfig::load_from(&dir.path().join("nope.json")).crash_grace,
            DEFAULT_CRASH_GRACE
        );
    }
}


1	//! `spt-daemon` — the per-machine broker/brain supervisor (M3b).
2	//!
3	//! ADR-0004 splits the logical daemon into two implementation layers so a
4	//! routine self-update swaps the logic without disturbing live endpoints:
5	//!
6	//! - a **broker** (stable kernel) holds *only* the un-transferable resources a
7	//!   live consumer would lose continuity on if the logic restarted — PTY
8	//!   masters (from [`spt_term`]), spawned harness children, accepted/listening
9	//!   sockets — and runs no logic;
10	//! - a **brain** (restartable logic) holds routing, registry, manifest parse,
11	//!   and the re-hosted [`spt_live`] lifecycle, attaching to the broker over a
12	//!   **versioned local IPC** so a *newer brain talks to an older broker*.
13	//!
14	//! **B0 (this commit)** lands the crate's layer position plus that versioned
15	//! IPC seam — the load-bearing contract everything else rides:
16	//!
17	//! - [`frame`] — the forward-compatible [`Envelope`] schema + the version
18	//!   [`Hello`] handshake (KNOWN-HAZARDS 2.3, [`REQ-HAZARD-HANDOFF-ARGV-COMPAT`]);
19	//! - [`codec`] — length-prefixed JSON framing over any [`std::io::Read`]/`Write`;
20	//! - [`transport`] — the OS-split carrier (named pipe / UDS) behind one
21	//!   [`DaemonTransport`] trait, mirroring `spt-term`'s `SessionSurface`.
22	//!
23	//! **B1 (this commit)** adds the **broker kernel** ([`broker`]) — the stable
24	//! half that hosts a real `spt-term` PTY child and relays its output/input over
25	//! the IPC, holding only the ADR-0004 §B resources and no logic — plus the
26	//! broker↔brain message catalog ([`msg`]).
27	//!
28	//! **B3 (this commit)** consolidates the interim no-daemon model into the brain:
29	//! the `api listen` pulse / Psyche / drop-ingest loops become scheduled
30	//! in-process loops ([`lifecycle::BrainLifecycle`]) paced by a durable,
31	//! configurable period ([`config::DaemonConfig`]) instead of the interim 5s
32	//! constant; the only residue left in the harness-hosted session is the thin,
33	//! stateless [`relay::Relay`]. The `spt` binary's `LiveHost` becomes a shim over
34	//! `BrainLifecycle` (re-host, not rewrite) ([`REQ-DAEMON-1`]).
35	//!
36	//! **B5 (this commit)** makes the broker↔brain boundary **exactly-once across a
37	//! brain crash** ([`effect::EffectJournal`]): an idempotent `input` carries a
38	//! durable `op_id`, and the broker dedups at the effect keyed by that id over a
39	//! broker-owned recovery anchor (KNOWN-HAZARDS 7.2, [`REQ-HAZARD-RESTART-IDEMPOTENT`];
40	//! Spike #6's two constraints — broker-owned anchor, dedup-at-effect-by-id —
41	//! instantiated on the real PTY-write boundary).
42	//!
43	//! The brain + gapless handoff (B2) and the rest of the M3b task table build on
44	//! this seam. `spt-daemon` is internal supervisor machinery, **not** public SDK
45	//! (R-ARCH-2).
46	//!
47	//! [`Envelope`]: frame::Envelope
48	//! [`Hello`]: frame::Hello
49	//! [`DaemonTransport`]: transport::DaemonTransport
50
51	pub mod brain;
52	pub mod broker;
53	pub mod codec;
54	pub mod config;
55	pub mod daemon;
56	pub mod effect;
57	pub mod endpoint;
58	pub mod frame;
59	pub mod lifecycle;
60	pub mod msg;
61	pub mod relay;
62	pub mod seedmap;
63	pub mod transport;
64
65	#[cfg(test)]
66	mod test_home;
67
68	pub use brain::{Brain, BrainState, BrokerEvent};
69	pub use broker::Broker;
70	pub use config::DaemonConfig;
71	pub use daemon::{ensure_running, is_running, Daemon};
72	pub use effect::{EffectJournal, EffectKey, EffectKind, Outcome};
73	pub use endpoint::{broker_socket_name, daemon_pid_path, seed_socket_name};
74	pub use lifecycle::{BrainLifecycle, TickReport};
75	pub use relay::Relay;
76	pub use seedmap::{put_seed, take_seed, SeedRegistry};
77	pub use frame::{
78	    accept_hello, Envelope, HandshakeError, Hello, Role, IPC_PROTOCOL_VERSION,
79	    MIN_COMPATIBLE_VERSION,
80	};
81	pub use transport::{DaemonTransport, LocalSocketTransport};
82

//! `spt-daemon` — the per-machine broker/brain supervisor (M3b).
//!
//! ADR-0004 splits the logical daemon into two implementation layers so a
//! routine self-update swaps the logic without disturbing live endpoints:
//!
//! - a **broker** (stable kernel) holds *only* the un-transferable resources a
//!   live consumer would lose continuity on if the logic restarted — PTY
//!   masters (from [`spt_term`]), spawned harness children, accepted/listening
//!   sockets — and runs no logic;
//! - a **brain** (restartable logic) holds routing, registry, manifest parse,
//!   and the re-hosted [`spt_live`] lifecycle, attaching to the broker over a
//!   **versioned local IPC** so a *newer brain talks to an older broker*.
//!
//! **B0 (this commit)** lands the crate's layer position plus that versioned
//! IPC seam — the load-bearing contract everything else rides:
//!
//! - [`frame`] — the forward-compatible [`Envelope`] schema + the version
//!   [`Hello`] handshake (KNOWN-HAZARDS 2.3, [`REQ-HAZARD-HANDOFF-ARGV-COMPAT`]);
//! - [`codec`] — length-prefixed JSON framing over any [`std::io::Read`]/`Write`;
//! - [`transport`] — the OS-split carrier (named pipe / UDS) behind one
//!   [`DaemonTransport`] trait, mirroring `spt-term`'s `SessionSurface`.
//!
//! **B1 (this commit)** adds the **broker kernel** ([`broker`]) — the stable
//! half that hosts a real `spt-term` PTY child and relays its output/input over
//! the IPC, holding only the ADR-0004 §B resources and no logic — plus the
//! broker↔brain message catalog ([`msg`]).
//!
//! **B3 (this commit)** consolidates the interim no-daemon model into the brain:
//! the `api listen` pulse / Psyche / drop-ingest loops become scheduled
//! in-process loops ([`lifecycle::BrainLifecycle`]) paced by a durable,
//! configurable period ([`config::DaemonConfig`]) instead of the interim 5s
//! constant; the only residue left in the harness-hosted session is the thin,
//! stateless [`relay::Relay`]. The `spt` binary's `LiveHost` becomes a shim over
//! `BrainLifecycle` (re-host, not rewrite) ([`REQ-DAEMON-1`]).
//!
//! **B5 (this commit)** makes the broker↔brain boundary **exactly-once across a
//! brain crash** ([`effect::EffectJournal`]): an idempotent `input` carries a
//! durable `op_id`, and the broker dedups at the effect keyed by that id over a
//! broker-owned recovery anchor (KNOWN-HAZARDS 7.2, [`REQ-HAZARD-RESTART-IDEMPOTENT`];
//! Spike #6's two constraints — broker-owned anchor, dedup-at-effect-by-id —
//! instantiated on the real PTY-write boundary).
//!
//! The brain + gapless handoff (B2) and the rest of the M3b task table build on
//! this seam. `spt-daemon` is internal supervisor machinery, **not** public SDK
//! (R-ARCH-2).
//!
//! [`Envelope`]: frame::Envelope
//! [`Hello`]: frame::Hello
//! [`DaemonTransport`]: transport::DaemonTransport

pub mod brain;
pub mod broker;
pub mod codec;
pub mod config;
pub mod daemon;
pub mod effect;
pub mod endpoint;
pub mod frame;
pub mod lifecycle;
pub mod msg;
pub mod relay;
pub mod seedmap;
pub mod transport;

#[cfg(test)]
mod test_home;

pub use brain::{Brain, BrainState, BrokerEvent};
pub use broker::Broker;
pub use config::DaemonConfig;
pub use daemon::{ensure_running, is_running, Daemon};
pub use effect::{EffectJournal, EffectKey, EffectKind, Outcome};
pub use endpoint::{broker_socket_name, daemon_pid_path, seed_socket_name};
pub use lifecycle::{BrainLifecycle, TickReport};
pub use relay::Relay;
pub use seedmap::{put_seed, take_seed, SeedRegistry};
pub use frame::{
    accept_hello, Envelope, HandshakeError, Hello, Role, IPC_PROTOCOL_VERSION,
    MIN_COMPATIBLE_VERSION,
};
pub use transport::{DaemonTransport, LocalSocketTransport};


Now manifest seam (`pty_digest`) + spt CLI structure. Read MANIFEST doc + runtime manifest + spt CLI.


crates\spt-runtime\src\lib.rs
crates\spt-runtime\src\runtime.rs
crates\spt-runtime\src\manifest.rs


crates\spt\src\testutil.rs
crates\spt\src\main.rs
crates\spt\src\api\auth.rs
crates\spt\src\api\reporting.rs
crates\spt\src\api\delivery.rs
crates\spt\src\api\worker.rs
crates\spt\src\api\live.rs
crates\spt\src\roster.rs
crates\spt\src\cli.rs
crates\spt\src\api\mod.rs
crates\spt\src\api\startup.rs