1	//! Harness-hosted startup (interim no-daemon): `api seed` → `api listen` → bind,
2	//! plus the post-spawn `api bind`.
3	//!
4	//! Flow (CONTEXT §Startup flows, harness-hosted):
5	//! 1. The harness's SessionStart hook fires `api seed --pid <parent_pid>
6	//!    --session-id <sid>` → the seed is stored in the **daemon's in-memory map**
7	//!    keyed by the stable parent pid (REQ-START-3; no file — B6 retired the
8	//!    `spt_store::seed` file bridge).
9	//! 2. The agent runs its `$SPT listen <id>` alias → `api listen` self-discovers
10	//!    its parent pid, TAKEs the seed from the daemon, validates it against
11	//!    pid-recycle (KNOWN-HAZARDS 5.1), binds the perch skeleton→live, and relays.
12	//! 3. Alternatively a just-spawned binary fires `api bind <id>` to associate
13	//!    itself post-spawn (REQ-SEAM-POSTSPAWN) — establishing the live perch on
14	//!    first contact, or flipping an existing skeleton.
15	//
16	// [impl->REQ-START-2]
17	// [impl->REQ-START-3]
18	// [impl->REQ-SEAM-POSTSPAWN]
19
20	use std::time::Duration;
21
22	use spt_msg::listener::PollListener;
23	use spt_store::info::{self, InfoJson};
24	use spt_store::perch::{self, ParentHint};
25	use spt_store::proc;
26	use spt_store::seed::Seed;
27	use spt_store::spool;
28
29	use super::auth;
30	use super::{Ctx, EXIT_REFUSED};
31
32	/// Poll window for the relay loop — bounds each blocking accept so the loop
33	/// stays responsive (mirrors M1's listener cadence).
34	const RELAY_POLL: Duration = Duration::from_millis(200);
35
36	/// `api seed` — record the ephemeral startup seed keyed by parent pid. No prior
37	/// perch exists, so this call is not auth-gated (it *establishes* the binding
38	/// material the gated calls later authenticate against).
39	///
40	/// REQ-START-3: the seed is PUT into the daemon's in-memory map over the
41	/// seed-control IPC — **no file is written**. The daemon is auto-started if
42	/// absent (REQ-DAEMON-3).
43	// [impl->REQ-START-3]
44	pub fn cmd_seed(ctx: &Ctx, parent_pid: u32, session_id: String) -> i32 {
45	    if session_id.is_empty() {
46	        eprintln!("USAGE: seed requires a non-empty --session-id");
47	        return EXIT_REFUSED;
48	    }
49	    let seed = Seed {
50	        parent_pid,
51	        session_id,
52	        adapter: ctx.adapter.clone(),
53	        cwd: std::env::current_dir()
54	            .ok()
55	            .map(|p| p.to_string_lossy().replace('\\', "/")),
56	        created: now_stamp(),
57	    };
58	    if let Err(e) = spt_daemon::ensure_running() {
59	        eprintln!("SEED_FAIL:{parent_pid}: daemon unavailable: {e}");
60	        return EXIT_REFUSED;
61	    }
62	    match spt_daemon::put_seed(&spt_daemon::seed_socket_name(), &seed) {
63	        Ok(()) => {
64	            eprintln!("SEEDED:{parent_pid}");
65	            0
66	        }
67	        Err(e) => {
68	            eprintln!("SEED_FAIL:{parent_pid}: {e}");
69	            EXIT_REFUSED
70	        }
71	    }
72	}
73
74	/// Why a seed-driven bind was refused. Each variant is a distinct diagnostic so
75	/// a stale/recycled seed never silently binds the wrong session.
76	#[derive(Debug, PartialEq, Eq)]
77	pub enum BindError {
78	    NoSeed(u32),
79	    /// The seed's anchor process is dead → recycled/abandoned (5.1).
80	    StaleSeed(u32),
81	    EmptySession,
82	    /// A *live* perch already holds this id under a different session (5.1).
83	    Conflict { id: String, held: String },
84	    /// Home assignment refused for a NEW endpoint (REQ-INST-15:
85	    /// refuse-and-qualify on a multi-subnet node — no silent guess).
86	    Home(String),
87	    Io(String),
88	}
89
90	/// Consume the seed for `parent_pid` and bind/revive the perch skeleton→live.
91	/// Pure of any listener I/O so it is unit-testable without spawning processes.
92	/// Returns the perch's capability token on success.
93	///
94	/// REQ-START-3: the seed is TAKEn (consume-once) from the daemon's in-memory map
95	/// — the take itself consumes it, so a stale/conflicting seed is spent and the
96	/// harness re-seeds on the next SessionStart rather than leaving it to re-bind.
97	// [impl->REQ-START-2]
98	// [impl->REQ-START-3]
99	// [impl->REQ-HAZARD-WINDOWS-PID-RECYCLE]
100	pub fn bind_from_seed(
101	    id: &str,
102	    parent_pid: u32,
103	    subnet: Option<&str>,
104	) -> Result<String, BindError> {
105	    let seed = take_seed_from_daemon(parent_pid)?.ok_or(BindError::NoSeed(parent_pid))?;
106	    if seed.session_id.is_empty() {
107	        return Err(BindError::EmptySession);
108	    }
109	    // Pid-recycle defense (5.1): anchor liveness on the stable parent pid. A
110	    // dead anchor means the harness that wrote the seed is gone — a recycled or
111	    // abandoned seed must never bind.
112	    if !proc::is_process_alive(seed.parent_pid) {
113	        return Err(BindError::StaleSeed(seed.parent_pid));
114	    }
115	    // The seed carries the creating adapter — the adapter-at-creation rule's
116	    // source on this path (REQ-INST-15).
117	    let adapter = Some(seed.adapter.as_str()).filter(|a| !a.is_empty());
118	    establish_perch(id, &seed.session_id, seed.parent_pid, adapter, subnet)
119	}
120
121	/// TAKE (consume) the seed for `parent_pid` from the daemon's in-memory map,
122	/// auto-starting the daemon if absent (REQ-DAEMON-3 / REQ-START-3).
123	fn take_seed_from_daemon(parent_pid: u32) -> Result<Option<Seed>, BindError> {
124	    spt_daemon::ensure_running()
125	        .map_err(|e| BindError::Io(format!("daemon unavailable: {e}")))?;
126	    spt_daemon::take_seed(&spt_daemon::seed_socket_name(), parent_pid)
127	        .map_err(|e| BindError::Io(e.to_string()))
128	}
129
130	/// Write the perch record (skeleton→live) + ready marker + capability token.
131	/// Reviving an existing perch keeps its token; a live perch held under a
132	/// different session id is a conflict (pid-recycle / id collision guard).
133	///
134	/// Creation-time fields ride the shared seam
135	/// ([`spt_store::home::stamp_creation_fields`], REQ-INST-15): a NEW perch
136	/// assigns its immutable home (refuse-and-qualify on a multi-subnet node) +
137	/// seeds `sync_subnets = [home]`; a revive **carries forward** home, the
138	/// adapter (an explicit one wins — resume-under-new), the resource blurb,
139	/// and the auto-suspend override — a re-bind rewrites `info.json` and must
140	/// not wipe them.
141	// [impl->REQ-INST-15]
142	fn establish_perch(
143	    id: &str,
144	    session_id: &str,
145	    parent_pid: u32,
146	    adapter: Option<&str>,
147	    subnet: Option<&str>,
148	) -> Result<String, BindError> {
149	    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
150
151	    let prior = info::read_info(&perch_path);
152	    if let Some(existing) = &prior {
153	        let owner_alive = info::read_pid(&perch_path)
154	            .map(proc::is_process_alive)
155	            .unwrap_or(false);
156	        if owner_alive
157	            && !existing.session_id.is_empty()
158	            && existing.session_id != session_id
159	        {
160	            return Err(BindError::Conflict {
161	                id: id.to_string(),
162	                held: existing.session_id.clone(),
163	            });
164	        }
165	    }
166
167	    std::fs::create_dir_all(&perch_path).map_err(|e| BindError::Io(e.to_string()))?;
168	    let mut rec = InfoJson::new(id, &now_stamp(), std::process::id(), session_id, "live_agent");
169	    rec.parent_pid = Some(parent_pid);
170	    let subnets = spt_store::subnet::SubnetStore::load();
171	    let mut vis = spt_store::visibility::VisibilityStore::load();
172	    let dirty = spt_store::home::stamp_creation_fields(
173	        &mut rec,
174	        prior.as_ref(),
175	        &subnets,
176	        &mut vis,
177	        subnet,
178	        adapter,
179	    )
180	    .map_err(|e| BindError::Home(e.to_string()))?;
181	    if dirty {
182	        let _ = vis.save();
183	    }
184	    info::write_info(&perch_path, &rec).map_err(|e| BindError::Io(e.to_string()))?;
185	    std::fs::write(perch::resolve_ready_file(id, ParentHint::Infer), "")
186	        .map_err(|e| BindError::Io(e.to_string()))?;
187
188	    // Keep an existing token across a revive; mint one for a fresh perch.
189	    let token = auth::read_token(&perch_path).unwrap_or_else(auth::mint_token);
190	    auth::write_token(&perch_path, &token).map_err(|e| BindError::Io(e.to_string()))?;
191	    Ok(token)
192	}
193
194	/// `api listen <id>` — consume the seed, bind the perch, then run the relay
195	/// loop (heir to `$LIVE start`'s Monitor-bound poll loop). `--once` drains and
196	/// exits (testability); otherwise blocks streaming messages to stdout.
197	///
198	/// When the manifest is **live-capable** (declares `[session.psyche_init]`), the
199	/// loop also hosts the LiveAgent lifecycle over the M2b `spt-live` seams: sweep
200	/// stale signoff on start (3.2), spawn the Psyche (T2), and run a pulse tick
201	/// each cycle (drop ingest + echo fires — T3/T4/T5). The graceful signoff (T6)
202	/// is driven by the shutdown path; M3's daemon re-hosts all of it.
203	// [impl->REQ-START-2]
204	// [impl->REQ-SEAM-PSYCHE]
205	pub fn cmd_listen(
206	    id: &str,
207	    parent_pid_override: Option<u32>,
208	    once: bool,
209	    manifest: Option<&spt_runtime::Manifest>,
210	    subnet: Option<&str>,
211	) -> i32 {
212	    // [impl->REQ-HAZARD-ID-CHARSET] reject malformed ids at the creation seam.
213	    if let Err(e) = spt_proto::id::validate_endpoint_id(id) {
214	        eprintln!("INVALID_ID:{id}: {e}");
215	        return EXIT_REFUSED;
216	    }
217	    let parent_pid = match parent_pid_override.or_else(proc::parent_pid) {
218	        Some(p) => p,
219	        None => {
220	            eprintln!("NO_PARENT_PID: could not resolve the parent pid anchor");
221	            return EXIT_REFUSED;
222	        }
223	    };
224	    let token = match bind_from_seed(id, parent_pid, subnet) {
225	        Ok(t) => t,
226	        Err(e) => {
227	            report_bind_error(&e);
228	            return EXIT_REFUSED;
229	        }
230	    };
231	    eprintln!("BOUND:{id} token={token}");
232
233	    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
234	    let listener = match PollListener::bind_blocking(id, RELAY_POLL) {
235	        Ok(l) => l,
236	        Err(e) => {
237	            eprintln!("LISTEN_FAIL:{id}: {e}");
238	            return EXIT_REFUSED;
239	        }
240	    };
241
242	    // The bound session id (from the seed) — fills the lifecycle templates.
243	    let session_id = info::read_info(&perch_path)
244	        .map(|i| i.session_id)
245	        .filter(|s| !s.is_empty());
246
247	    // Live-agent lifecycle host (interim; M3's daemon re-hosts). Sweep a stale
248	    // signoff sentinel (3.2) then spawn the Psyche companion (T2) before READY.
249	    let live = super::live::LiveHost::new_opt(manifest, id);
250	    if let Some(host) = &live {
251	        host.sweep_on_start();
252	        match host.spawn_psyche(session_id.as_deref(), None) {
253	            Ok(p) => eprintln!("PSYCHE_SPAWNED:{} pid={}", p.id, p.pid),
254	            Err(e) => eprintln!("PSYCHE_SPAWN_FAIL:{id}: {e}"),
255	        }
256	    }
257
258	    eprintln!("READY:{id}");
259
260	    // New-session-start is a reported resurface boundary (ADR-0007/D8b):
261	    // undismissed notifs surface into the spool before the backlog drain, so
262	    // they ride this very first drain — and render endpoint-native when the
263	    // manifest declares [session.notif]. Best-effort — never fails the listen.
264	    super::reporting::resurface_notifs_with(id, manifest);
265
266	    // Drain the offline backlog first (non-deferred only; deferred rows wait
267	    // for the hook channel).
268	    for msg in spool::drain_non_deferred_at(&perch_path).unwrap_or_default() {
269	        emit(&msg);
270	    }
271	    // One pulse tick: ingest drops + fire a pending echo (T4/T3/T5).
272	    let pulse = |host: &Option<super::live::LiveHost>| {
273	        if let Some(h) = host {
274	            if let Err(e) = h.pulse_tick(session_id.as_deref()) {
275	                eprintln!("PULSE_FAIL:{id}: {e}");
276	            }
277	        }
278	    };
279	    if once {
280	        if let Some(msg) = listener.check_message_blocking() {
281	            emit(&msg);
282	        }
283	        for msg in spool::drain_non_deferred_at(&perch_path).unwrap_or_default() {
284	            emit(&msg);
285	        }
286	        pulse(&live);
287	        return 0;
288	    }
289	    loop {
290	        if let Some(msg) = listener.check_message_blocking() {
291	            emit(&msg);
292	        }
293	        for msg in spool::drain_non_deferred_at(&perch_path).unwrap_or_default() {
294	            emit(&msg);
295	        }
296	        pulse(&live);
297	    }
298	}
299
300	/// `api bind <id>` — post-spawn boot bind (REQ-SEAM-POSTSPAWN). Establishes the
301	/// live perch on first contact, or flips an existing skeleton, keyed to the
302	/// session id the binary reports. Auth is intrinsic: an existing *live* perch
303	/// under a different session id is refused (you can only bind your own).
304	// [impl->REQ-SEAM-POSTSPAWN]
305	pub fn cmd_bind(
306	    id: &str,
307	    set_session_id: Option<String>,
308	    proof_session: Option<String>,
309	    adapter: Option<&str>,
310	    subnet: Option<&str>,
311	) -> i32 {
312	    // [impl->REQ-HAZARD-ID-CHARSET] reject malformed ids at the creation seam.
313	    if let Err(e) = spt_proto::id::validate_endpoint_id(id) {
314	        eprintln!("INVALID_ID:{id}: {e}");
315	        return EXIT_REFUSED;
316	    }
317	    let session_id = set_session_id
318	        .or(proof_session)
319	        .or_else(|| std::env::var("OWL_SESSION_ID").ok())
320	        .filter(|s| !s.is_empty());
321	    let Some(session_id) = session_id else {
322	        eprintln!("USAGE: bind needs --set-session-id (or $OWL_SESSION_ID)");
323	        return EXIT_REFUSED;
324	    };
325	    // The session's own anchor: the process that fired this hook (the session).
326	    let parent_pid = proc::parent_pid().unwrap_or_else(std::process::id);
327	    match establish_perch(id, &session_id, parent_pid, adapter, subnet) {
328	        Ok(token) => {
329	            eprintln!("BOUND:{id} token={token}");
330	            // A post-spawn bind is a new-session-start — a reported resurface
331	            // boundary (ADR-0007/D8b). Best-effort.
332	            super::reporting::resurface_notifs(id);
333	            0
334	        }
335	        Err(e) => {
336	            report_bind_error(&e);
337	            EXIT_REFUSED
338	        }
339	    }
340	}
341
342	fn report_bind_error(e: &BindError) {
343	    match e {
344	        BindError::NoSeed(p) => eprintln!("NO_SEED:{p} (no seed for this parent pid)"),
345	        BindError::StaleSeed(p) => eprintln!("STALE_SEED:{p} (anchor process is dead)"),
346	        BindError::EmptySession => eprintln!("EMPTY_SESSION: seed carried no session id"),
347	        BindError::Conflict { id, held } => {
348	            eprintln!("CONFLICT:{id} is live under session {held}")
349	        }
350	        BindError::Home(m) => eprintln!("HOME_REFUSED: {m}"),
351	        BindError::Io(m) => eprintln!("BIND_IO: {m}"),
352	    }
353	}
354
355	/// Print a framed message to stdout and flush (one delivery per emit).
356	fn emit(msg: &str) {
357	    use std::io::Write;
358	    print!("{msg}");
359	    let _ = std::io::stdout().flush();
360	}
361
362	/// Coarse creation timestamp (epoch seconds as a string), matching the
363	/// `info.json` `started` producer-format convention.
364	fn now_stamp() -> String {
365	    std::time::SystemTime::now()
366	        .duration_since(std::time::UNIX_EPOCH)
367	        .map(|d| d.as_secs().to_string())
368	        .unwrap_or_else(|_| "0".to_string())
369	}
370
371	#[cfg(test)]
372	mod tests {
373	    use super::*;
374	    use crate::testutil::isolated_home;
375	    use spt_daemon::SeedRegistry;
376	    use std::sync::Arc;
377	    use std::time::Duration;
378
379	    /// Stand up an in-process seed daemon on the current isolated home's seed
380	    /// socket and wait until it answers. Returns the shared registry so the test
381	    /// can seed it directly; the take path then consumes from it over the real
382	    /// IPC (no real `spt daemon` process spawned, since the channel already
383	    /// answers `ensure_running`'s ping). The serve thread is leaked (dies with the
384	    /// test process; each isolated home gets a distinct socket name).
385	    fn start_seed_daemon() -> Arc<SeedRegistry> {
386	        let reg = Arc::new(SeedRegistry::new());
387	        let name = spt_daemon::seed_socket_name();
388	        let srv = Arc::clone(&reg);
389	        std::thread::spawn(move || {
390	            let _ = spt_daemon::seedmap::serve_seed_control(&name, srv);
391	        });
392	        for _ in 0..400 {
393	            if spt_daemon::is_running() {
394	                return reg;
395	            }
396	            std::thread::sleep(Duration::from_millis(5));
397	        }
398	        panic!("seed daemon did not come up");
399	    }
400
401	    fn put_seed(reg: &SeedRegistry, parent_pid: u32, session_id: &str) {
402	        reg.put(Seed {
403	            parent_pid,
404	            session_id: session_id.into(),
405	            adapter: "mock".into(),
406	            cwd: None,
407	            created: "0".into(),
408	        });
409	    }
410
411	    // [unit->REQ-START-2] harness-hosted startup: a seed → listen binds the perch.
412	    #[test] // [unit->REQ-START-3] a valid seed (live anchor) binds a live perch.
413	    fn valid_seed_binds_live_perch() {
414	        let _h = isolated_home();
415	        let reg = start_seed_daemon();
416	        // Use this very process as the (alive) anchor.
417	        let anchor = std::process::id();
418	        put_seed(&reg, anchor, "sid-1");
419	        let token = bind_from_seed("alice", anchor, None).expect("bind");
420	        assert_eq!(token.len(), 32);
421
422	        let perch_path = perch::resolve_perch_path("alice", ParentHint::Infer);
423	        let rec = info::read_info(&perch_path).expect("info.json");
424	        assert_eq!(rec.session_id, "sid-1");
425	        assert_eq!(rec.state, "live_agent");
426	        assert_eq!(rec.parent_pid, Some(anchor));
427	        assert!(perch::resolve_ready_file("alice", ParentHint::Infer).exists());
428	        // Seed is consumed from the daemon's in-memory map (take-once).
429	        assert!(reg.peek(anchor).is_none());
430	    }
431
432	    #[test] // [unit->REQ-HAZARD-WINDOWS-PID-RECYCLE] a dead-anchor seed never binds.
433	    fn stale_seed_with_dead_anchor_refused() {
434	        let _h = isolated_home();
435	        let reg = start_seed_daemon();
436	        const DEAD: u32 = 2_000_000_000;
437	        put_seed(&reg, DEAD, "sid-1");
438	        let err = bind_from_seed("alice", DEAD, None).unwrap_err();
439	        assert_eq!(err, BindError::StaleSeed(DEAD));
440	        // No perch was created.
441	        assert!(info::read_info(&perch::resolve_perch_path("alice", ParentHint::Infer)).is_none());
442	    }
443
444	    // [unit->REQ-START-2] no seed for this listener → bind is refused, not bound.
445	    #[test] // [unit->REQ-START-3] absent seed is refused, not bound.
446	    fn missing_seed_refused() {
447	        let _h = isolated_home();
448	        let _reg = start_seed_daemon();
449	        let err = bind_from_seed("alice", std::process::id(), None).unwrap_err();
450	        assert!(matches!(err, BindError::NoSeed(_)));
451	    }
452
453	    #[test] // [unit->REQ-START-3] empty session id in the seed is refused.
454	    fn empty_session_seed_refused() {
455	        let _h = isolated_home();
456	        let reg = start_seed_daemon();
457	        let anchor = std::process::id();
458	        put_seed(&reg, anchor, "");
459	        assert_eq!(bind_from_seed("alice", anchor, None).unwrap_err(), BindError::EmptySession);
460	    }
461
462	    #[test] // [unit->REQ-HAZARD-WINDOWS-PID-RECYCLE] a live perch under another
463	    // session is not silently rebound (id-collision guard).
464	    fn live_perch_under_other_session_conflicts() {
465	        let _h = isolated_home();
466	        let reg = start_seed_daemon();
467	        let anchor = std::process::id();
468	        // First bind establishes alice@sid-1 owned by this (live) process.
469	        put_seed(&reg, anchor, "sid-1");
470	        bind_from_seed("alice", anchor, None).unwrap();
471	        // A second seed tries to claim alice for a different session.
472	        put_seed(&reg, anchor, "sid-2");
473	        let err = bind_from_seed("alice", anchor, None).unwrap_err();
474	        assert_eq!(err, BindError::Conflict { id: "alice".into(), held: "sid-1".into() });
475	    }
476
477	    // [unit->REQ-INST-15] home rides the bind seam: a NEW endpoint on a
478	    // sole-subnet node homes there (+ the seed's adapter records, + the sync
479	    // scope seeds); the RE-BIND — which rewrites info.json — carries home
480	    // forward (immutable); a NEW endpoint on a multi-subnet node refuses
481	    // without an explicit pick and binds with one.
482	    #[test]
483	    fn bind_assigns_home_and_revive_preserves_it() {
484	        let _h = isolated_home();
485	        let reg = start_seed_daemon();
486	        let anchor = std::process::id();
487
488	        let mut subnets = spt_store::subnet::SubnetStore::load();
489	        subnets.create_subnet("home").unwrap();
490	        subnets.save().unwrap();
491
492	        put_seed(&reg, anchor, "sid-1");
493	        bind_from_seed("alice", anchor, None).expect("bind");
494	        let perch_path = perch::resolve_perch_path("alice", ParentHint::Infer);
495	        let rec = info::read_info(&perch_path).unwrap();
496	        assert_eq!(rec.home_subnet.as_deref(), Some("home"), "sole subnet auto-homes");
497	        assert_eq!(rec.adapter.as_deref(), Some("mock"), "the seed's adapter records");
498	        let vis = spt_store::visibility::VisibilityStore::load();
499	        assert_eq!(
500	            vis.sync_subnets("alice"),
501	            Some(&["home".to_string()][..]),
502	            "creation seeds sync_subnets = [home] (the visibility seam, now real)"
503	        );
504
505	        // Re-bind rewrites info.json — home survives by carry-forward.
506	        put_seed(&reg, anchor, "sid-1");
507	        bind_from_seed("alice", anchor, None).expect("re-bind");
508	        assert_eq!(
509	            info::read_info(&perch_path).unwrap().home_subnet.as_deref(),
510	            Some("home"),
511	            "home is immutable across the re-bind"
512	        );
513
514	        // Multi-subnet node: a NEW endpoint refuses without an explicit pick.
515	        subnets.create_subnet("work").unwrap();
516	        subnets.save().unwrap();
517	        put_seed(&reg, anchor, "sid-9");
518	        let err = bind_from_seed("fresh", anchor, None).unwrap_err();
519	        assert!(matches!(err, BindError::Home(_)), "refuse-and-qualify: {err:?}");
520	        put_seed(&reg, anchor, "sid-9");
521	        bind_from_seed("fresh", anchor, Some("work")).expect("explicit pick binds");
522	        let fresh = perch::resolve_perch_path("fresh", ParentHint::Infer);
523	        assert_eq!(info::read_info(&fresh).unwrap().home_subnet.as_deref(), Some("work"));
524	    }
525
526	    #[test] // [unit->REQ-SEAM-POSTSPAWN] first-contact bind establishes a live perch.
527	    fn post_spawn_bind_establishes_perch() {
528	        let _h = isolated_home();
529	        let code = cmd_bind("bob", Some("sid-boot".into()), None, None, None);
530	        assert_eq!(code, 0);
531	        let rec = info::read_info(&perch::resolve_perch_path("bob", ParentHint::Infer)).unwrap();
532	        assert_eq!(rec.session_id, "sid-boot");
533	        assert_eq!(rec.state, "live_agent");
534	    }
535
536	    #[test] // [unit->REQ-SEAM-POSTSPAWN] re-bind with the same session is idempotent.
537	    fn rebind_same_session_ok() {
538	        let _h = isolated_home();
539	        assert_eq!(cmd_bind("bob", Some("sid-boot".into()), None, None, None), 0);
540	        assert_eq!(cmd_bind("bob", Some("sid-boot".into()), None, None, None), 0);
541	    }
542	}
543

//! Harness-hosted startup (interim no-daemon): `api seed` → `api listen` → bind,
//! plus the post-spawn `api bind`.
//!
//! Flow (CONTEXT §Startup flows, harness-hosted):
//! 1. The harness's SessionStart hook fires `api seed --pid <parent_pid>
//!    --session-id <sid>` → the seed is stored in the **daemon's in-memory map**
//!    keyed by the stable parent pid (REQ-START-3; no file — B6 retired the
//!    `spt_store::seed` file bridge).
//! 2. The agent runs its `$SPT listen <id>` alias → `api listen` self-discovers
//!    its parent pid, TAKEs the seed from the daemon, validates it against
//!    pid-recycle (KNOWN-HAZARDS 5.1), binds the perch skeleton→live, and relays.
//! 3. Alternatively a just-spawned binary fires `api bind <id>` to associate
//!    itself post-spawn (REQ-SEAM-POSTSPAWN) — establishing the live perch on
//!    first contact, or flipping an existing skeleton.
//
// [impl->REQ-START-2]
// [impl->REQ-START-3]
// [impl->REQ-SEAM-POSTSPAWN]

use std::time::Duration;

use spt_msg::listener::PollListener;
use spt_store::info::{self, InfoJson};
use spt_store::perch::{self, ParentHint};
use spt_store::proc;
use spt_store::seed::Seed;
use spt_store::spool;

use super::auth;
use super::{Ctx, EXIT_REFUSED};

/// Poll window for the relay loop — bounds each blocking accept so the loop
/// stays responsive (mirrors M1's listener cadence).
const RELAY_POLL: Duration = Duration::from_millis(200);

/// `api seed` — record the ephemeral startup seed keyed by parent pid. No prior
/// perch exists, so this call is not auth-gated (it *establishes* the binding
/// material the gated calls later authenticate against).
///
/// REQ-START-3: the seed is PUT into the daemon's in-memory map over the
/// seed-control IPC — **no file is written**. The daemon is auto-started if
/// absent (REQ-DAEMON-3).
// [impl->REQ-START-3]
pub fn cmd_seed(ctx: &Ctx, parent_pid: u32, session_id: String) -> i32 {
    if session_id.is_empty() {
        eprintln!("USAGE: seed requires a non-empty --session-id");
        return EXIT_REFUSED;
    }
    let seed = Seed {
        parent_pid,
        session_id,
        adapter: ctx.adapter.clone(),
        cwd: std::env::current_dir()
            .ok()
            .map(|p| p.to_string_lossy().replace('\\', "/")),
        created: now_stamp(),
    };
    if let Err(e) = spt_daemon::ensure_running() {
        eprintln!("SEED_FAIL:{parent_pid}: daemon unavailable: {e}");
        return EXIT_REFUSED;
    }
    match spt_daemon::put_seed(&spt_daemon::seed_socket_name(), &seed) {
        Ok(()) => {
            eprintln!("SEEDED:{parent_pid}");
            0
        }
        Err(e) => {
            eprintln!("SEED_FAIL:{parent_pid}: {e}");
            EXIT_REFUSED
        }
    }
}

/// Why a seed-driven bind was refused. Each variant is a distinct diagnostic so
/// a stale/recycled seed never silently binds the wrong session.
#[derive(Debug, PartialEq, Eq)]
pub enum BindError {
    NoSeed(u32),
    /// The seed's anchor process is dead → recycled/abandoned (5.1).
    StaleSeed(u32),
    EmptySession,
    /// A *live* perch already holds this id under a different session (5.1).
    Conflict { id: String, held: String },
    /// Home assignment refused for a NEW endpoint (REQ-INST-15:
    /// refuse-and-qualify on a multi-subnet node — no silent guess).
    Home(String),
    Io(String),
}

/// Consume the seed for `parent_pid` and bind/revive the perch skeleton→live.
/// Pure of any listener I/O so it is unit-testable without spawning processes.
/// Returns the perch's capability token on success.
///
/// REQ-START-3: the seed is TAKEn (consume-once) from the daemon's in-memory map
/// — the take itself consumes it, so a stale/conflicting seed is spent and the
/// harness re-seeds on the next SessionStart rather than leaving it to re-bind.
// [impl->REQ-START-2]
// [impl->REQ-START-3]
// [impl->REQ-HAZARD-WINDOWS-PID-RECYCLE]
pub fn bind_from_seed(
    id: &str,
    parent_pid: u32,
    subnet: Option<&str>,
) -> Result<String, BindError> {
    let seed = take_seed_from_daemon(parent_pid)?.ok_or(BindError::NoSeed(parent_pid))?;
    if seed.session_id.is_empty() {
        return Err(BindError::EmptySession);
    }
    // Pid-recycle defense (5.1): anchor liveness on the stable parent pid. A
    // dead anchor means the harness that wrote the seed is gone — a recycled or
    // abandoned seed must never bind.
    if !proc::is_process_alive(seed.parent_pid) {
        return Err(BindError::StaleSeed(seed.parent_pid));
    }
    // The seed carries the creating adapter — the adapter-at-creation rule's
    // source on this path (REQ-INST-15).
    let adapter = Some(seed.adapter.as_str()).filter(|a| !a.is_empty());
    establish_perch(id, &seed.session_id, seed.parent_pid, adapter, subnet)
}

/// TAKE (consume) the seed for `parent_pid` from the daemon's in-memory map,
/// auto-starting the daemon if absent (REQ-DAEMON-3 / REQ-START-3).
fn take_seed_from_daemon(parent_pid: u32) -> Result<Option<Seed>, BindError> {
    spt_daemon::ensure_running()
        .map_err(|e| BindError::Io(format!("daemon unavailable: {e}")))?;
    spt_daemon::take_seed(&spt_daemon::seed_socket_name(), parent_pid)
        .map_err(|e| BindError::Io(e.to_string()))
}

/// Write the perch record (skeleton→live) + ready marker + capability token.
/// Reviving an existing perch keeps its token; a live perch held under a
/// different session id is a conflict (pid-recycle / id collision guard).
///
/// Creation-time fields ride the shared seam
/// ([`spt_store::home::stamp_creation_fields`], REQ-INST-15): a NEW perch
/// assigns its immutable home (refuse-and-qualify on a multi-subnet node) +
/// seeds `sync_subnets = [home]`; a revive **carries forward** home, the
/// adapter (an explicit one wins — resume-under-new), the resource blurb,
/// and the auto-suspend override — a re-bind rewrites `info.json` and must
/// not wipe them.
// [impl->REQ-INST-15]
fn establish_perch(
    id: &str,
    session_id: &str,
    parent_pid: u32,
    adapter: Option<&str>,
    subnet: Option<&str>,
) -> Result<String, BindError> {
    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);

    let prior = info::read_info(&perch_path);
    if let Some(existing) = &prior {
        let owner_alive = info::read_pid(&perch_path)
            .map(proc::is_process_alive)
            .unwrap_or(false);
        if owner_alive
            && !existing.session_id.is_empty()
            && existing.session_id != session_id
        {
            return Err(BindError::Conflict {
                id: id.to_string(),
                held: existing.session_id.clone(),
            });
        }
    }

    std::fs::create_dir_all(&perch_path).map_err(|e| BindError::Io(e.to_string()))?;
    let mut rec = InfoJson::new(id, &now_stamp(), std::process::id(), session_id, "live_agent");
    rec.parent_pid = Some(parent_pid);
    let subnets = spt_store::subnet::SubnetStore::load();
    let mut vis = spt_store::visibility::VisibilityStore::load();
    let dirty = spt_store::home::stamp_creation_fields(
        &mut rec,
        prior.as_ref(),
        &subnets,
        &mut vis,
        subnet,
        adapter,
    )
    .map_err(|e| BindError::Home(e.to_string()))?;
    if dirty {
        let _ = vis.save();
    }
    info::write_info(&perch_path, &rec).map_err(|e| BindError::Io(e.to_string()))?;
    std::fs::write(perch::resolve_ready_file(id, ParentHint::Infer), "")
        .map_err(|e| BindError::Io(e.to_string()))?;

    // Keep an existing token across a revive; mint one for a fresh perch.
    let token = auth::read_token(&perch_path).unwrap_or_else(auth::mint_token);
    auth::write_token(&perch_path, &token).map_err(|e| BindError::Io(e.to_string()))?;
    Ok(token)
}

/// `api listen <id>` — consume the seed, bind the perch, then run the relay
/// loop (heir to `$LIVE start`'s Monitor-bound poll loop). `--once` drains and
/// exits (testability); otherwise blocks streaming messages to stdout.
///
/// When the manifest is **live-capable** (declares `[session.psyche_init]`), the
/// loop also hosts the LiveAgent lifecycle over the M2b `spt-live` seams: sweep
/// stale signoff on start (3.2), spawn the Psyche (T2), and run a pulse tick
/// each cycle (drop ingest + echo fires — T3/T4/T5). The graceful signoff (T6)
/// is driven by the shutdown path; M3's daemon re-hosts all of it.
// [impl->REQ-START-2]
// [impl->REQ-SEAM-PSYCHE]
pub fn cmd_listen(
    id: &str,
    parent_pid_override: Option<u32>,
    once: bool,
    manifest: Option<&spt_runtime::Manifest>,
    subnet: Option<&str>,
) -> i32 {
    // [impl->REQ-HAZARD-ID-CHARSET] reject malformed ids at the creation seam.
    if let Err(e) = spt_proto::id::validate_endpoint_id(id) {
        eprintln!("INVALID_ID:{id}: {e}");
        return EXIT_REFUSED;
    }
    let parent_pid = match parent_pid_override.or_else(proc::parent_pid) {
        Some(p) => p,
        None => {
            eprintln!("NO_PARENT_PID: could not resolve the parent pid anchor");
            return EXIT_REFUSED;
        }
    };
    let token = match bind_from_seed(id, parent_pid, subnet) {
        Ok(t) => t,
        Err(e) => {
            report_bind_error(&e);
            return EXIT_REFUSED;
        }
    };
    eprintln!("BOUND:{id} token={token}");

    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
    let listener = match PollListener::bind_blocking(id, RELAY_POLL) {
        Ok(l) => l,
        Err(e) => {
            eprintln!("LISTEN_FAIL:{id}: {e}");
            return EXIT_REFUSED;
        }
    };

    // The bound session id (from the seed) — fills the lifecycle templates.
    let session_id = info::read_info(&perch_path)
        .map(|i| i.session_id)
        .filter(|s| !s.is_empty());

    // Live-agent lifecycle host (interim; M3's daemon re-hosts). Sweep a stale
    // signoff sentinel (3.2) then spawn the Psyche companion (T2) before READY.
    let live = super::live::LiveHost::new_opt(manifest, id);
    if let Some(host) = &live {
        host.sweep_on_start();
        match host.spawn_psyche(session_id.as_deref(), None) {
            Ok(p) => eprintln!("PSYCHE_SPAWNED:{} pid={}", p.id, p.pid),
            Err(e) => eprintln!("PSYCHE_SPAWN_FAIL:{id}: {e}"),
        }
    }

    eprintln!("READY:{id}");

    // New-session-start is a reported resurface boundary (ADR-0007/D8b):
    // undismissed notifs surface into the spool before the backlog drain, so
    // they ride this very first drain — and render endpoint-native when the
    // manifest declares [session.notif]. Best-effort — never fails the listen.
    super::reporting::resurface_notifs_with(id, manifest);

    // Drain the offline backlog first (non-deferred only; deferred rows wait
    // for the hook channel).
    for msg in spool::drain_non_deferred_at(&perch_path).unwrap_or_default() {
        emit(&msg);
    }
    // One pulse tick: ingest drops + fire a pending echo (T4/T3/T5).
    let pulse = |host: &Option<super::live::LiveHost>| {
        if let Some(h) = host {
            if let Err(e) = h.pulse_tick(session_id.as_deref()) {
                eprintln!("PULSE_FAIL:{id}: {e}");
            }
        }
    };
    if once {
        if let Some(msg) = listener.check_message_blocking() {
            emit(&msg);
        }
        for msg in spool::drain_non_deferred_at(&perch_path).unwrap_or_default() {
            emit(&msg);
        }
        pulse(&live);
        return 0;
    }
    loop {
        if let Some(msg) = listener.check_message_blocking() {
            emit(&msg);
        }
        for msg in spool::drain_non_deferred_at(&perch_path).unwrap_or_default() {
            emit(&msg);
        }
        pulse(&live);
    }
}

/// `api bind <id>` — post-spawn boot bind (REQ-SEAM-POSTSPAWN). Establishes the
/// live perch on first contact, or flips an existing skeleton, keyed to the
/// session id the binary reports. Auth is intrinsic: an existing *live* perch
/// under a different session id is refused (you can only bind your own).
// [impl->REQ-SEAM-POSTSPAWN]
pub fn cmd_bind(
    id: &str,
    set_session_id: Option<String>,
    proof_session: Option<String>,
    adapter: Option<&str>,
    subnet: Option<&str>,
) -> i32 {
    // [impl->REQ-HAZARD-ID-CHARSET] reject malformed ids at the creation seam.
    if let Err(e) = spt_proto::id::validate_endpoint_id(id) {
        eprintln!("INVALID_ID:{id}: {e}");
        return EXIT_REFUSED;
    }
    let session_id = set_session_id
        .or(proof_session)
        .or_else(|| std::env::var("OWL_SESSION_ID").ok())
        .filter(|s| !s.is_empty());
    let Some(session_id) = session_id else {
        eprintln!("USAGE: bind needs --set-session-id (or $OWL_SESSION_ID)");
        return EXIT_REFUSED;
    };
    // The session's own anchor: the process that fired this hook (the session).
    let parent_pid = proc::parent_pid().unwrap_or_else(std::process::id);
    match establish_perch(id, &session_id, parent_pid, adapter, subnet) {
        Ok(token) => {
            eprintln!("BOUND:{id} token={token}");
            // A post-spawn bind is a new-session-start — a reported resurface
            // boundary (ADR-0007/D8b). Best-effort.
            super::reporting::resurface_notifs(id);
            0
        }
        Err(e) => {
            report_bind_error(&e);
            EXIT_REFUSED
        }
    }
}

fn report_bind_error(e: &BindError) {
    match e {
        BindError::NoSeed(p) => eprintln!("NO_SEED:{p} (no seed for this parent pid)"),
        BindError::StaleSeed(p) => eprintln!("STALE_SEED:{p} (anchor process is dead)"),
        BindError::EmptySession => eprintln!("EMPTY_SESSION: seed carried no session id"),
        BindError::Conflict { id, held } => {
            eprintln!("CONFLICT:{id} is live under session {held}")
        }
        BindError::Home(m) => eprintln!("HOME_REFUSED: {m}"),
        BindError::Io(m) => eprintln!("BIND_IO: {m}"),
    }
}

/// Print a framed message to stdout and flush (one delivery per emit).
fn emit(msg: &str) {
    use std::io::Write;
    print!("{msg}");
    let _ = std::io::stdout().flush();
}

/// Coarse creation timestamp (epoch seconds as a string), matching the
/// `info.json` `started` producer-format convention.
fn now_stamp() -> String {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs().to_string())
        .unwrap_or_else(|_| "0".to_string())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::testutil::isolated_home;
    use spt_daemon::SeedRegistry;
    use std::sync::Arc;
    use std::time::Duration;

    /// Stand up an in-process seed daemon on the current isolated home's seed
    /// socket and wait until it answers. Returns the shared registry so the test
    /// can seed it directly; the take path then consumes from it over the real
    /// IPC (no real `spt daemon` process spawned, since the channel already
    /// answers `ensure_running`'s ping). The serve thread is leaked (dies with the
    /// test process; each isolated home gets a distinct socket name).
    fn start_seed_daemon() -> Arc<SeedRegistry> {
        let reg = Arc::new(SeedRegistry::new());
        let name = spt_daemon::seed_socket_name();
        let srv = Arc::clone(&reg);
        std::thread::spawn(move || {
            let _ = spt_daemon::seedmap::serve_seed_control(&name, srv);
        });
        for _ in 0..400 {
            if spt_daemon::is_running() {
                return reg;
            }
            std::thread::sleep(Duration::from_millis(5));
        }
        panic!("seed daemon did not come up");
    }

    fn put_seed(reg: &SeedRegistry, parent_pid: u32, session_id: &str) {
        reg.put(Seed {
            parent_pid,
            session_id: session_id.into(),
            adapter: "mock".into(),
            cwd: None,
            created: "0".into(),
        });
    }

    // [unit->REQ-START-2] harness-hosted startup: a seed → listen binds the perch.
    #[test] // [unit->REQ-START-3] a valid seed (live anchor) binds a live perch.
    fn valid_seed_binds_live_perch() {
        let _h = isolated_home();
        let reg = start_seed_daemon();
        // Use this very process as the (alive) anchor.
        let anchor = std::process::id();
        put_seed(&reg, anchor, "sid-1");
        let token = bind_from_seed("alice", anchor, None).expect("bind");
        assert_eq!(token.len(), 32);

        let perch_path = perch::resolve_perch_path("alice", ParentHint::Infer);
        let rec = info::read_info(&perch_path).expect("info.json");
        assert_eq!(rec.session_id, "sid-1");
        assert_eq!(rec.state, "live_agent");
        assert_eq!(rec.parent_pid, Some(anchor));
        assert!(perch::resolve_ready_file("alice", ParentHint::Infer).exists());
        // Seed is consumed from the daemon's in-memory map (take-once).
        assert!(reg.peek(anchor).is_none());
    }

    #[test] // [unit->REQ-HAZARD-WINDOWS-PID-RECYCLE] a dead-anchor seed never binds.
    fn stale_seed_with_dead_anchor_refused() {
        let _h = isolated_home();
        let reg = start_seed_daemon();
        const DEAD: u32 = 2_000_000_000;
        put_seed(&reg, DEAD, "sid-1");
        let err = bind_from_seed("alice", DEAD, None).unwrap_err();
        assert_eq!(err, BindError::StaleSeed(DEAD));
        // No perch was created.
        assert!(info::read_info(&perch::resolve_perch_path("alice", ParentHint::Infer)).is_none());
    }

    // [unit->REQ-START-2] no seed for this listener → bind is refused, not bound.
    #[test] // [unit->REQ-START-3] absent seed is refused, not bound.
    fn missing_seed_refused() {
        let _h = isolated_home();
        let _reg = start_seed_daemon();
        let err = bind_from_seed("alice", std::process::id(), None).unwrap_err();
        assert!(matches!(err, BindError::NoSeed(_)));
    }

    #[test] // [unit->REQ-START-3] empty session id in the seed is refused.
    fn empty_session_seed_refused() {
        let _h = isolated_home();
        let reg = start_seed_daemon();
        let anchor = std::process::id();
        put_seed(&reg, anchor, "");
        assert_eq!(bind_from_seed("alice", anchor, None).unwrap_err(), BindError::EmptySession);
    }

    #[test] // [unit->REQ-HAZARD-WINDOWS-PID-RECYCLE] a live perch under another
    // session is not silently rebound (id-collision guard).
    fn live_perch_under_other_session_conflicts() {
        let _h = isolated_home();
        let reg = start_seed_daemon();
        let anchor = std::process::id();
        // First bind establishes alice@sid-1 owned by this (live) process.
        put_seed(&reg, anchor, "sid-1");
        bind_from_seed("alice", anchor, None).unwrap();
        // A second seed tries to claim alice for a different session.
        put_seed(&reg, anchor, "sid-2");
        let err = bind_from_seed("alice", anchor, None).unwrap_err();
        assert_eq!(err, BindError::Conflict { id: "alice".into(), held: "sid-1".into() });
    }

    // [unit->REQ-INST-15] home rides the bind seam: a NEW endpoint on a
    // sole-subnet node homes there (+ the seed's adapter records, + the sync
    // scope seeds); the RE-BIND — which rewrites info.json — carries home
    // forward (immutable); a NEW endpoint on a multi-subnet node refuses
    // without an explicit pick and binds with one.
    #[test]
    fn bind_assigns_home_and_revive_preserves_it() {
        let _h = isolated_home();
        let reg = start_seed_daemon();
        let anchor = std::process::id();

        let mut subnets = spt_store::subnet::SubnetStore::load();
        subnets.create_subnet("home").unwrap();
        subnets.save().unwrap();

        put_seed(&reg, anchor, "sid-1");
        bind_from_seed("alice", anchor, None).expect("bind");
        let perch_path = perch::resolve_perch_path("alice", ParentHint::Infer);
        let rec = info::read_info(&perch_path).unwrap();
        assert_eq!(rec.home_subnet.as_deref(), Some("home"), "sole subnet auto-homes");
        assert_eq!(rec.adapter.as_deref(), Some("mock"), "the seed's adapter records");
        let vis = spt_store::visibility::VisibilityStore::load();
        assert_eq!(
            vis.sync_subnets("alice"),
            Some(&["home".to_string()][..]),
            "creation seeds sync_subnets = [home] (the visibility seam, now real)"
        );

        // Re-bind rewrites info.json — home survives by carry-forward.
        put_seed(&reg, anchor, "sid-1");
        bind_from_seed("alice", anchor, None).expect("re-bind");
        assert_eq!(
            info::read_info(&perch_path).unwrap().home_subnet.as_deref(),
            Some("home"),
            "home is immutable across the re-bind"
        );

        // Multi-subnet node: a NEW endpoint refuses without an explicit pick.
        subnets.create_subnet("work").unwrap();
        subnets.save().unwrap();
        put_seed(&reg, anchor, "sid-9");
        let err = bind_from_seed("fresh", anchor, None).unwrap_err();
        assert!(matches!(err, BindError::Home(_)), "refuse-and-qualify: {err:?}");
        put_seed(&reg, anchor, "sid-9");
        bind_from_seed("fresh", anchor, Some("work")).expect("explicit pick binds");
        let fresh = perch::resolve_perch_path("fresh", ParentHint::Infer);
        assert_eq!(info::read_info(&fresh).unwrap().home_subnet.as_deref(), Some("work"));
    }

    #[test] // [unit->REQ-SEAM-POSTSPAWN] first-contact bind establishes a live perch.
    fn post_spawn_bind_establishes_perch() {
        let _h = isolated_home();
        let code = cmd_bind("bob", Some("sid-boot".into()), None, None, None);
        assert_eq!(code, 0);
        let rec = info::read_info(&perch::resolve_perch_path("bob", ParentHint::Infer)).unwrap();
        assert_eq!(rec.session_id, "sid-boot");
        assert_eq!(rec.state, "live_agent");
    }

    #[test] // [unit->REQ-SEAM-POSTSPAWN] re-bind with the same session is idempotent.
    fn rebind_same_session_ok() {
        let _h = isolated_home();
        assert_eq!(cmd_bind("bob", Some("sid-boot".into()), None, None, None), 0);
        assert_eq!(cmd_bind("bob", Some("sid-boot".into()), None, None, None), 0);
    }
}


Proceed with bug root cause analysis and fixes


ToolSearch

Question: select:TaskCreate,TaskUpdate


Question: select:TaskCreate,TaskUpdate


C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\daemon.rs-179-
crates\spt-daemon\src\daemon.rs-180-\ Auto-start (REQ-DAEMON-3): ensure a daemon is up for this home. A no-op if one
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\daemon.rs-181-/// already answers; otherwise spawn `spt daemon` detached and wait (bounded) for
crates\spt-daemon\src\daemon.rs-182-\ it to bind. Idempotent + race-tolerant: if two callers spawn at once, one
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\daemon.rs-183-/// wins the bind and the other exits — and this check passes either way.
crates\spt-daemon\src\daemon.rs:184:pub fn ensure_running() -> io::Result<()> {
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\daemon.rs-185-    if is_running() {
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\daemon.rs-186-        return Ok(());
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\daemon.rs-187-    }
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\daemon.rs-188-    spawn_detached()?;
crates\spt-daemon\src\daemon.rs-189-    let mut waited = Duration::ZERO;


TaskCreate

Fix 1: Non-admin daemon spawn (both OSes)

Daemon must always run unelevated in invoking user's universe. Windows: elevated command spawns elevated daemon → pipes deny unelevated clients → later spt spawn attempts get bind Access-denied. Linux: sudo'd command spawns root daemon/root state in user home. Fix: de-elevated spawn (Windows: unelevated shell token; Linux: drop to SUDO_UID) and/or pipe SDDL granting user session.


Task #1 created successfully: Fix 1: Non-admin daemon spawn (both OSes)


Fix 2: Ghost registry row eviction

Dead node identity rows persist forever in registry snapshots (no TTL/eviction; only per-(endpoint,node) epoch lease supersedes). Poisons bare-id resolution with phantom AcrossNodes ambiguity. Fix: decay rows unrefreshed for N pump cadences (reuse heard-map from D2); consider trust-prune verb.


Task #2 created successfully: Fix 2: Ghost registry row eviction

Fix 2: Ghost registry row eviction


Ride-along: AMBIGUOUS render as copy-paste targets

Resolution refusals should print valid qualified targets (sergey@gravity-linux, sergey@03854a12), labels preferred — not AcrossNodes([..hex..]) debug dump. Render fix at wansend/CLI boundary.


Task #3 created successfully: Ride-along: AMBIGUOUS render as copy-paste targets


Updated task #1 status


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	#[cfg(unix)]
18	use std::process::{Command, Stdio};
19	use std::sync::Arc;
20	use std::thread;
21	use std::time::Duration;
22
23	use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
24
25	use crate::broker::{default_journal_path, Broker};
26	use crate::config::{DaemonConfig, RelayChoice};
27	use crate::digesthub::{self, DigestHub};
28	use crate::endpoint::{broker_socket_name, daemon_pid_path, digest_socket_name, seed_socket_name};
29	use crate::nethost::{NetConfig, NetHost};
30	use crate::seedmap::{self, SeedRegistry};
31
32	/// How long auto-start waits for a freshly-spawned daemon to bind its channel.
33	const START_TIMEOUT: Duration = Duration::from_secs(5);
34	/// Poll cadence while waiting for the spawned daemon to come up.
35	const START_POLL: Duration = Duration::from_millis(25);
36
37	/// The per-machine broker/brain daemon process.
38	pub struct Daemon;
39
40	impl Daemon {
41	    /// Run the daemon: bind the broker (served on a background thread) + the
42	    /// seed-control channel (the foreground loop + liveness anchor) and serve
43	    /// until the process is killed. The body of `spt daemon`.
44	    pub fn run() -> io::Result<()> {
45	        let reg = Arc::new(SeedRegistry::new());
46
47	        // Boot housekeeping (KNOWN-HAZARDS 1.3): sweep stale `index.lock`
48	        // wedges out of the git-touching runtime the daemon hosts — the
49	        // context store's seed repo + its agent/project worktrees (a crashed
50	        // git leaves a 0-byte lock that blocks every later commit forever).
51	        // Path composed via the layout authority without opening the full
52	        // ContextStore on the boot path; a lock-less (or store-less) home
53	        // no-ops.
54	        // [impl->REQ-HAZARD-STALE-INDEX-LOCK]
55	        for lock in spt_store::branchstore::sweep_stale_index_locks(
56	            &spt_store::contextstore::seed_git_dir(&spt_store::perch::tracked_dir()),
57	            spt_store::branchstore::STALE_LOCK_AGE,
58	        ) {
59	            eprintln!("STALE_LOCK_SWEPT:{}", lock.display());
60	        }
61
62	        // The broker's network host (D4a, ADR-0004 §B): the node's iroh endpoint
63	        // + live QUIC connections live in the broker so they survive a brain
64	        // restart. Identity is the durable node.key (stable name across daemon
65	        // restarts); relay stance comes from daemon.json; mDNS rides the
66	        // endpoint (closing the §B mDNS/relay-session row by construction). A
67	        // net failure degrades to a net-less broker — it must not cost PTY
68	        // hosting (mirrors the broker-bind soft-fail below).
69	        // [impl->REQ-DAEMON-2]
70	        let mut node_hex: Option<String> = None;
71	        let net = {
72	            let cfg = DaemonConfig::load();
73	            let relay = match cfg.relay {
74	                RelayChoice::N0 => RelayPolicy::N0Default,
75	                RelayChoice::Disabled => RelayPolicy::Disabled,
76	            };
77	            match spt_store::nodeid::load_or_create() {
78	                Ok(identity) => {
79	                    node_hex = Some(identity.public_key().to_hex());
80	                    let net_cfg = NetConfig {
81	                        identity,
82	                        relay,
83	                        discovery: LocalDiscovery::Mdns,
84	                        scope: BindScope::All, // production: real interfaces
85	                    };
86	                    match NetHost::start(net_cfg) {
87	                        Ok(host) => Some(host),
88	                        Err(e) => {
89	                            eprintln!("NET_BIND_FAIL: {e} — broker runs net-less");
90	                            None
91	                        }
92	                    }
93	                }
94	                Err(e) => {
95	                    eprintln!("NODE_KEY_FAIL: {e} — broker runs net-less");
96	                    None
97	                }
98	            }
99	        };
100
101	        // The broker hosts PTYs (B1/B2) + the net endpoint (D4a). Serve it on a
102	        // background thread; a bind failure there must not stop the seed channel
103	        // (the B6 surface) — the PTY-hosting consumers arrive in B7/B8.
104	        let net_up = net.is_some();
105	        match Broker::bind_in_with_net(&broker_socket_name(), default_journal_path(), net) {
106	            Ok(broker) => {
107	                thread::spawn(move || {
108	                    let _ = broker.serve();
109	                });
110	                // Inbound net dispatch + outbound peer pump (D9-1): the
111	                // production trigger loops. Only meaningful with a net host —
112	                // a net-less broker accepts nothing and dials nobody.
113	                if net_up {
114	                    let hex = node_hex.clone().unwrap_or_default();
115	                    let registry = Arc::new(crate::registryhost::RegistryHost::new(&hex));
116	                    let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
117	                    let _ = crate::dispatch::spawn_dispatcher(
118	                        broker_socket_name(),
119	                        Arc::clone(&registry),
120	                        Arc::clone(&stop),
121	                    );
122	                    let cfg = DaemonConfig::load();
123	                    if cfg.peer_pump {
124	                        let _ = crate::peerloop::spawn_peer_pump(
125	                            broker_socket_name(),
126	                            registry,
127	                            cfg,
128	                            stop,
129	                        );
130	                    }
131	                }
132	            }
133	            Err(e) => eprintln!("BROKER_BIND_FAIL: {e}"),
134	        }
135
136	        // The digest hub (B8) holds the live per-endpoint PTY-digest projections;
137	        // the digest-control channel serves `spt digest <id>` snapshot pulls +
138	        // delta-stream subscribes against it. Served on a background thread like
139	        // the broker. The per-session feed that *populates* the hub is driven by
140	        // the brain (the parse is logic — ADR-0004 §B), wired as daemon-hosted
141	        // sessions are spawned through the broker; a bare daemon serves the
142	        // channel and answers "no live digest" until a session is hosted.
143	        let digest_hub = Arc::new(DigestHub::new());
144	        {
145	            let hub = Arc::clone(&digest_hub);
146	            thread::spawn(move || {
147	                let _ = digesthub::serve_digest_control(&digest_socket_name(), hub);
148	            });
149	        }
150
151	        // Shell wake-watchers (M5-D4b): the daemon hosts the offline half of
152	        // the shell online/offline exclusivity — a reconcile loop that runs
153	        // each offline instance's manifest `wake_command` as a supervised
154	        // watcher child (exit-opcode → wake resolution) and stops it when the
155	        // binary side wins. Boot sweep doubles as orphan cleanup after a
156	        // daemon crash. The daemon runs until killed, so the stop flag here
157	        // is held for symmetry with the dispatcher loops, never raised.
158	        // [impl->REQ-SHELL-2]
159	        {
160	            let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
161	            let _ = crate::shellwake::spawn_wake_host(stop);
162	        }
163
164	        // Best-effort breadcrumb: record our pid for management/cleanup. Liveness
165	        // is the socket ping, never this file, so a stale value is harmless.
166	        let _ = std::fs::write(daemon_pid_path(), std::process::id().to_string());
167
168	        // The seed-control channel is the foreground loop. Binding it is what
169	        // arbitrates the single-daemon-per-home invariant: a second daemon racing
170	        // to start fails this bind and exits, leaving the winner serving.
171	        seedmap::serve_seed_control(&seed_socket_name(), reg)
172	    }
173	}
174
175	/// Whether a daemon is already serving the seed-control channel for this home.
176	pub fn is_running() -> bool {
177	    seedmap::ping(&seed_socket_name()).is_ok()
178	}
179
180	/// Auto-start (REQ-DAEMON-3): ensure a daemon is up for this home. A no-op if one
181	/// already answers; otherwise spawn `spt daemon` detached and wait (bounded) for
182	/// it to bind. Idempotent + race-tolerant: if two callers spawn at once, one
183	/// wins the bind and the other exits — and this check passes either way.
184	pub fn ensure_running() -> io::Result<()> {
185	    if is_running() {
186	        return Ok(());
187	    }
188	    spawn_detached()?;
189	    let mut waited = Duration::ZERO;
190	    while waited < START_TIMEOUT {
191	        if is_running() {
192	            return Ok(());
193	        }
194	        thread::sleep(START_POLL);
195	        waited += START_POLL;
196	    }
197	    Err(io::Error::new(
198	        io::ErrorKind::TimedOut,
199	        "daemon did not come up within the start window",
200	    ))
201	}
202
203	/// Spawn `spt daemon` as a detached background process. Inherits the environment
204	/// — so the child resolves the **same** `SPT_HOME`, hence the same canonical
205	/// socket names — but drops the std handles so it outlives the launching CLI
206	/// call. The child is independent (not killed when the parent exits): on unix it
207	/// is reparented to init when the launcher exits; on Windows a child is not in
208	/// the parent's job by default.
209	///
210	/// **Windows handle-inheritance guard (KNOWN-HAZARDS 5.6).** A caller may
211	/// capture this process's stdout/stderr through a pipe (`Command::output()`, a
212	/// harness hook reading the command's output). On Windows `CreateProcess` runs
213	/// with `bInheritHandles = TRUE`, so the long-lived daemon would inherit that
214	/// pipe's write end and hold it open forever — the capturing reader never sees
215	/// EOF and hangs. Worse, inheritable pipe handles flow through EVERY
216	/// `bInheritHandles = TRUE` hop: stripping only this process's *std* handles
217	/// (the first guard shipped here) still leaked a grandparent capture's pipe
218	/// sitting in our handle table as a stray. So detached long-lived children are
219	/// spawned with **`bInheritHandles = FALSE`** ([`detached_no_inherit`]) — zero
220	/// handles flow, whatever the ancestry. Unix is immune (Rust marks pipe fds
221	/// `CLOEXEC`) and keeps the plain `Command` path.
222	fn spawn_detached() -> io::Result<()> {
223	    let exe = std::env::current_exe()?;
224	    #[cfg(windows)]
225	    {
226	        detached_no_inherit(&exe.to_string_lossy(), &["daemon".to_string()])?;
227	        Ok(())
228	    }
229	    #[cfg(unix)]
230	    {
231	        Command::new(exe)
232	            .arg("daemon")
233	            .stdin(Stdio::null())
234	            .stdout(Stdio::null())
235	            .stderr(Stdio::null())
236	            .spawn()?;
237	        Ok(())
238	    }
239	}
240
241	/// Spawn `program args…` detached with **`bInheritHandles = FALSE`** — the KH
242	/// 5.6 guard in its complete form: no handle (std or stray grandparent pipe)
243	/// flows into the immortal child, so no capturing caller anywhere up the
244	/// process chain can be wedged by it. `DETACHED_PROCESS` ⇒ no console, so the
245	/// child's std handles are simply absent (every such child runs stdio-null by
246	/// design anyway). `lpApplicationName` stays NULL so `program` resolves via
247	/// PATH like `Command` would. Shared by the daemon ([`spawn_detached`]) and
248	/// the shell binary ([`crate::shellhost::launch_shell`]). Raw `CreateProcessW`
249	/// declared inline to avoid a winapi dependency for one call.
250	// [impl->REQ-HAZARD-DETACHED-PIPE-INHERIT]
251	#[cfg(windows)]
252	pub(crate) fn detached_no_inherit(program: &str, args: &[String]) -> io::Result<u32> {
253	    use std::os::windows::ffi::OsStrExt;
254
255	    #[repr(C)]
256	    struct StartupInfoW {
257	        cb: u32,
258	        reserved: *mut u16,
259	        desktop: *mut u16,
260	        title: *mut u16,
261	        x: u32,
262	        y: u32,
263	        x_size: u32,
264	        y_size: u32,
265	        x_count_chars: u32,
266	        y_count_chars: u32,
267	        fill_attribute: u32,
268	        flags: u32,
269	        show_window: u16,
270	        cb_reserved2: u16,
271	        lp_reserved2: *mut u8,
272	        std_input: isize,
273	        std_output: isize,
274	        std_error: isize,
275	    }
276	    #[repr(C)]
277	    struct ProcessInformation {
278	        process: isize,
279	        thread: isize,
280	        pid: u32,
281	        tid: u32,
282	    }
283	    extern "system" {
284	        fn CreateProcessW(
285	            app: *const u16,
286	            cmdline: *mut u16,
287	            process_attrs: *mut core::ffi::c_void,
288	            thread_attrs: *mut core::ffi::c_void,
289	            inherit_handles: i32,
290	            flags: u32,
291	            env: *mut core::ffi::c_void,
292	            cwd: *const u16,
293	            startup: *mut StartupInfoW,
294	            out: *mut ProcessInformation,
295	        ) -> i32;
296	        fn CloseHandle(h: isize) -> i32;
297	    }
298	    // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW: no
299	    // inherited console, no popup window, its own signal group.
300	    const FLAGS: u32 = 0x0000_0008 | 0x0000_0200 | 0x0800_0000;
301
302	    let mut cmdline = String::new();
303	    quote_windows_arg(&mut cmdline, program);
304	    for a in args {
305	        cmdline.push(' ');
306	        quote_windows_arg(&mut cmdline, a);
307	    }
308	    let mut cmdline_w: Vec<u16> =
309	        std::ffi::OsStr::new(&cmdline).encode_wide().chain(std::iter::once(0)).collect();
310	    let mut si: StartupInfoW = unsafe { std::mem::zeroed() };
311	    si.cb = std::mem::size_of::<StartupInfoW>() as u32;
312	    let mut pi: ProcessInformation = unsafe { std::mem::zeroed() };
313	    let ok = unsafe {
314	        CreateProcessW(
315	            std::ptr::null(),
316	            cmdline_w.as_mut_ptr(),
317	            std::ptr::null_mut(),
318	            std::ptr::null_mut(),
319	            0, // bInheritHandles = FALSE — the whole point.
320	            FLAGS,
321	            std::ptr::null_mut(), // inherit this process's environment
322	            std::ptr::null(),
323	            &mut si,
324	            &mut pi,
325	        )
326	    };
327	    if ok == 0 {
328	        return Err(io::Error::last_os_error());
329	    }
330	    unsafe {
331	        CloseHandle(pi.process);
332	        CloseHandle(pi.thread);
333	    }
334	    Ok(pi.pid)
335	}
336
337	/// Append `arg` to a Windows command line under `CommandLineToArgvW` quoting
338	/// rules (quote when needed; double preceding backslashes before an embedded
339	/// quote; double trailing backslashes before the closing quote).
340	// [impl->REQ-HAZARD-DETACHED-PIPE-INHERIT]
341	#[cfg(windows)]
342	pub(crate) fn quote_windows_arg(out: &mut String, arg: &str) {
343	    if !arg.is_empty() && !arg.chars().any(|c| c == ' ' || c == '\t' || c == '"') {
344	        out.push_str(arg);
345	        return;
346	    }
347	    out.push('"');
348	    let mut backslashes = 0usize;
349	    for c in arg.chars() {
350	        if c == '\\' {
351	            backslashes += 1;
352	        } else if c == '"' {
353	            for _ in 0..(backslashes * 2 + 1) {
354	                out.push('\\');
355	            }
356	            backslashes = 0;
357	            out.push('"');
358	        } else {
359	            for _ in 0..backslashes {
360	                out.push('\\');
361	            }
362	            backslashes = 0;
363	            out.push(c);
364	        }
365	    }
366	    for _ in 0..(backslashes * 2) {
367	        out.push('\\');
368	    }
369	    out.push('"');
370	}
371
372	#[cfg(test)]
373	mod tests {
374	    use super::*;
375	    use crate::seedmap::{serve_seed_control, SeedRegistry};
376	    use crate::test_home::with_home;
377
378	    // [unit->REQ-HAZARD-DETACHED-PIPE-INHERIT] the no-inherit spawn's command
379	    // line obeys CommandLineToArgvW quoting: bare words pass through; spaces
380	    // quote; embedded quotes and the backslash-before-quote cases escape per
381	    // the rules (a wrong quoting would mis-tokenize the shell spawn template).
382	    #[cfg(windows)]
383	    #[test]
384	    fn windows_arg_quoting_roundtrips_the_rules() {
385	        let q = |arg: &str| {
386	            let mut s = String::new();
387	            quote_windows_arg(&mut s, arg);
388	            s
389	        };
390	        assert_eq!(q("daemon"), "daemon");
391	        assert_eq!(q(r"C:\bins\spt.exe"), r"C:\bins\spt.exe");
392	        assert_eq!(q(r"C:\Program Files\spt.exe"), r#""C:\Program Files\spt.exe""#);
393	        assert_eq!(q(""), r#""""#);
394	        assert_eq!(q(r#"say "hi""#), r#""say \"hi\"""#);
395	        assert_eq!(q(r"plain\back"), r"plain\back", "lone backslashes need no quoting");
396	        assert_eq!(q(r"trail ing\"), r#""trail ing\\""#, "trailing backslash doubles");
397	        assert_eq!(q(r#"back\"slash"#), r#""back\\\"slash""#);
398	    }
399
400	    // [unit->REQ-DAEMON-3] with no daemon up, is_running() is false; once a
401	    // seed-control server answers, it flips true and ensure_running() is a no-op
402	    // (the spawn branch — which would need the real `spt` binary — is not taken).
403	    #[test]
404	    fn is_running_tracks_the_seed_channel() {
405	        with_home(|_| {
406	            assert!(!is_running(), "no daemon bound yet");
407
408	            let reg = Arc::new(SeedRegistry::new());
409	            let name = seed_socket_name();
410	            let srv = Arc::clone(&reg);
411	            thread::spawn(move || {
412	                let _ = serve_seed_control(&name, srv);
413	            });
414	            for _ in 0..400 {
415	                if is_running() {
416	                    break;
417	                }
418	                thread::sleep(Duration::from_millis(5));
419	            }
420	            assert!(is_running(), "the seed channel answers → daemon is up");
421	            // ensure_running short-circuits (no real process spawn).
422	            ensure_running().expect("ensure is a no-op when already up");
423	        });
424	    }
425	}
426

//! 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;
#[cfg(unix)]
use std::process::{Command, Stdio};
use std::sync::Arc;
use std::thread;
use std::time::Duration;

use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};

use crate::broker::{default_journal_path, Broker};
use crate::config::{DaemonConfig, RelayChoice};
use crate::digesthub::{self, DigestHub};
use crate::endpoint::{broker_socket_name, daemon_pid_path, digest_socket_name, seed_socket_name};
use crate::nethost::{NetConfig, NetHost};
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());

        // Boot housekeeping (KNOWN-HAZARDS 1.3): sweep stale `index.lock`
        // wedges out of the git-touching runtime the daemon hosts — the
        // context store's seed repo + its agent/project worktrees (a crashed
        // git leaves a 0-byte lock that blocks every later commit forever).
        // Path composed via the layout authority without opening the full
        // ContextStore on the boot path; a lock-less (or store-less) home
        // no-ops.
        // [impl->REQ-HAZARD-STALE-INDEX-LOCK]
        for lock in spt_store::branchstore::sweep_stale_index_locks(
            &spt_store::contextstore::seed_git_dir(&spt_store::perch::tracked_dir()),
            spt_store::branchstore::STALE_LOCK_AGE,
        ) {
            eprintln!("STALE_LOCK_SWEPT:{}", lock.display());
        }

        // The broker's network host (D4a, ADR-0004 §B): the node's iroh endpoint
        // + live QUIC connections live in the broker so they survive a brain
        // restart. Identity is the durable node.key (stable name across daemon
        // restarts); relay stance comes from daemon.json; mDNS rides the
        // endpoint (closing the §B mDNS/relay-session row by construction). A
        // net failure degrades to a net-less broker — it must not cost PTY
        // hosting (mirrors the broker-bind soft-fail below).
        // [impl->REQ-DAEMON-2]
        let mut node_hex: Option<String> = None;
        let net = {
            let cfg = DaemonConfig::load();
            let relay = match cfg.relay {
                RelayChoice::N0 => RelayPolicy::N0Default,
                RelayChoice::Disabled => RelayPolicy::Disabled,
            };
            match spt_store::nodeid::load_or_create() {
                Ok(identity) => {
                    node_hex = Some(identity.public_key().to_hex());
                    let net_cfg = NetConfig {
                        identity,
                        relay,
                        discovery: LocalDiscovery::Mdns,
                        scope: BindScope::All, // production: real interfaces
                    };
                    match NetHost::start(net_cfg) {
                        Ok(host) => Some(host),
                        Err(e) => {
                            eprintln!("NET_BIND_FAIL: {e} — broker runs net-less");
                            None
                        }
                    }
                }
                Err(e) => {
                    eprintln!("NODE_KEY_FAIL: {e} — broker runs net-less");
                    None
                }
            }
        };

        // The broker hosts PTYs (B1/B2) + the net endpoint (D4a). 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.
        let net_up = net.is_some();
        match Broker::bind_in_with_net(&broker_socket_name(), default_journal_path(), net) {
            Ok(broker) => {
                thread::spawn(move || {
                    let _ = broker.serve();
                });
                // Inbound net dispatch + outbound peer pump (D9-1): the
                // production trigger loops. Only meaningful with a net host —
                // a net-less broker accepts nothing and dials nobody.
                if net_up {
                    let hex = node_hex.clone().unwrap_or_default();
                    let registry = Arc::new(crate::registryhost::RegistryHost::new(&hex));
                    let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
                    let _ = crate::dispatch::spawn_dispatcher(
                        broker_socket_name(),
                        Arc::clone(&registry),
                        Arc::clone(&stop),
                    );
                    let cfg = DaemonConfig::load();
                    if cfg.peer_pump {
                        let _ = crate::peerloop::spawn_peer_pump(
                            broker_socket_name(),
                            registry,
                            cfg,
                            stop,
                        );
                    }
                }
            }
            Err(e) => eprintln!("BROKER_BIND_FAIL: {e}"),
        }

        // The digest hub (B8) holds the live per-endpoint PTY-digest projections;
        // the digest-control channel serves `spt digest <id>` snapshot pulls +
        // delta-stream subscribes against it. Served on a background thread like
        // the broker. The per-session feed that *populates* the hub is driven by
        // the brain (the parse is logic — ADR-0004 §B), wired as daemon-hosted
        // sessions are spawned through the broker; a bare daemon serves the
        // channel and answers "no live digest" until a session is hosted.
        let digest_hub = Arc::new(DigestHub::new());
        {
            let hub = Arc::clone(&digest_hub);
            thread::spawn(move || {
                let _ = digesthub::serve_digest_control(&digest_socket_name(), hub);
            });
        }

        // Shell wake-watchers (M5-D4b): the daemon hosts the offline half of
        // the shell online/offline exclusivity — a reconcile loop that runs
        // each offline instance's manifest `wake_command` as a supervised
        // watcher child (exit-opcode → wake resolution) and stops it when the
        // binary side wins. Boot sweep doubles as orphan cleanup after a
        // daemon crash. The daemon runs until killed, so the stop flag here
        // is held for symmetry with the dispatcher loops, never raised.
        // [impl->REQ-SHELL-2]
        {
            let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
            let _ = crate::shellwake::spawn_wake_host(stop);
        }

        // 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 (KNOWN-HAZARDS 5.6).** 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` runs
/// 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. Worse, inheritable pipe handles flow through EVERY
/// `bInheritHandles = TRUE` hop: stripping only this process's *std* handles
/// (the first guard shipped here) still leaked a grandparent capture's pipe
/// sitting in our handle table as a stray. So detached long-lived children are
/// spawned with **`bInheritHandles = FALSE`** ([`detached_no_inherit`]) — zero
/// handles flow, whatever the ancestry. Unix is immune (Rust marks pipe fds
/// `CLOEXEC`) and keeps the plain `Command` path.
fn spawn_detached() -> io::Result<()> {
    let exe = std::env::current_exe()?;
    #[cfg(windows)]
    {
        detached_no_inherit(&exe.to_string_lossy(), &["daemon".to_string()])?;
        Ok(())
    }
    #[cfg(unix)]
    {
        Command::new(exe)
            .arg("daemon")
            .stdin(Stdio::null())
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .spawn()?;
        Ok(())
    }
}

/// Spawn `program args…` detached with **`bInheritHandles = FALSE`** — the KH
/// 5.6 guard in its complete form: no handle (std or stray grandparent pipe)
/// flows into the immortal child, so no capturing caller anywhere up the
/// process chain can be wedged by it. `DETACHED_PROCESS` ⇒ no console, so the
/// child's std handles are simply absent (every such child runs stdio-null by
/// design anyway). `lpApplicationName` stays NULL so `program` resolves via
/// PATH like `Command` would. Shared by the daemon ([`spawn_detached`]) and
/// the shell binary ([`crate::shellhost::launch_shell`]). Raw `CreateProcessW`
/// declared inline to avoid a winapi dependency for one call.
// [impl->REQ-HAZARD-DETACHED-PIPE-INHERIT]
#[cfg(windows)]
pub(crate) fn detached_no_inherit(program: &str, args: &[String]) -> io::Result<u32> {
    use std::os::windows::ffi::OsStrExt;

    #[repr(C)]
    struct StartupInfoW {
        cb: u32,
        reserved: *mut u16,
        desktop: *mut u16,
        title: *mut u16,
        x: u32,
        y: u32,
        x_size: u32,
        y_size: u32,
        x_count_chars: u32,
        y_count_chars: u32,
        fill_attribute: u32,
        flags: u32,
        show_window: u16,
        cb_reserved2: u16,
        lp_reserved2: *mut u8,
        std_input: isize,
        std_output: isize,
        std_error: isize,
    }
    #[repr(C)]
    struct ProcessInformation {
        process: isize,
        thread: isize,
        pid: u32,
        tid: u32,
    }
    extern "system" {
        fn CreateProcessW(
            app: *const u16,
            cmdline: *mut u16,
            process_attrs: *mut core::ffi::c_void,
            thread_attrs: *mut core::ffi::c_void,
            inherit_handles: i32,
            flags: u32,
            env: *mut core::ffi::c_void,
            cwd: *const u16,
            startup: *mut StartupInfoW,
            out: *mut ProcessInformation,
        ) -> i32;
        fn CloseHandle(h: isize) -> i32;
    }
    // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW: no
    // inherited console, no popup window, its own signal group.
    const FLAGS: u32 = 0x0000_0008 | 0x0000_0200 | 0x0800_0000;

    let mut cmdline = String::new();
    quote_windows_arg(&mut cmdline, program);
    for a in args {
        cmdline.push(' ');
        quote_windows_arg(&mut cmdline, a);
    }
    let mut cmdline_w: Vec<u16> =
        std::ffi::OsStr::new(&cmdline).encode_wide().chain(std::iter::once(0)).collect();
    let mut si: StartupInfoW = unsafe { std::mem::zeroed() };
    si.cb = std::mem::size_of::<StartupInfoW>() as u32;
    let mut pi: ProcessInformation = unsafe { std::mem::zeroed() };
    let ok = unsafe {
        CreateProcessW(
            std::ptr::null(),
            cmdline_w.as_mut_ptr(),
            std::ptr::null_mut(),
            std::ptr::null_mut(),
            0, // bInheritHandles = FALSE — the whole point.
            FLAGS,
            std::ptr::null_mut(), // inherit this process's environment
            std::ptr::null(),
            &mut si,
            &mut pi,
        )
    };
    if ok == 0 {
        return Err(io::Error::last_os_error());
    }
    unsafe {
        CloseHandle(pi.process);
        CloseHandle(pi.thread);
    }
    Ok(pi.pid)
}

/// Append `arg` to a Windows command line under `CommandLineToArgvW` quoting
/// rules (quote when needed; double preceding backslashes before an embedded
/// quote; double trailing backslashes before the closing quote).
// [impl->REQ-HAZARD-DETACHED-PIPE-INHERIT]
#[cfg(windows)]
pub(crate) fn quote_windows_arg(out: &mut String, arg: &str) {
    if !arg.is_empty() && !arg.chars().any(|c| c == ' ' || c == '\t' || c == '"') {
        out.push_str(arg);
        return;
    }
    out.push('"');
    let mut backslashes = 0usize;
    for c in arg.chars() {
        if c == '\\' {
            backslashes += 1;
        } else if c == '"' {
            for _ in 0..(backslashes * 2 + 1) {
                out.push('\\');
            }
            backslashes = 0;
            out.push('"');
        } else {
            for _ in 0..backslashes {
                out.push('\\');
            }
            backslashes = 0;
            out.push(c);
        }
    }
    for _ in 0..(backslashes * 2) {
        out.push('\\');
    }
    out.push('"');
}

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

    // [unit->REQ-HAZARD-DETACHED-PIPE-INHERIT] the no-inherit spawn's command
    // line obeys CommandLineToArgvW quoting: bare words pass through; spaces
    // quote; embedded quotes and the backslash-before-quote cases escape per
    // the rules (a wrong quoting would mis-tokenize the shell spawn template).
    #[cfg(windows)]
    #[test]
    fn windows_arg_quoting_roundtrips_the_rules() {
        let q = |arg: &str| {
            let mut s = String::new();
            quote_windows_arg(&mut s, arg);
            s
        };
        assert_eq!(q("daemon"), "daemon");
        assert_eq!(q(r"C:\bins\spt.exe"), r"C:\bins\spt.exe");
        assert_eq!(q(r"C:\Program Files\spt.exe"), r#""C:\Program Files\spt.exe""#);
        assert_eq!(q(""), r#""""#);
        assert_eq!(q(r#"say "hi""#), r#""say \"hi\"""#);
        assert_eq!(q(r"plain\back"), r"plain\back", "lone backslashes need no quoting");
        assert_eq!(q(r"trail ing\"), r#""trail ing\\""#, "trailing backslash doubles");
        assert_eq!(q(r#"back\"slash"#), r#""back\\\"slash""#);
    }

    // [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");
        });
    }
}