1	//! LiveAgent lifecycle hosting — a thin shim over the daemon's re-hosted
2	//! lifecycle ([`spt_daemon::BrainLifecycle`]).
3	//!
4	//! M2b hosted the LiveAgent lifecycle (Psyche spawn, pulse-driven drop ingest +
5	//! echo, graceful signoff ordering) directly in the `api listen` runner. B3
6	//! consolidated that orchestration **down into `spt-daemon`** (REQ-DAEMON-1); the
7	//! brain now runs it as scheduled in-process loops paced by a durable config.
8	//! This module keeps the binary's call sites stable — `cmd_listen` /
9	//! `cmd_shutdown` — by forwarding to `BrainLifecycle`. The logic lives once, in
10	//! the daemon crate; the duplicate here is gone.
11	//
12	// [impl->REQ-SEAM-PSYCHE]
13	// [impl->REQ-DAEMON-1]
14
15	use spt_daemon::{BrainLifecycle, TickReport};
16	use spt_live::{Ingested, PsycheHandle, SignoffOutcome};
17	use spt_runtime::Manifest;
18	use spt_store::perch::{self, ParentHint};
19
20	/// The LiveAgent lifecycle host — a thin newtype over the daemon's
21	/// [`BrainLifecycle`]. Built from a live-capable manifest; `None` for a plain
22	/// relay endpoint (no Psyche).
23	pub struct LiveHost(BrainLifecycle);
24
25	impl LiveHost {
26	    /// Build a host for `id` from a live-capable manifest; `None` if the manifest
27	    /// is absent or declares no Psyche.
28	    pub fn new_opt(manifest: Option<&Manifest>, id: &str) -> Option<LiveHost> {
29	        BrainLifecycle::new_opt(manifest, id).map(LiveHost)
30	    }
31
32	    /// Sweep a stale signoff sentinel on listener start (3.2).
33	    pub fn sweep_on_start(&self) {
34	        self.0.sweep_on_start();
35	    }
36
37	    /// Spawn the Psyche companion (T2).
38	    pub fn spawn_psyche(
39	        &self,
40	        session_id: Option<&str>,
41	        event: Option<&str>,
42	    ) -> Result<PsycheHandle, String> {
43	        self.0.spawn_psyche(session_id, event)
44	    }
45
46	    /// One pulse tick (T5): ingest drops (T4) then fire a pending echo (T3).
47	    /// Returns the ingested drops and whether an echo fired (the historical shape
48	    /// the `api listen` runner consumes).
49	    pub fn pulse_tick(&self, session_id: Option<&str>) -> Result<(Vec<Ingested>, bool), String> {
50	        let TickReport { ingested, echo_fired } = self.0.pulse_tick(session_id)?;
51	        Ok((ingested, echo_fired))
52	    }
53
54	    /// Execute the graceful signoff (T6): echo-commune before teardown (3.3),
55	    /// grace recheck before any signoff write (1.1).
56	    pub fn graceful_signoff(&self, session_id: Option<&str>) -> Result<SignoffOutcome, String> {
57	        self.0.graceful_signoff(session_id)
58	    }
59	}
60
61	/// `api shutdown <id>` — the graceful live-agent signoff path. Builds the live
62	/// host from the manifest and runs the echo-before-teardown ordering (T6). A
63	/// non-live manifest (no Psyche) is a plain soft-stop: just drop the `ready`
64	/// marker. Auth-gated by the dispatcher.
65	pub fn cmd_shutdown(id: &str, manifest: Option<&Manifest>) -> i32 {
66	    let session_id = perch_session_id(id);
67	    match LiveHost::new_opt(manifest, id) {
68	        Some(host) => match host.graceful_signoff(session_id.as_deref()) {
69	            Ok(outcome) => {
70	                eprintln!(
71	                    "SHUTDOWN:{id} echo_ran={} signed_off={}",
72	                    outcome.echo_ran, outcome.signed_off
73	                );
74	                0
75	            }
76	            Err(e) => {
77	                eprintln!("SHUTDOWN_FAIL:{id}: {e}");
78	                super::EXIT_REFUSED
79	            }
80	        },
81	        None => {
82	            // Non-live endpoint: soft-stop only (remove the online marker).
83	            let _ = std::fs::remove_file(perch::resolve_ready_file(id, ParentHint::Infer));
84	            eprintln!("SHUTDOWN:{id} (non-live soft-stop)");
85	            0
86	        }
87	    }
88	}
89
90	/// The bound session id from the perch's info.json, if any.
91	fn perch_session_id(id: &str) -> Option<String> {
92	    let path = perch::resolve_perch_path(id, ParentHint::Infer);
93	    spt_store::info::read_info(&path)
94	        .map(|i| i.session_id)
95	        .filter(|s| !s.is_empty())
96	}
97

//! LiveAgent lifecycle hosting — a thin shim over the daemon's re-hosted
//! lifecycle ([`spt_daemon::BrainLifecycle`]).
//!
//! M2b hosted the LiveAgent lifecycle (Psyche spawn, pulse-driven drop ingest +
//! echo, graceful signoff ordering) directly in the `api listen` runner. B3
//! consolidated that orchestration **down into `spt-daemon`** (REQ-DAEMON-1); the
//! brain now runs it as scheduled in-process loops paced by a durable config.
//! This module keeps the binary's call sites stable — `cmd_listen` /
//! `cmd_shutdown` — by forwarding to `BrainLifecycle`. The logic lives once, in
//! the daemon crate; the duplicate here is gone.
//
// [impl->REQ-SEAM-PSYCHE]
// [impl->REQ-DAEMON-1]

use spt_daemon::{BrainLifecycle, TickReport};
use spt_live::{Ingested, PsycheHandle, SignoffOutcome};
use spt_runtime::Manifest;
use spt_store::perch::{self, ParentHint};

/// The LiveAgent lifecycle host — a thin newtype over the daemon's
/// [`BrainLifecycle`]. Built from a live-capable manifest; `None` for a plain
/// relay endpoint (no Psyche).
pub struct LiveHost(BrainLifecycle);

impl LiveHost {
    /// Build a host for `id` from a live-capable manifest; `None` if the manifest
    /// is absent or declares no Psyche.
    pub fn new_opt(manifest: Option<&Manifest>, id: &str) -> Option<LiveHost> {
        BrainLifecycle::new_opt(manifest, id).map(LiveHost)
    }

    /// Sweep a stale signoff sentinel on listener start (3.2).
    pub fn sweep_on_start(&self) {
        self.0.sweep_on_start();
    }

    /// Spawn the Psyche companion (T2).
    pub fn spawn_psyche(
        &self,
        session_id: Option<&str>,
        event: Option<&str>,
    ) -> Result<PsycheHandle, String> {
        self.0.spawn_psyche(session_id, event)
    }

    /// One pulse tick (T5): ingest drops (T4) then fire a pending echo (T3).
    /// Returns the ingested drops and whether an echo fired (the historical shape
    /// the `api listen` runner consumes).
    pub fn pulse_tick(&self, session_id: Option<&str>) -> Result<(Vec<Ingested>, bool), String> {
        let TickReport { ingested, echo_fired } = self.0.pulse_tick(session_id)?;
        Ok((ingested, echo_fired))
    }

    /// Execute the graceful signoff (T6): echo-commune before teardown (3.3),
    /// grace recheck before any signoff write (1.1).
    pub fn graceful_signoff(&self, session_id: Option<&str>) -> Result<SignoffOutcome, String> {
        self.0.graceful_signoff(session_id)
    }
}

/// `api shutdown <id>` — the graceful live-agent signoff path. Builds the live
/// host from the manifest and runs the echo-before-teardown ordering (T6). A
/// non-live manifest (no Psyche) is a plain soft-stop: just drop the `ready`
/// marker. Auth-gated by the dispatcher.
pub fn cmd_shutdown(id: &str, manifest: Option<&Manifest>) -> i32 {
    let session_id = perch_session_id(id);
    match LiveHost::new_opt(manifest, id) {
        Some(host) => match host.graceful_signoff(session_id.as_deref()) {
            Ok(outcome) => {
                eprintln!(
                    "SHUTDOWN:{id} echo_ran={} signed_off={}",
                    outcome.echo_ran, outcome.signed_off
                );
                0
            }
            Err(e) => {
                eprintln!("SHUTDOWN_FAIL:{id}: {e}");
                super::EXIT_REFUSED
            }
        },
        None => {
            // Non-live endpoint: soft-stop only (remove the online marker).
            let _ = std::fs::remove_file(perch::resolve_ready_file(id, ParentHint::Infer));
            eprintln!("SHUTDOWN:{id} (non-live soft-stop)");
            0
        }
    }
}

/// The bound session id from the perch's info.json, if any.
fn perch_session_id(id: &str) -> Option<String> {
    let path = perch::resolve_perch_path(id, ParentHint::Infer);
    spt_store::info::read_info(&path)
        .map(|i| i.session_id)
        .filter(|s| !s.is_empty())
}


1	//! The thin, stateless in-session relay (B3) — the only residue of the interim
2	//! `api listen` runner that stays in the harness-hosted session (topology 1).
3	//!
4	//! Consolidation (REQ-DAEMON-1) moves the *lifecycle* — Psyche spawn, pulse,
5	//! echo, drop-ingest — into the daemon brain as scheduled in-process loops. What
6	//! remains running inside the agent's own session is just this relay: it forwards
7	//! inbound messages (the offline spool backlog + live poll deliveries) to a sink
8	//! (stdout for the real session) and holds **no state of its own**.
9	//!
10	//! Because it is stateless and every undelivered message is durable in the perch
11	//! spool, the relay is **freely killable and respawnable without losing state**:
12	//! a killed relay leaves its backlog in the spool, and a fresh relay re-drains it
13	//! on its next pump. That is the property the daemon needs — a routine that kills
14	//! the in-session relay (e.g. to swap it) never drops a message.
15	// [impl->REQ-DAEMON-1]
16
17	use std::path::{Path, PathBuf};
18	use std::sync::atomic::{AtomicBool, Ordering};
19	use std::time::Duration;
20
21	use spt_msg::listener::PollListener;
22	use spt_store::spool;
23
24	/// Poll window for the relay's blocking accept — bounds each cycle so the loop
25	/// stays responsive to a stop signal (mirrors the interim listener cadence).
26	pub const DEFAULT_RELAY_POLL: Duration = Duration::from_millis(200);
27
28	/// A stateless message relay for one perch: a poll listener bound to the id plus
29	/// the perch path it drains. It carries no message buffer — every byte it
30	/// forwards comes straight from the live poll or the durable spool.
31	pub struct Relay {
32	    id: String,
33	    perch_path: PathBuf,
34	    listener: PollListener,
35	}
36
37	impl Relay {
38	    /// Bind a relay for `id` draining `perch_path`, with the default poll window.
39	    pub fn bind(id: &str, perch_path: &Path) -> std::io::Result<Self> {
40	        Self::bind_with(id, perch_path, DEFAULT_RELAY_POLL)
41	    }
42
43	    /// Bind a relay with an explicit poll window.
44	    pub fn bind_with(id: &str, perch_path: &Path, poll: Duration) -> std::io::Result<Self> {
45	        let listener = PollListener::bind_blocking(id, poll)
46	            .map_err(|e| std::io::Error::other(e.to_string()))?;
47	        Ok(Relay { id: id.to_string(), perch_path: perch_path.to_path_buf(), listener })
48	    }
49
50	    /// The relayed endpoint id.
51	    pub fn id(&self) -> &str {
52	        &self.id
53	    }
54
55	    /// Drain the durable offline backlog (non-deferred spool rows) to `sink`.
56	    /// Idempotent across respawns: a fresh relay re-drains whatever a killed one
57	    /// left behind. Returns the number of messages forwarded.
58	    pub fn drain_backlog<F: FnMut(&str)>(&self, mut sink: F) -> usize {
59	        let msgs = spool::drain_non_deferred_at(&self.perch_path).unwrap_or_default();
60	        let n = msgs.len();
61	        for m in &msgs {
62	            sink(m);
63	        }
64	        n
65	    }
66
67	    /// One relay cycle: forward a single live poll delivery (if one arrives in the
68	    /// poll window) then drain the offline backlog. Holds no state between calls.
69	    pub fn pump_once<F: FnMut(&str)>(&self, mut sink: F) {
70	        if let Some(msg) = self.listener.check_message_blocking() {
71	            sink(&msg);
72	        }
73	        self.drain_backlog(&mut sink);
74	    }
75
76	    /// Run the relay until `stop` is set, forwarding every delivery to `sink`.
77	    /// The blocking poll bounds each iteration, so a kill signal is honored within
78	    /// one poll window. State loss is impossible: the loop buffers nothing.
79	    pub fn run<F: FnMut(&str)>(&self, stop: &AtomicBool, mut sink: F) {
80	        // Drain whatever a predecessor relay left behind before streaming live.
81	        self.drain_backlog(&mut sink);
82	        while !stop.load(Ordering::Relaxed) {
83	            self.pump_once(&mut sink);
84	        }
85	    }
86	}
87
88	#[cfg(test)]
89	mod tests {
90	    use super::*;
91	    use crate::test_home::with_home;
92	    use spt_store::perch::{resolve_perch_path, ParentHint};
93
94	    fn perch_for(id: &str) -> PathBuf {
95	        let p = resolve_perch_path(id, ParentHint::Infer);
96	        std::fs::create_dir_all(&p).unwrap();
97	        p
98	    }
99
100	    #[test] // [unit->REQ-DAEMON-1] a respawned relay re-drains a killed relay's backlog — no loss.
101	    fn respawn_loses_no_spooled_message() {
102	        with_home(|_| {
103	            let perch = perch_for("doyle");
104	            spool::spool_message_at(&perch, "alice", "m1").unwrap();
105	            spool::spool_message_at(&perch, "alice", "m2").unwrap();
106
107	            // Relay A binds then is "killed" (dropped) WITHOUT pumping. The two
108	            // messages remain durable in the spool — the relay held no state.
109	            {
110	                let _a = Relay::bind("doyle", &perch).unwrap();
111	            }
112	            assert_eq!(spool::pending_count_non_deferred_at(&perch).unwrap(), 2);
113
114	            // Relay B (respawn) drains the full backlog — nothing was lost.
115	            let b = Relay::bind("doyle", &perch).unwrap();
116	            let mut got = Vec::new();
117	            let n = b.drain_backlog(|m| got.push(m.to_string()));
118	            assert_eq!(n, 2);
119	            assert!(got.iter().any(|m| m.contains("m1")));
120	            assert!(got.iter().any(|m| m.contains("m2")));
121	            assert_eq!(spool::pending_count_non_deferred_at(&perch).unwrap(), 0);
122	        });
123	    }
124
125	    #[test] // [unit->REQ-DAEMON-1] run() exits promptly when the stop flag is set.
126	    fn run_honors_stop() {
127	        with_home(|_| {
128	            let perch = perch_for("doyle");
129	            let relay = Relay::bind_with("doyle", &perch, Duration::from_millis(20)).unwrap();
130	            let stop = AtomicBool::new(true); // already stopped: drains once, returns.
131	            let mut got = Vec::new();
132	            spool::spool_message_at(&perch, "alice", "hi").unwrap();
133	            relay.run(&stop, |m| got.push(m.to_string()));
134	            assert_eq!(got.len(), 1, "the pre-stream backlog drain still fires");
135	        });
136	    }
137	}
138

//! The thin, stateless in-session relay (B3) — the only residue of the interim
//! `api listen` runner that stays in the harness-hosted session (topology 1).
//!
//! Consolidation (REQ-DAEMON-1) moves the *lifecycle* — Psyche spawn, pulse,
//! echo, drop-ingest — into the daemon brain as scheduled in-process loops. What
//! remains running inside the agent's own session is just this relay: it forwards
//! inbound messages (the offline spool backlog + live poll deliveries) to a sink
//! (stdout for the real session) and holds **no state of its own**.
//!
//! Because it is stateless and every undelivered message is durable in the perch
//! spool, the relay is **freely killable and respawnable without losing state**:
//! a killed relay leaves its backlog in the spool, and a fresh relay re-drains it
//! on its next pump. That is the property the daemon needs — a routine that kills
//! the in-session relay (e.g. to swap it) never drops a message.
// [impl->REQ-DAEMON-1]

use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;

use spt_msg::listener::PollListener;
use spt_store::spool;

/// Poll window for the relay's blocking accept — bounds each cycle so the loop
/// stays responsive to a stop signal (mirrors the interim listener cadence).
pub const DEFAULT_RELAY_POLL: Duration = Duration::from_millis(200);

/// A stateless message relay for one perch: a poll listener bound to the id plus
/// the perch path it drains. It carries no message buffer — every byte it
/// forwards comes straight from the live poll or the durable spool.
pub struct Relay {
    id: String,
    perch_path: PathBuf,
    listener: PollListener,
}

impl Relay {
    /// Bind a relay for `id` draining `perch_path`, with the default poll window.
    pub fn bind(id: &str, perch_path: &Path) -> std::io::Result<Self> {
        Self::bind_with(id, perch_path, DEFAULT_RELAY_POLL)
    }

    /// Bind a relay with an explicit poll window.
    pub fn bind_with(id: &str, perch_path: &Path, poll: Duration) -> std::io::Result<Self> {
        let listener = PollListener::bind_blocking(id, poll)
            .map_err(|e| std::io::Error::other(e.to_string()))?;
        Ok(Relay { id: id.to_string(), perch_path: perch_path.to_path_buf(), listener })
    }

    /// The relayed endpoint id.
    pub fn id(&self) -> &str {
        &self.id
    }

    /// Drain the durable offline backlog (non-deferred spool rows) to `sink`.
    /// Idempotent across respawns: a fresh relay re-drains whatever a killed one
    /// left behind. Returns the number of messages forwarded.
    pub fn drain_backlog<F: FnMut(&str)>(&self, mut sink: F) -> usize {
        let msgs = spool::drain_non_deferred_at(&self.perch_path).unwrap_or_default();
        let n = msgs.len();
        for m in &msgs {
            sink(m);
        }
        n
    }

    /// One relay cycle: forward a single live poll delivery (if one arrives in the
    /// poll window) then drain the offline backlog. Holds no state between calls.
    pub fn pump_once<F: FnMut(&str)>(&self, mut sink: F) {
        if let Some(msg) = self.listener.check_message_blocking() {
            sink(&msg);
        }
        self.drain_backlog(&mut sink);
    }

    /// Run the relay until `stop` is set, forwarding every delivery to `sink`.
    /// The blocking poll bounds each iteration, so a kill signal is honored within
    /// one poll window. State loss is impossible: the loop buffers nothing.
    pub fn run<F: FnMut(&str)>(&self, stop: &AtomicBool, mut sink: F) {
        // Drain whatever a predecessor relay left behind before streaming live.
        self.drain_backlog(&mut sink);
        while !stop.load(Ordering::Relaxed) {
            self.pump_once(&mut sink);
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_home::with_home;
    use spt_store::perch::{resolve_perch_path, ParentHint};

    fn perch_for(id: &str) -> PathBuf {
        let p = resolve_perch_path(id, ParentHint::Infer);
        std::fs::create_dir_all(&p).unwrap();
        p
    }

    #[test] // [unit->REQ-DAEMON-1] a respawned relay re-drains a killed relay's backlog — no loss.
    fn respawn_loses_no_spooled_message() {
        with_home(|_| {
            let perch = perch_for("doyle");
            spool::spool_message_at(&perch, "alice", "m1").unwrap();
            spool::spool_message_at(&perch, "alice", "m2").unwrap();

            // Relay A binds then is "killed" (dropped) WITHOUT pumping. The two
            // messages remain durable in the spool — the relay held no state.
            {
                let _a = Relay::bind("doyle", &perch).unwrap();
            }
            assert_eq!(spool::pending_count_non_deferred_at(&perch).unwrap(), 2);

            // Relay B (respawn) drains the full backlog — nothing was lost.
            let b = Relay::bind("doyle", &perch).unwrap();
            let mut got = Vec::new();
            let n = b.drain_backlog(|m| got.push(m.to_string()));
            assert_eq!(n, 2);
            assert!(got.iter().any(|m| m.contains("m1")));
            assert!(got.iter().any(|m| m.contains("m2")));
            assert_eq!(spool::pending_count_non_deferred_at(&perch).unwrap(), 0);
        });
    }

    #[test] // [unit->REQ-DAEMON-1] run() exits promptly when the stop flag is set.
    fn run_honors_stop() {
        with_home(|_| {
            let perch = perch_for("doyle");
            let relay = Relay::bind_with("doyle", &perch, Duration::from_millis(20)).unwrap();
            let stop = AtomicBool::new(true); // already stopped: drains once, returns.
            let mut got = Vec::new();
            spool::spool_message_at(&perch, "alice", "hi").unwrap();
            relay.run(&stop, |m| got.push(m.to_string()));
            assert_eq!(got.len(), 1, "the pre-stream backlog drain still fires");
        });
    }
}


NOTE fROM THE USER: proceed with next steps.


1	//! The in-memory daemon seed map (B6 / REQ-START-3) — the in-memory replacement
2	//! for the retired `spt_store::seed` FILE bridge.
3	//!
4	//! Interim (M1/M2a) harness-hosted startup wrote an ephemeral seed *file* keyed
5	//! by the harness `parent_pid`: `api seed` wrote it, a later `api listen`
6	//! consumed it. M3b retires that file (the file path goes dead): the seed now
7	//! lives in the daemon's memory. `api seed` PUTs it over this seed-control IPC;
8	//! `api listen` TAKEs it (consume-once) by the same `parent_pid`. No file is ever
9	//! written — the acceptance for B6 (an spt-hosted session binds with no seed
10	//! file).
11	//!
12	//! The seed map is ephemeral cross-invocation **binding material** — the
13	//! in-memory analog of the file the daemon would otherwise hold on disk, the
14	//! same category as the accepted-socket bookkeeping the broker already owns
15	//! (ADR-0004 §B), not routing/registry/parse logic. It is intentionally tiny:
16	//! put / take by pid. Because seeds are ephemeral (refreshed on the next
17	//! SessionStart), a daemon restart dropping the map is recoverable — the harness
18	//! re-seeds.
19	//!
20	//! The control protocol rides the same forward-compatible [`Envelope`] + codec +
21	//! version handshake as the broker IPC; a transient `api` CLI connects as a
22	//! [`Role::Brain`] logic client, issues one op, reads the reply, and disconnects.
23	// [impl->REQ-START-3]
24
25	use std::collections::HashMap;
26	use std::io::{self, Read, Write};
27	use std::sync::{Arc, Mutex};
28	use std::thread;
29
30	use serde::{Deserialize, Serialize};
31
32	use spt_store::seed::Seed;
33
34	use crate::codec::{read_frame, write_frame};
35	use crate::frame::{Envelope, Role};
36	use crate::transport::{recv_hello, send_hello, DaemonTransport, LocalSocketTransport};
37
38	/// Client→daemon: store (or refresh) a seed in the in-memory map.
39	pub const KIND_SEED_PUT: &str = "seed_put";
40	/// Client→daemon: consume the seed for a `parent_pid` (take-once).
41	pub const KIND_SEED_TAKE: &str = "seed_take";
42	/// Daemon→client: the result of a put/take (the seed, or none).
43	pub const KIND_SEED_RESULT: &str = "seed_result";
44	/// Client→daemon: liveness probe (the auto-start readiness check).
45	pub const KIND_PING: &str = "ping";
46	/// Daemon→client: liveness ack.
47	pub const KIND_PONG: &str = "pong";
48
49	/// `seed_take` payload — which harness pid's seed to consume.
50	#[derive(Debug, Clone, Serialize, Deserialize)]
51	pub struct TakeReq {
52	    /// The stable harness parent pid the seed was keyed under.
53	    pub parent_pid: u32,
54	}
55
56	/// `seed_result` payload — the seed (present on a hit, absent on a miss). A put
57	/// echoes the stored seed so the client knows the daemon processed the frame
58	/// before it closes the connection.
59	#[derive(Debug, Clone, Default, Serialize, Deserialize)]
60	pub struct SeedResult {
61	    /// The seed, when present.
62	    #[serde(default, skip_serializing_if = "Option::is_none")]
63	    pub seed: Option<Seed>,
64	}
65
66	/// The daemon's in-memory seed map, keyed by the stable harness `parent_pid`.
67	#[derive(Default)]
68	pub struct SeedRegistry {
69	    map: Mutex<HashMap<u32, Seed>>,
70	}
71
72	impl SeedRegistry {
73	    /// A fresh, empty registry.
74	    pub fn new() -> Self {
75	        SeedRegistry { map: Mutex::new(HashMap::new()) }
76	    }
77
78	    /// Store (or refresh) a seed keyed by its `parent_pid`.
79	    pub fn put(&self, seed: Seed) {
80	        self.map.lock().unwrap().insert(seed.parent_pid, seed);
81	    }
82
83	    /// Consume the seed for `parent_pid` (take-once), if present.
84	    pub fn take(&self, parent_pid: u32) -> Option<Seed> {
85	        self.map.lock().unwrap().remove(&parent_pid)
86	    }
87
88	    /// Peek the seed for `parent_pid` without consuming (introspection / tests).
89	    pub fn peek(&self, parent_pid: u32) -> Option<Seed> {
90	        self.map.lock().unwrap().get(&parent_pid).cloned()
91	    }
92	}
93
94	/// Build a `seed_result` envelope carrying `seed` (or none).
95	fn result_frame(seed: Option<Seed>) -> Envelope {
96	    Envelope::new(
97	        KIND_SEED_RESULT,
98	        serde_json::to_value(SeedResult { seed }).expect("SeedResult serializes"),
99	    )
100	}
101
102	/// Bind the seed-control channel at `name` and serve it forever, dispatching
103	/// against `reg`. Each accepted connection is handled on **its own thread**
104	/// (mirroring the broker): a control client's connection lives until the client
105	/// closes it, and `handle_conn` blocks reading the next frame in the meantime, so
106	/// serving inline would let one open connection stall every other client's
107	/// `accept` — a deadlock that bites because a single `api` call opens several
108	/// short connections (ping, then put/take). Thread-per-connection removes that
109	/// coupling; a failure on one connection never disturbs the others.
110	pub fn serve_seed_control(name: &str, reg: Arc<SeedRegistry>) -> io::Result<()> {
111	    let listener = LocalSocketTransport::bind(name)?;
112	    loop {
113	        let conn = listener.accept()?;
114	        let reg = Arc::clone(&reg);
115	        thread::spawn(move || {
116	            let _ = handle_conn(conn, &reg);
117	        });
118	    }
119	}
120
121	/// Drive one control connection: handshake, then service its op frames until it
122	/// disconnects.
123	fn handle_conn<C: Read + Write>(mut conn: C, reg: &SeedRegistry) -> io::Result<()> {
124	    // A control client announces itself as a brain (a transient logic client of
125	    // the daemon); the version handshake gives the same forward-compat as the
126	    // broker IPC.
127	    recv_hello(&mut conn, Role::Brain)?;
128	    loop {
129	        let env = match read_frame(&mut conn) {
130	            Ok(e) => e,
131	            Err(_) => return Ok(()), // peer hung up — connection done
132	        };
133	        match env.kind.as_str() {
134	            KIND_SEED_PUT => {
135	                let seed: Seed = serde_json::from_value(env.payload)
136	                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
137	                let echo = seed.clone();
138	                reg.put(seed);
139	                // Echo the stored seed so the client knows the put landed.
140	                write_frame(&mut conn, &result_frame(Some(echo)))?;
141	            }
142	            KIND_SEED_TAKE => {
143	                let req: TakeReq = serde_json::from_value(env.payload)
144	                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
145	                write_frame(&mut conn, &result_frame(reg.take(req.parent_pid)))?;
146	            }
147	            KIND_PING => {
148	                write_frame(&mut conn, &Envelope::new(KIND_PONG, serde_json::Value::Null))?;
149	            }
150	            // Forward-compat: an unknown control kind is tolerated, not fatal.
151	            _ => {}
152	        }
153	    }
154	}
155
156	/// Connect to the seed-control channel `name` and announce ourselves as a brain
157	/// (the api CLI is a transient logic client of the daemon).
158	fn connect(name: &str) -> io::Result<interprocess::local_socket::Stream> {
159	    let mut conn = LocalSocketTransport::connect(name)?;
160	    send_hello(&mut conn, Role::Brain)?;
161	    Ok(conn)
162	}
163
164	/// PUT a seed into the daemon's in-memory map over the seed-control channel
165	/// `name`, waiting for the daemon's echo so the caller knows it landed before the
166	/// connection closes.
167	// [impl->REQ-START-3]
168	pub fn put_seed(name: &str, seed: &Seed) -> io::Result<()> {
169	    let mut conn = connect(name)?;
170	    write_frame(
171	        &mut conn,
172	        &Envelope::new(KIND_SEED_PUT, serde_json::to_value(seed).expect("Seed serializes")),
173	    )?;
174	    let _ack = read_frame(&mut conn)?; // the echoed result confirms processing
175	    Ok(())
176	}
177
178	/// TAKE (consume) the seed for `parent_pid` from the daemon's map over `name`.
179	/// `Ok(None)` means the daemon holds no seed for that pid.
180	// [impl->REQ-START-3]
181	pub fn take_seed(name: &str, parent_pid: u32) -> io::Result<Option<Seed>> {
182	    let mut conn = connect(name)?;
183	    write_frame(
184	        &mut conn,
185	        &Envelope::new(
186	            KIND_SEED_TAKE,
187	            serde_json::to_value(TakeReq { parent_pid }).expect("TakeReq serializes"),
188	        ),
189	    )?;
190	    let env = read_frame(&mut conn)?;
191	    let res: SeedResult = serde_json::from_value(env.payload)
192	        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
193	    Ok(res.seed)
194	}
195
196	/// Probe whether a daemon is serving the seed-control channel `name` (the
197	/// auto-start liveness check). `Ok(())` iff a daemon answered the ping.
198	pub fn ping(name: &str) -> io::Result<()> {
199	    let mut conn = connect(name)?;
200	    write_frame(&mut conn, &Envelope::new(KIND_PING, serde_json::Value::Null))?;
201	    let env = read_frame(&mut conn)?;
202	    if env.kind == KIND_PONG {
203	        Ok(())
204	    } else {
205	        Err(io::Error::new(io::ErrorKind::InvalidData, "expected pong"))
206	    }
207	}
208
209	#[cfg(test)]
210	mod tests {
211	    use super::*;
212	    use crate::endpoint::seed_socket_name;
213	    use crate::test_home::with_home;
214	    use std::sync::Arc;
215	    use std::time::Duration;
216
217	    /// Stand up an in-process seed daemon on the current home's seed socket and
218	    /// wait until it answers. Returns the shared registry so the test can inspect
219	    /// or seed it directly. The serve thread is leaked (dies with the process).
220	    fn start(reg: Arc<SeedRegistry>) {
221	        let name = seed_socket_name();
222	        let srv = Arc::clone(&reg);
223	        std::thread::spawn(move || {
224	            let _ = serve_seed_control(&name, srv);
225	        });
226	        for _ in 0..400 {
227	            if ping(&seed_socket_name()).is_ok() {
228	                return;
229	            }
230	            std::thread::sleep(Duration::from_millis(5));
231	        }
232	        panic!("seed daemon did not come up");
233	    }
234
235	    fn a_seed(pid: u32, sid: &str) -> Seed {
236	        Seed {
237	            parent_pid: pid,
238	            session_id: sid.into(),
239	            adapter: "mock".into(),
240	            cwd: None,
241	            created: "0".into(),
242	        }
243	    }
244
245	    // [unit->REQ-START-3] a seed PUT over IPC is TAKEn once: the second take
246	    // misses (consume-once), proving the in-memory map replaces the file bridge.
247	    #[test]
248	    fn put_then_take_consumes_once() {
249	        with_home(|_| {
250	            let reg = Arc::new(SeedRegistry::new());
251	            start(Arc::clone(&reg));
252	            let name = seed_socket_name();
253
254	            put_seed(&name, &a_seed(4321, "sid-xyz")).unwrap();
255	            let got = take_seed(&name, 4321).unwrap().expect("first take hits");
256	            assert_eq!(got.session_id, "sid-xyz");
257	            assert_eq!(got.parent_pid, 4321);
258	            // Consumed: a second take for the same pid misses.
259	            assert!(take_seed(&name, 4321).unwrap().is_none(), "take is consume-once");
260	        });
261	    }
262
263	    // [unit->REQ-START-3] taking a pid the daemon never saw is a clean miss.
264	    #[test]
265	    fn take_absent_is_none() {
266	        with_home(|_| {
267	            let reg = Arc::new(SeedRegistry::new());
268	            start(Arc::clone(&reg));
269	            assert!(take_seed(&seed_socket_name(), 999).unwrap().is_none());
270	        });
271	    }
272
273	    // [unit->REQ-START-3] a re-PUT for the same pid refreshes the stored seed.
274	    #[test]
275	    fn put_refreshes_existing() {
276	        with_home(|_| {
277	            let reg = Arc::new(SeedRegistry::new());
278	            start(Arc::clone(&reg));
279	            let name = seed_socket_name();
280	            put_seed(&name, &a_seed(7, "old")).unwrap();
281	            put_seed(&name, &a_seed(7, "new")).unwrap();
282	            assert_eq!(take_seed(&name, 7).unwrap().unwrap().session_id, "new");
283	        });
284	    }
285	}
286

//! The in-memory daemon seed map (B6 / REQ-START-3) — the in-memory replacement
//! for the retired `spt_store::seed` FILE bridge.
//!
//! Interim (M1/M2a) harness-hosted startup wrote an ephemeral seed *file* keyed
//! by the harness `parent_pid`: `api seed` wrote it, a later `api listen`
//! consumed it. M3b retires that file (the file path goes dead): the seed now
//! lives in the daemon's memory. `api seed` PUTs it over this seed-control IPC;
//! `api listen` TAKEs it (consume-once) by the same `parent_pid`. No file is ever
//! written — the acceptance for B6 (an spt-hosted session binds with no seed
//! file).
//!
//! The seed map is ephemeral cross-invocation **binding material** — the
//! in-memory analog of the file the daemon would otherwise hold on disk, the
//! same category as the accepted-socket bookkeeping the broker already owns
//! (ADR-0004 §B), not routing/registry/parse logic. It is intentionally tiny:
//! put / take by pid. Because seeds are ephemeral (refreshed on the next
//! SessionStart), a daemon restart dropping the map is recoverable — the harness
//! re-seeds.
//!
//! The control protocol rides the same forward-compatible [`Envelope`] + codec +
//! version handshake as the broker IPC; a transient `api` CLI connects as a
//! [`Role::Brain`] logic client, issues one op, reads the reply, and disconnects.
// [impl->REQ-START-3]

use std::collections::HashMap;
use std::io::{self, Read, Write};
use std::sync::{Arc, Mutex};
use std::thread;

use serde::{Deserialize, Serialize};

use spt_store::seed::Seed;

use crate::codec::{read_frame, write_frame};
use crate::frame::{Envelope, Role};
use crate::transport::{recv_hello, send_hello, DaemonTransport, LocalSocketTransport};

/// Client→daemon: store (or refresh) a seed in the in-memory map.
pub const KIND_SEED_PUT: &str = "seed_put";
/// Client→daemon: consume the seed for a `parent_pid` (take-once).
pub const KIND_SEED_TAKE: &str = "seed_take";
/// Daemon→client: the result of a put/take (the seed, or none).
pub const KIND_SEED_RESULT: &str = "seed_result";
/// Client→daemon: liveness probe (the auto-start readiness check).
pub const KIND_PING: &str = "ping";
/// Daemon→client: liveness ack.
pub const KIND_PONG: &str = "pong";

/// `seed_take` payload — which harness pid's seed to consume.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TakeReq {
    /// The stable harness parent pid the seed was keyed under.
    pub parent_pid: u32,
}

/// `seed_result` payload — the seed (present on a hit, absent on a miss). A put
/// echoes the stored seed so the client knows the daemon processed the frame
/// before it closes the connection.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SeedResult {
    /// The seed, when present.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub seed: Option<Seed>,
}

/// The daemon's in-memory seed map, keyed by the stable harness `parent_pid`.
#[derive(Default)]
pub struct SeedRegistry {
    map: Mutex<HashMap<u32, Seed>>,
}

impl SeedRegistry {
    /// A fresh, empty registry.
    pub fn new() -> Self {
        SeedRegistry { map: Mutex::new(HashMap::new()) }
    }

    /// Store (or refresh) a seed keyed by its `parent_pid`.
    pub fn put(&self, seed: Seed) {
        self.map.lock().unwrap().insert(seed.parent_pid, seed);
    }

    /// Consume the seed for `parent_pid` (take-once), if present.
    pub fn take(&self, parent_pid: u32) -> Option<Seed> {
        self.map.lock().unwrap().remove(&parent_pid)
    }

    /// Peek the seed for `parent_pid` without consuming (introspection / tests).
    pub fn peek(&self, parent_pid: u32) -> Option<Seed> {
        self.map.lock().unwrap().get(&parent_pid).cloned()
    }
}

/// Build a `seed_result` envelope carrying `seed` (or none).
fn result_frame(seed: Option<Seed>) -> Envelope {
    Envelope::new(
        KIND_SEED_RESULT,
        serde_json::to_value(SeedResult { seed }).expect("SeedResult serializes"),
    )
}

/// Bind the seed-control channel at `name` and serve it forever, dispatching
/// against `reg`. Each accepted connection is handled on **its own thread**
/// (mirroring the broker): a control client's connection lives until the client
/// closes it, and `handle_conn` blocks reading the next frame in the meantime, so
/// serving inline would let one open connection stall every other client's
/// `accept` — a deadlock that bites because a single `api` call opens several
/// short connections (ping, then put/take). Thread-per-connection removes that
/// coupling; a failure on one connection never disturbs the others.
pub fn serve_seed_control(name: &str, reg: Arc<SeedRegistry>) -> io::Result<()> {
    let listener = LocalSocketTransport::bind(name)?;
    loop {
        let conn = listener.accept()?;
        let reg = Arc::clone(&reg);
        thread::spawn(move || {
            let _ = handle_conn(conn, &reg);
        });
    }
}

/// Drive one control connection: handshake, then service its op frames until it
/// disconnects.
fn handle_conn<C: Read + Write>(mut conn: C, reg: &SeedRegistry) -> io::Result<()> {
    // A control client announces itself as a brain (a transient logic client of
    // the daemon); the version handshake gives the same forward-compat as the
    // broker IPC.
    recv_hello(&mut conn, Role::Brain)?;
    loop {
        let env = match read_frame(&mut conn) {
            Ok(e) => e,
            Err(_) => return Ok(()), // peer hung up — connection done
        };
        match env.kind.as_str() {
            KIND_SEED_PUT => {
                let seed: Seed = serde_json::from_value(env.payload)
                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                let echo = seed.clone();
                reg.put(seed);
                // Echo the stored seed so the client knows the put landed.
                write_frame(&mut conn, &result_frame(Some(echo)))?;
            }
            KIND_SEED_TAKE => {
                let req: TakeReq = serde_json::from_value(env.payload)
                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                write_frame(&mut conn, &result_frame(reg.take(req.parent_pid)))?;
            }
            KIND_PING => {
                write_frame(&mut conn, &Envelope::new(KIND_PONG, serde_json::Value::Null))?;
            }
            // Forward-compat: an unknown control kind is tolerated, not fatal.
            _ => {}
        }
    }
}

/// Connect to the seed-control channel `name` and announce ourselves as a brain
/// (the api CLI is a transient logic client of the daemon).
fn connect(name: &str) -> io::Result<interprocess::local_socket::Stream> {
    let mut conn = LocalSocketTransport::connect(name)?;
    send_hello(&mut conn, Role::Brain)?;
    Ok(conn)
}

/// PUT a seed into the daemon's in-memory map over the seed-control channel
/// `name`, waiting for the daemon's echo so the caller knows it landed before the
/// connection closes.
// [impl->REQ-START-3]
pub fn put_seed(name: &str, seed: &Seed) -> io::Result<()> {
    let mut conn = connect(name)?;
    write_frame(
        &mut conn,
        &Envelope::new(KIND_SEED_PUT, serde_json::to_value(seed).expect("Seed serializes")),
    )?;
    let _ack = read_frame(&mut conn)?; // the echoed result confirms processing
    Ok(())
}

/// TAKE (consume) the seed for `parent_pid` from the daemon's map over `name`.
/// `Ok(None)` means the daemon holds no seed for that pid.
// [impl->REQ-START-3]
pub fn take_seed(name: &str, parent_pid: u32) -> io::Result<Option<Seed>> {
    let mut conn = connect(name)?;
    write_frame(
        &mut conn,
        &Envelope::new(
            KIND_SEED_TAKE,
            serde_json::to_value(TakeReq { parent_pid }).expect("TakeReq serializes"),
        ),
    )?;
    let env = read_frame(&mut conn)?;
    let res: SeedResult = serde_json::from_value(env.payload)
        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
    Ok(res.seed)
}

/// Probe whether a daemon is serving the seed-control channel `name` (the
/// auto-start liveness check). `Ok(())` iff a daemon answered the ping.
pub fn ping(name: &str) -> io::Result<()> {
    let mut conn = connect(name)?;
    write_frame(&mut conn, &Envelope::new(KIND_PING, serde_json::Value::Null))?;
    let env = read_frame(&mut conn)?;
    if env.kind == KIND_PONG {
        Ok(())
    } else {
        Err(io::Error::new(io::ErrorKind::InvalidData, "expected pong"))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::endpoint::seed_socket_name;
    use crate::test_home::with_home;
    use std::sync::Arc;
    use std::time::Duration;

    /// Stand up an in-process seed daemon on the current home's seed socket and
    /// wait until it answers. Returns the shared registry so the test can inspect
    /// or seed it directly. The serve thread is leaked (dies with the process).
    fn start(reg: Arc<SeedRegistry>) {
        let name = seed_socket_name();
        let srv = Arc::clone(&reg);
        std::thread::spawn(move || {
            let _ = serve_seed_control(&name, srv);
        });
        for _ in 0..400 {
            if ping(&seed_socket_name()).is_ok() {
                return;
            }
            std::thread::sleep(Duration::from_millis(5));
        }
        panic!("seed daemon did not come up");
    }

    fn a_seed(pid: u32, sid: &str) -> Seed {
        Seed {
            parent_pid: pid,
            session_id: sid.into(),
            adapter: "mock".into(),
            cwd: None,
            created: "0".into(),
        }
    }

    // [unit->REQ-START-3] a seed PUT over IPC is TAKEn once: the second take
    // misses (consume-once), proving the in-memory map replaces the file bridge.
    #[test]
    fn put_then_take_consumes_once() {
        with_home(|_| {
            let reg = Arc::new(SeedRegistry::new());
            start(Arc::clone(&reg));
            let name = seed_socket_name();

            put_seed(&name, &a_seed(4321, "sid-xyz")).unwrap();
            let got = take_seed(&name, 4321).unwrap().expect("first take hits");
            assert_eq!(got.session_id, "sid-xyz");
            assert_eq!(got.parent_pid, 4321);
            // Consumed: a second take for the same pid misses.
            assert!(take_seed(&name, 4321).unwrap().is_none(), "take is consume-once");
        });
    }

    // [unit->REQ-START-3] taking a pid the daemon never saw is a clean miss.
    #[test]
    fn take_absent_is_none() {
        with_home(|_| {
            let reg = Arc::new(SeedRegistry::new());
            start(Arc::clone(&reg));
            assert!(take_seed(&seed_socket_name(), 999).unwrap().is_none());
        });
    }

    // [unit->REQ-START-3] a re-PUT for the same pid refreshes the stored seed.
    #[test]
    fn put_refreshes_existing() {
        with_home(|_| {
            let reg = Arc::new(SeedRegistry::new());
            start(Arc::clone(&reg));
            let name = seed_socket_name();
            put_seed(&name, &a_seed(7, "old")).unwrap();
            put_seed(&name, &a_seed(7, "new")).unwrap();
            assert_eq!(take_seed(&name, 7).unwrap().unwrap().session_id, "new");
        });
    }
}


1	//! Canonical per-machine daemon endpoint names + pid path (B6).
2	//!
3	//! The daemon binds two local-IPC listeners — the broker (PTY hosting, B1/B2)
4	//! and the seed-control channel (B6) — under names derived from the active
5	//! `spt_home`. Deriving from `spt_home` keeps each isolated home (a test's
6	//! `SPT_HOME` override, a per-user install) on its own daemon, so parallel test
7	//! processes never collide on one machine while a real install gets one stable
8	//! per-home daemon. The auto-start spawn inherits `SPT_HOME`, so the spawned
9	//! daemon computes the **same** names as the client that started it — that
10	//! shared derivation is the whole of the rendezvous (no registry lookup).
11	// [impl->REQ-DAEMON-3]
12
13	use std::hash::{Hash, Hasher};
14	use std::path::PathBuf;
15
16	use spt_store::perch::spt_home;
17
18	/// A deterministic short tag for the active spt home. `DefaultHasher::new()` is
19	/// seeded with fixed keys (it is **not** randomized like `RandomState`), so the
20	/// tag is stable across processes that share the same `spt_home` — exactly what
21	/// the auto-start handshake needs (the spawned daemon must independently derive
22	/// the launching client's socket name).
23	fn home_tag() -> String {
24	    let mut h = std::collections::hash_map::DefaultHasher::new();
25	    spt_home().hash(&mut h);
26	    format!("{:016x}", h.finish())
27	}
28
29	/// The seed-control listener name (B6): the channel `api seed`/`api listen`
30	/// connect to for the in-memory seed map. It is also the daemon's liveness
31	/// anchor — present for the daemon's whole lifetime.
32	pub fn seed_socket_name() -> String {
33	    format!("spt-daemon-seed-{}.sock", home_tag())
34	}
35
36	/// The broker listener name (B1/B2): PTY hosting + gapless handoff.
37	pub fn broker_socket_name() -> String {
38	    format!("spt-daemon-broker-{}.sock", home_tag())
39	}
40
41	/// The daemon's pid file: `<spt_home>/daemon.pid`. Best-effort breadcrumb the
42	/// running daemon writes on start — discoverable for management/cleanup. Not a
43	/// liveness signal (liveness is the seed-control ping); a stale pid here is
44	/// harmless because callers probe the socket, never this file.
45	pub fn daemon_pid_path() -> PathBuf {
46	    spt_home().join("daemon.pid")
47	}
48

//! Canonical per-machine daemon endpoint names + pid path (B6).
//!
//! The daemon binds two local-IPC listeners — the broker (PTY hosting, B1/B2)
//! and the seed-control channel (B6) — under names derived from the active
//! `spt_home`. Deriving from `spt_home` keeps each isolated home (a test's
//! `SPT_HOME` override, a per-user install) on its own daemon, so parallel test
//! processes never collide on one machine while a real install gets one stable
//! per-home daemon. The auto-start spawn inherits `SPT_HOME`, so the spawned
//! daemon computes the **same** names as the client that started it — that
//! shared derivation is the whole of the rendezvous (no registry lookup).
// [impl->REQ-DAEMON-3]

use std::hash::{Hash, Hasher};
use std::path::PathBuf;

use spt_store::perch::spt_home;

/// A deterministic short tag for the active spt home. `DefaultHasher::new()` is
/// seeded with fixed keys (it is **not** randomized like `RandomState`), so the
/// tag is stable across processes that share the same `spt_home` — exactly what
/// the auto-start handshake needs (the spawned daemon must independently derive
/// the launching client's socket name).
fn home_tag() -> String {
    let mut h = std::collections::hash_map::DefaultHasher::new();
    spt_home().hash(&mut h);
    format!("{:016x}", h.finish())
}

/// The seed-control listener name (B6): the channel `api seed`/`api listen`
/// connect to for the in-memory seed map. It is also the daemon's liveness
/// anchor — present for the daemon's whole lifetime.
pub fn seed_socket_name() -> String {
    format!("spt-daemon-seed-{}.sock", home_tag())
}

/// The broker listener name (B1/B2): PTY hosting + gapless handoff.
pub fn broker_socket_name() -> String {
    format!("spt-daemon-broker-{}.sock", home_tag())
}

/// The daemon's pid file: `<spt_home>/daemon.pid`. Best-effort breadcrumb the
/// running daemon writes on start — discoverable for management/cleanup. Not a
/// liveness signal (liveness is the seed-control ping); a stale pid here is
/// harmless because callers probe the socket, never this file.
pub fn daemon_pid_path() -> PathBuf {
    spt_home().join("daemon.pid")
}


Now check REQ-TERM-4 registry + history store (Path-B) + how history-log/native push works in spt-store.


Bash

grep -n "REQ-TERM-4" -A 20 traceable-reqs.toml | head -60

Show REQ-TERM-4 registry entry


346:id = "REQ-TERM-4"
347-title = "Live activity buffer (PTY digest): adapter-supplied patterns over broker PTY, spt digest pull + delta-stream, opt-in Path-B log"
348-required_stages = ["impl", "unit"]
349-[[requirements]]
350-id = "REQ-FRONT-1"
351-title = "Day-one launcher/manager frontend (list/launch/attach/init)"
352-required_stages = []
353-
354-# ───────────────────────────── Install / migration / infra ──────────────────
355-[[requirements]]
356-id = "REQ-INSTALL-1"
357-title = "Two install paths; signed one-line script; OS-service registration"
358-required_stages = []
359-[[requirements]]
360-id = "REQ-INSTALL-2"
361-title = "Marketplace-repackaging-friendly install"
362-required_stages = []
363-[[requirements]]
364-id = "REQ-INSTALL-3"
365-title = "Idempotent + interactive-optional first run"
366-required_stages = []


crates\spt-store\src\atomic.rs
crates\spt-store\src\proc.rs
crates\spt-store\src\db.rs
crates\spt-store\src\spool.rs
crates\spt-store\src\info.rs
crates\spt-store\src\perch.rs
crates\spt-store\src\liveness.rs
crates\spt-store\src\lib.rs
crates\spt-store\src\registry.rs
crates\spt-store\src\seed.rs


REQ-TERM-4 at `["impl","unit"]` — B8 adds `int`. No history.rs in spt-store. Check Path-B / history-log existence + api/mod structure.


grep -rn "history.log\|history_log\|history-log\|HistoryLog\|append_history\|native" crates/spt-store/src crates/spt-msg/src crates/spt/src 2>/dev/null | head -30

Search for Path-B history store wiring


1	//! The inbound half of the harness contract: the `spt api <cmd>` surface a
2	//! harness's hooks fire to mutate on-disk SPT state. Every call carries
3	//! `--adapter <name>` (the `adapter_name`), and every *mutation* is gated by
4	//! [`auth`] (REQ-HAZARD-LOCAL-API-AUTH).
5	//!
6	//! Interim no-daemon model (decision 2026-06-01): these commands mutate the
7	//! perch/spool/registry directly via spt-store/spt-msg, mirroring M1's
8	//! standalone listener. M3's `spt-daemon` swaps the on-disk seed + standalone
9	//! `listen` for an in-memory daemon record — the surface here stays thin so
10	//! that is a move, not a rewrite.
11	//!
12	//! Exit codes (superset of the messaging surface): `0` ok · `1` runtime refusal
13	//! (auth refused, no endpoint, bad manifest) · `2` usage (clap).
14	//
15	// [impl->REQ-API-1]
16
17	pub mod auth;
18	pub mod delivery;
19	pub mod live;
20	pub mod reporting;
21	pub mod startup;
22	pub mod worker;
23
24	use clap::{Args, Subcommand};
25	use std::path::PathBuf;
26
27	use spt_runtime::Manifest;
28
29	use auth::{authenticate, AuthResult, Proof};
30
31	/// Exit code for a runtime refusal (auth/no-endpoint).
32	pub const EXIT_REFUSED: i32 = 1;
33
34	/// The `api` command group. `--adapter` is required on every invocation; an
35	/// optional `--manifest` is parsed for commands that need the adapter contract
36	/// (spawn roles, capability advertisement).
37	#[derive(Args)]
38	pub struct ApiArgs {
39	    /// adapter_name — the calling harness adapter. Required on every `api` call.
40	    #[arg(long)]
41	    adapter: String,
42	    /// Path to the adapter's runtime manifest (when the command needs it).
43	    #[arg(long)]
44	    manifest: Option<PathBuf>,
45	    #[command(subcommand)]
46	    cmd: ApiCmd,
47	}
48
49	/// The inbound surface. Lifecycle-execution commands (psyche/commune) are M2b;
50	/// these are the M2a harness-contract entry points.
51	#[derive(Subcommand)]
52	pub enum ApiCmd {
53	    /// Harness-hosted startup: record an ephemeral seed keyed by parent pid (T4).
54	    Seed {
55	        #[arg(long)]
56	        pid: u32,
57	        #[arg(long)]
58	        session_id: String,
59	    },
60	    /// Consume a seed and hold the perch + relay loop (interim long-runner, T4).
61	    Listen {
62	        id: String,
63	        /// Override the parent-pid anchor (defaults to the self-discovered PPID).
64	        #[arg(long)]
65	        parent_pid: Option<u32>,
66	        /// Drain backlog + one receive cycle, then exit (testability).
67	        #[arg(long)]
68	        once: bool,
69	    },
70	    /// Post-spawn bind of a session to its perch (T4).
71	    Bind {
72	        id: String,
73	        /// The session id discovered post-spawn, written into the perch record.
74	        #[arg(long = "set-session-id")]
75	        bind_session: Option<String>,
76	        #[command(flatten)]
77	        auth: AuthFlags,
78	    },
79	    /// Set activity state busy|idle (+ echo-gate sentinel) (T5).
80	    State {
81	        #[arg(value_parser = ["busy", "idle"])]
82	        state: String,
83	        id: String,
84	        #[arg(long)]
85	        no_gate: bool,
86	        #[command(flatten)]
87	        auth: AuthFlags,
88	    },
89	    /// Manage the echo-gate sentinel directly (T5).
90	    EchoGate {
91	        #[arg(value_parser = ["set", "clear"])]
92	        action: String,
93	        id: String,
94	        #[command(flatten)]
95	        auth: AuthFlags,
96	    },
97	    /// Drain delivered messages (hook channel) (T5).
98	    Poll {
99	        id: String,
100	        #[arg(long)]
101	        include_deferred: bool,
102	        #[command(flatten)]
103	        auth: AuthFlags,
104	    },
105	    /// Create a nested worker perch under a parent (T6).
106	    WorkerStart {
107	        parent: String,
108	        id: String,
109	        #[command(flatten)]
110	        auth: AuthFlags,
111	    },
112	    /// Tear down a worker perch (T6).
113	    WorkerStop {
114	        id: String,
115	        #[command(flatten)]
116	        auth: AuthFlags,
117	    },
118	    /// Drain a worker perch's messages (T6).
119	    WorkerPoll {
120	        id: String,
121	        #[command(flatten)]
122	        auth: AuthFlags,
123	    },
124	    /// Rebind the perch to a new session_id, preserving identity (T7).
125	    Boundary {
126	        #[arg(value_parser = ["clear", "compact"])]
127	        mode: String,
128	        id: String,
129	        /// The new session id to rebind the perch to.
130	        #[arg(long = "to-session-id")]
131	        to_session: String,
132	        #[command(flatten)]
133	        auth: AuthFlags,
134	    },
135	    /// Soft teardown (spool/history preserved); `--erase` hard-wipes (T7).
136	    SessionEnd {
137	        id: String,
138	        #[arg(long)]
139	        erase: bool,
140	        #[command(flatten)]
141	        auth: AuthFlags,
142	    },
143	    /// Report presence (T7).
144	    Presence {
145	        id: String,
146	        #[command(flatten)]
147	        auth: AuthFlags,
148	    },
149	    /// Append to the native history store (Path-B) (T7).
150	    HistoryLog {
151	        id: String,
152	        #[command(flatten)]
153	        auth: AuthFlags,
154	    },
155	    /// Emit a Shell sensory payload (stub) (T7).
156	    Emit {
157	        id: String,
158	        #[arg(long)]
159	        r#type: String,
160	        #[command(flatten)]
161	        auth: AuthFlags,
162	    },
163	    /// Print the adapter's declared capability (`hostable_types`) (T7).
164	    Capability,
165	    /// Graceful live-agent signoff (M2b): run the final echo-commune BEFORE
166	    /// teardown (3.3), then soft-stop. The `spt shutdown` lifecycle path.
167	    Shutdown {
168	        id: String,
169	        #[command(flatten)]
170	        auth: AuthFlags,
171	    },
172	}
173
174	/// Auth proof flags shared by every mutating subcommand.
175	#[derive(Args)]
176	pub struct AuthFlags {
177	    /// Capability token proving association to the target perch.
178	    #[arg(long)]
179	    token: Option<String>,
180	    /// Session id proving association (matches the perch's info.json).
181	    #[arg(long)]
182	    session_id: Option<String>,
183	}
184
185	impl AuthFlags {
186	    fn proof(&self) -> Proof {
187	        Proof::resolve(self.token.clone(), self.session_id.clone())
188	    }
189	}
190
191	/// Resolved per-call context shared by handlers.
192	pub struct Ctx {
193	    /// The `adapter_name` carried on this call.
194	    pub adapter: String,
195	    /// Parsed manifest, when `--manifest` was supplied (and valid).
196	    pub manifest: Option<Manifest>,
197	}
198
199	/// Auto-start the per-machine daemon if absent (REQ-DAEMON-3). Best-effort: a
200	/// start failure is surfaced but does not abort commands that don't need the
201	/// daemon (e.g. `capability`); the seed/listen path re-checks and fails loudly if
202	/// the daemon is genuinely unreachable.
203	// [impl->REQ-DAEMON-3]
204	fn ensure_daemon() {
205	    if let Err(e) = spt_daemon::ensure_running() {
206	        eprintln!("DAEMON_START_WARN: {e}");
207	    }
208	}
209
210	/// Parse + dispatch the `api` group. Returns the process exit code.
211	pub fn run(args: ApiArgs) -> i32 {
212	    // Any api invocation brings the daemon up if absent — the reliable anchor
213	    // every harness hook shares (REQ-DAEMON-3).
214	    ensure_daemon();
215
216	    // Resolve the manifest up front so a bad path/content fails fast and
217	    // uniformly, before any state mutation.
218	    let manifest = match &args.manifest {
219	        Some(path) => match load_manifest(path, &args.adapter) {
220	            Ok(m) => Some(m),
221	            Err(code) => return code,
222	        },
223	        None => None,
224	    };
225	    let ctx = Ctx { adapter: args.adapter, manifest };
226
227	    match args.cmd {
228	        ApiCmd::Capability => reporting::cmd_capability(&ctx),
229	        // Harness-hosted startup (T4). Seed establishes; listen consumes+binds;
230	        // bind is the post-spawn establishing call (intrinsic auth).
231	        ApiCmd::Seed { pid, session_id } => startup::cmd_seed(&ctx, pid, session_id),
232	        ApiCmd::Listen { id, parent_pid, once } => {
233	            startup::cmd_listen(&id, parent_pid, once, ctx.manifest.as_ref())
234	        }
235	        ApiCmd::Bind { id, bind_session, auth } => {
236	            startup::cmd_bind(&id, bind_session, auth.session_id)
237	        }
238	        // Delivery + activity (T5), gated by auth.
239	        ApiCmd::State { state, id, no_gate, auth } => {
240	            gated(&id, &auth, |id| delivery::cmd_state(id, &state, no_gate))
241	        }
242	        ApiCmd::EchoGate { action, id, auth } => {
243	            gated(&id, &auth, |id| delivery::cmd_echo_gate(id, &action))
244	        }
245	        ApiCmd::Poll { id, include_deferred, auth } => gated(&id, &auth, |id| {
246	            delivery::cmd_poll(id, include_deferred, ctx.manifest.as_ref())
247	        }),
248	        // Worker perches (T6). worker-start authenticates against the parent.
249	        ApiCmd::WorkerStart { parent, id, auth } => {
250	            gated(&parent, &auth, |parent| worker::cmd_worker_start(parent, &id))
251	        }
252	        ApiCmd::WorkerStop { id, auth } => gated(&id, &auth, worker::cmd_worker_stop),
253	        ApiCmd::WorkerPoll { id, auth } => {
254	            gated(&id, &auth, |id| worker::cmd_worker_poll(id, false))
255	        }
256	        // Reporting + boundary (T7), gated by auth.
257	        ApiCmd::Boundary { mode, id, to_session, auth } => {
258	            gated(&id, &auth, |id| reporting::cmd_boundary(id, &mode, &to_session))
259	        }
260	        ApiCmd::SessionEnd { id, erase, auth } => {
261	            gated(&id, &auth, |id| reporting::cmd_session_end(id, erase))
262	        }
263	        ApiCmd::Presence { id, auth } => gated(&id, &auth, reporting::cmd_presence),
264	        ApiCmd::HistoryLog { id, auth } => gated(&id, &auth, reporting::cmd_history_log),
265	        ApiCmd::Shutdown { id, auth } => {
266	            let manifest = ctx.manifest.as_ref();
267	            gated(&id, &auth, |id| live::cmd_shutdown(id, manifest))
268	        }
269	        ApiCmd::Emit { id, r#type, auth } => {
270	            gated(&id, &auth, |id| reporting::cmd_emit(id, &r#type))
271	        }
272	    }
273	}
274
275	/// Load + validate a manifest, checking it matches the declared adapter_name.
276	/// Returns an exit code on failure (already reported to stderr).
277	fn load_manifest(path: &PathBuf, adapter: &str) -> Result<Manifest, i32> {
278	    let text = std::fs::read_to_string(path).map_err(|e| {
279	        eprintln!("MANIFEST_READ:{}: {e}", path.display());
280	        EXIT_REFUSED
281	    })?;
282	    let manifest = Manifest::from_toml_str(&text).map_err(|e| {
283	        eprintln!("MANIFEST_INVALID:{e}");
284	        EXIT_REFUSED
285	    })?;
286	    if manifest.adapter.name != adapter {
287	        eprintln!(
288	            "ADAPTER_MISMATCH: --adapter {adapter} but manifest declares {}",
289	            manifest.adapter.name
290	        );
291	        return Err(EXIT_REFUSED);
292	    }
293	    Ok(manifest)
294	}
295
296	/// Run `f` only if the proof authenticates against `target_id`; otherwise print
297	/// the refusal and return [`EXIT_REFUSED`].
298	// [impl->REQ-HAZARD-LOCAL-API-AUTH]
299	fn gated<F: FnOnce(&str) -> i32>(target_id: &str, auth: &AuthFlags, f: F) -> i32 {
300	    match authenticate(target_id, &auth.proof()) {
301	        AuthResult::Ok => f(target_id),
302	        AuthResult::NoEndpoint => {
303	            eprintln!("NO_ENDPOINT:{target_id} has no perch to authenticate against");
304	            EXIT_REFUSED
305	        }
306	        AuthResult::Refused => {
307	            eprintln!("AUTH_REFUSED:{target_id} (need --token or matching --session-id)");
308	            EXIT_REFUSED
309	        }
310	    }
311	}
312
313	#[cfg(test)]
314	mod tests {
315	    use super::*;
316	    use clap::Parser;
317
318	    /// A tiny parent Cli wrapping just the api group, for parse tests.
319	    #[derive(Parser)]
320	    struct Probe {
321	        #[command(flatten)]
322	        api: ApiArgs,
323	    }
324
325	    fn parse(args: &[&str]) -> Result<Probe, clap::Error> {
326	        Probe::try_parse_from(args)
327	    }
328
329	    #[test] // [unit->REQ-API-1] adapter_name is mandatory on every api call.
330	    fn adapter_is_required() {
331	        assert!(parse(&["spt", "capability"]).is_err());
332	        assert!(parse(&["spt", "--adapter", "mock", "capability"]).is_ok());
333	    }
334
335	    #[test] // [unit->REQ-API-1] the surface parses its documented subcommands.
336	    fn surface_parses() {
337	        assert!(parse(&["spt", "--adapter", "m", "seed", "--pid", "10", "--session-id", "s"]).is_ok());
338	        assert!(parse(&["spt", "--adapter", "m", "listen", "alice"]).is_ok());
339	        assert!(parse(&["spt", "--adapter", "m", "state", "idle", "alice"]).is_ok());
340	        assert!(parse(&["spt", "--adapter", "m", "poll", "alice", "--include-deferred"]).is_ok());
341	        assert!(parse(&["spt", "--adapter", "m", "worker-start", "alice", "alice-w1"]).is_ok());
342	        assert!(parse(&["spt", "--adapter", "m", "boundary", "compact", "alice", "--to-session-id", "s2"]).is_ok());
343	        assert!(parse(&["spt", "--adapter", "m", "session-end", "alice", "--erase"]).is_ok());
344	    }
345
346	    #[test] // [unit->REQ-API-1] closed-vocabulary args reject bad values.
347	    fn state_value_is_validated() {
348	        assert!(parse(&["spt", "--adapter", "m", "state", "sideways", "alice"]).is_err());
349	        assert!(parse(&["spt", "--adapter", "m", "boundary", "explode", "alice", "--to-session-id", "s"]).is_err());
350	    }
351	}
352

//! The inbound half of the harness contract: the `spt api <cmd>` surface a
//! harness's hooks fire to mutate on-disk SPT state. Every call carries
//! `--adapter <name>` (the `adapter_name`), and every *mutation* is gated by
//! [`auth`] (REQ-HAZARD-LOCAL-API-AUTH).
//!
//! Interim no-daemon model (decision 2026-06-01): these commands mutate the
//! perch/spool/registry directly via spt-store/spt-msg, mirroring M1's
//! standalone listener. M3's `spt-daemon` swaps the on-disk seed + standalone
//! `listen` for an in-memory daemon record — the surface here stays thin so
//! that is a move, not a rewrite.
//!
//! Exit codes (superset of the messaging surface): `0` ok · `1` runtime refusal
//! (auth refused, no endpoint, bad manifest) · `2` usage (clap).
//
// [impl->REQ-API-1]

pub mod auth;
pub mod delivery;
pub mod live;
pub mod reporting;
pub mod startup;
pub mod worker;

use clap::{Args, Subcommand};
use std::path::PathBuf;

use spt_runtime::Manifest;

use auth::{authenticate, AuthResult, Proof};

/// Exit code for a runtime refusal (auth/no-endpoint).
pub const EXIT_REFUSED: i32 = 1;

/// The `api` command group. `--adapter` is required on every invocation; an
/// optional `--manifest` is parsed for commands that need the adapter contract
/// (spawn roles, capability advertisement).
#[derive(Args)]
pub struct ApiArgs {
    /// adapter_name — the calling harness adapter. Required on every `api` call.
    #[arg(long)]
    adapter: String,
    /// Path to the adapter's runtime manifest (when the command needs it).
    #[arg(long)]
    manifest: Option<PathBuf>,
    #[command(subcommand)]
    cmd: ApiCmd,
}

/// The inbound surface. Lifecycle-execution commands (psyche/commune) are M2b;
/// these are the M2a harness-contract entry points.
#[derive(Subcommand)]
pub enum ApiCmd {
    /// Harness-hosted startup: record an ephemeral seed keyed by parent pid (T4).
    Seed {
        #[arg(long)]
        pid: u32,
        #[arg(long)]
        session_id: String,
    },
    /// Consume a seed and hold the perch + relay loop (interim long-runner, T4).
    Listen {
        id: String,
        /// Override the parent-pid anchor (defaults to the self-discovered PPID).
        #[arg(long)]
        parent_pid: Option<u32>,
        /// Drain backlog + one receive cycle, then exit (testability).
        #[arg(long)]
        once: bool,
    },
    /// Post-spawn bind of a session to its perch (T4).
    Bind {
        id: String,
        /// The session id discovered post-spawn, written into the perch record.
        #[arg(long = "set-session-id")]
        bind_session: Option<String>,
        #[command(flatten)]
        auth: AuthFlags,
    },
    /// Set activity state busy|idle (+ echo-gate sentinel) (T5).
    State {
        #[arg(value_parser = ["busy", "idle"])]
        state: String,
        id: String,
        #[arg(long)]
        no_gate: bool,
        #[command(flatten)]
        auth: AuthFlags,
    },
    /// Manage the echo-gate sentinel directly (T5).
    EchoGate {
        #[arg(value_parser = ["set", "clear"])]
        action: String,
        id: String,
        #[command(flatten)]
        auth: AuthFlags,
    },
    /// Drain delivered messages (hook channel) (T5).
    Poll {
        id: String,
        #[arg(long)]
        include_deferred: bool,
        #[command(flatten)]
        auth: AuthFlags,
    },
    /// Create a nested worker perch under a parent (T6).
    WorkerStart {
        parent: String,
        id: String,
        #[command(flatten)]
        auth: AuthFlags,
    },
    /// Tear down a worker perch (T6).
    WorkerStop {
        id: String,
        #[command(flatten)]
        auth: AuthFlags,
    },
    /// Drain a worker perch's messages (T6).
    WorkerPoll {
        id: String,
        #[command(flatten)]
        auth: AuthFlags,
    },
    /// Rebind the perch to a new session_id, preserving identity (T7).
    Boundary {
        #[arg(value_parser = ["clear", "compact"])]
        mode: String,
        id: String,
        /// The new session id to rebind the perch to.
        #[arg(long = "to-session-id")]
        to_session: String,
        #[command(flatten)]
        auth: AuthFlags,
    },
    /// Soft teardown (spool/history preserved); `--erase` hard-wipes (T7).
    SessionEnd {
        id: String,
        #[arg(long)]
        erase: bool,
        #[command(flatten)]
        auth: AuthFlags,
    },
    /// Report presence (T7).
    Presence {
        id: String,
        #[command(flatten)]
        auth: AuthFlags,
    },
    /// Append to the native history store (Path-B) (T7).
    HistoryLog {
        id: String,
        #[command(flatten)]
        auth: AuthFlags,
    },
    /// Emit a Shell sensory payload (stub) (T7).
    Emit {
        id: String,
        #[arg(long)]
        r#type: String,
        #[command(flatten)]
        auth: AuthFlags,
    },
    /// Print the adapter's declared capability (`hostable_types`) (T7).
    Capability,
    /// Graceful live-agent signoff (M2b): run the final echo-commune BEFORE
    /// teardown (3.3), then soft-stop. The `spt shutdown` lifecycle path.
    Shutdown {
        id: String,
        #[command(flatten)]
        auth: AuthFlags,
    },
}

/// Auth proof flags shared by every mutating subcommand.
#[derive(Args)]
pub struct AuthFlags {
    /// Capability token proving association to the target perch.
    #[arg(long)]
    token: Option<String>,
    /// Session id proving association (matches the perch's info.json).
    #[arg(long)]
    session_id: Option<String>,
}

impl AuthFlags {
    fn proof(&self) -> Proof {
        Proof::resolve(self.token.clone(), self.session_id.clone())
    }
}

/// Resolved per-call context shared by handlers.
pub struct Ctx {
    /// The `adapter_name` carried on this call.
    pub adapter: String,
    /// Parsed manifest, when `--manifest` was supplied (and valid).
    pub manifest: Option<Manifest>,
}

/// Auto-start the per-machine daemon if absent (REQ-DAEMON-3). Best-effort: a
/// start failure is surfaced but does not abort commands that don't need the
/// daemon (e.g. `capability`); the seed/listen path re-checks and fails loudly if
/// the daemon is genuinely unreachable.
// [impl->REQ-DAEMON-3]
fn ensure_daemon() {
    if let Err(e) = spt_daemon::ensure_running() {
        eprintln!("DAEMON_START_WARN: {e}");
    }
}

/// Parse + dispatch the `api` group. Returns the process exit code.
pub fn run(args: ApiArgs) -> i32 {
    // Any api invocation brings the daemon up if absent — the reliable anchor
    // every harness hook shares (REQ-DAEMON-3).
    ensure_daemon();

    // Resolve the manifest up front so a bad path/content fails fast and
    // uniformly, before any state mutation.
    let manifest = match &args.manifest {
        Some(path) => match load_manifest(path, &args.adapter) {
            Ok(m) => Some(m),
            Err(code) => return code,
        },
        None => None,
    };
    let ctx = Ctx { adapter: args.adapter, manifest };

    match args.cmd {
        ApiCmd::Capability => reporting::cmd_capability(&ctx),
        // Harness-hosted startup (T4). Seed establishes; listen consumes+binds;
        // bind is the post-spawn establishing call (intrinsic auth).
        ApiCmd::Seed { pid, session_id } => startup::cmd_seed(&ctx, pid, session_id),
        ApiCmd::Listen { id, parent_pid, once } => {
            startup::cmd_listen(&id, parent_pid, once, ctx.manifest.as_ref())
        }
        ApiCmd::Bind { id, bind_session, auth } => {
            startup::cmd_bind(&id, bind_session, auth.session_id)
        }
        // Delivery + activity (T5), gated by auth.
        ApiCmd::State { state, id, no_gate, auth } => {
            gated(&id, &auth, |id| delivery::cmd_state(id, &state, no_gate))
        }
        ApiCmd::EchoGate { action, id, auth } => {
            gated(&id, &auth, |id| delivery::cmd_echo_gate(id, &action))
        }
        ApiCmd::Poll { id, include_deferred, auth } => gated(&id, &auth, |id| {
            delivery::cmd_poll(id, include_deferred, ctx.manifest.as_ref())
        }),
        // Worker perches (T6). worker-start authenticates against the parent.
        ApiCmd::WorkerStart { parent, id, auth } => {
            gated(&parent, &auth, |parent| worker::cmd_worker_start(parent, &id))
        }
        ApiCmd::WorkerStop { id, auth } => gated(&id, &auth, worker::cmd_worker_stop),
        ApiCmd::WorkerPoll { id, auth } => {
            gated(&id, &auth, |id| worker::cmd_worker_poll(id, false))
        }
        // Reporting + boundary (T7), gated by auth.
        ApiCmd::Boundary { mode, id, to_session, auth } => {
            gated(&id, &auth, |id| reporting::cmd_boundary(id, &mode, &to_session))
        }
        ApiCmd::SessionEnd { id, erase, auth } => {
            gated(&id, &auth, |id| reporting::cmd_session_end(id, erase))
        }
        ApiCmd::Presence { id, auth } => gated(&id, &auth, reporting::cmd_presence),
        ApiCmd::HistoryLog { id, auth } => gated(&id, &auth, reporting::cmd_history_log),
        ApiCmd::Shutdown { id, auth } => {
            let manifest = ctx.manifest.as_ref();
            gated(&id, &auth, |id| live::cmd_shutdown(id, manifest))
        }
        ApiCmd::Emit { id, r#type, auth } => {
            gated(&id, &auth, |id| reporting::cmd_emit(id, &r#type))
        }
    }
}

/// Load + validate a manifest, checking it matches the declared adapter_name.
/// Returns an exit code on failure (already reported to stderr).
fn load_manifest(path: &PathBuf, adapter: &str) -> Result<Manifest, i32> {
    let text = std::fs::read_to_string(path).map_err(|e| {
        eprintln!("MANIFEST_READ:{}: {e}", path.display());
        EXIT_REFUSED
    })?;
    let manifest = Manifest::from_toml_str(&text).map_err(|e| {
        eprintln!("MANIFEST_INVALID:{e}");
        EXIT_REFUSED
    })?;
    if manifest.adapter.name != adapter {
        eprintln!(
            "ADAPTER_MISMATCH: --adapter {adapter} but manifest declares {}",
            manifest.adapter.name
        );
        return Err(EXIT_REFUSED);
    }
    Ok(manifest)
}

/// Run `f` only if the proof authenticates against `target_id`; otherwise print
/// the refusal and return [`EXIT_REFUSED`].
// [impl->REQ-HAZARD-LOCAL-API-AUTH]
fn gated<F: FnOnce(&str) -> i32>(target_id: &str, auth: &AuthFlags, f: F) -> i32 {
    match authenticate(target_id, &auth.proof()) {
        AuthResult::Ok => f(target_id),
        AuthResult::NoEndpoint => {
            eprintln!("NO_ENDPOINT:{target_id} has no perch to authenticate against");
            EXIT_REFUSED
        }
        AuthResult::Refused => {
            eprintln!("AUTH_REFUSED:{target_id} (need --token or matching --session-id)");
            EXIT_REFUSED
        }
    }
}

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

    /// A tiny parent Cli wrapping just the api group, for parse tests.
    #[derive(Parser)]
    struct Probe {
        #[command(flatten)]
        api: ApiArgs,
    }

    fn parse(args: &[&str]) -> Result<Probe, clap::Error> {
        Probe::try_parse_from(args)
    }

    #[test] // [unit->REQ-API-1] adapter_name is mandatory on every api call.
    fn adapter_is_required() {
        assert!(parse(&["spt", "capability"]).is_err());
        assert!(parse(&["spt", "--adapter", "mock", "capability"]).is_ok());
    }

    #[test] // [unit->REQ-API-1] the surface parses its documented subcommands.
    fn surface_parses() {
        assert!(parse(&["spt", "--adapter", "m", "seed", "--pid", "10", "--session-id", "s"]).is_ok());
        assert!(parse(&["spt", "--adapter", "m", "listen", "alice"]).is_ok());
        assert!(parse(&["spt", "--adapter", "m", "state", "idle", "alice"]).is_ok());
        assert!(parse(&["spt", "--adapter", "m", "poll", "alice", "--include-deferred"]).is_ok());
        assert!(parse(&["spt", "--adapter", "m", "worker-start", "alice", "alice-w1"]).is_ok());
        assert!(parse(&["spt", "--adapter", "m", "boundary", "compact", "alice", "--to-session-id", "s2"]).is_ok());
        assert!(parse(&["spt", "--adapter", "m", "session-end", "alice", "--erase"]).is_ok());
    }

    #[test] // [unit->REQ-API-1] closed-vocabulary args reject bad values.
    fn state_value_is_validated() {
        assert!(parse(&["spt", "--adapter", "m", "state", "sideways", "alice"]).is_err());
        assert!(parse(&["spt", "--adapter", "m", "boundary", "explode", "alice", "--to-session-id", "s"]).is_err());
    }
}


crates/spt/src/api/mod.rs:149:    /// Append to the native history store (Path-B) (T7).
crates/spt/src/api/mod.rs:150:    HistoryLog {
crates/spt/src/api/mod.rs:264:        ApiCmd::HistoryLog { id, auth } => gated(&id, &auth, reporting::cmd_history_log),
crates/spt/src/api/reporting.rs:2://! `api presence`, `api history-log`, `api emit`, plus the M2a *declarations*
crates/spt/src/api/reporting.rs:13://! - **history-log** (REQ-API-2) — Path-B native store: append a normalized
crates/spt/src/api/reporting.rs:14://!   record to the perch's history log.
crates/spt/src/api/reporting.rs:112:/// Append a normalized record to the perch's Path-B native history store,
crates/spt/src/api/reporting.rs:114:pub fn append_history(id: &str, record: &str) -> std::io::Result<usize> {
crates/spt/src/api/reporting.rs:121:        .open(perch_path.join("history.log"))?;
crates/spt/src/api/reporting.rs:131:/// `api history-log <id>` — Path-B ingest: read a normalized record from stdin
crates/spt/src/api/reporting.rs:132:/// and append it to the native store.
crates/spt/src/api/reporting.rs:134:pub fn cmd_history_log(id: &str) -> i32 {
crates/spt/src/api/reporting.rs:139:        eprintln!("EMPTY_RECORD: history-log read nothing on stdin");
crates/spt/src/api/reporting.rs:142:    match append_history(id, record.trim_end()) {
crates/spt/src/api/reporting.rs:277:    #[test] // [unit->REQ-API-2] history-log appends to the Path-B native store.
crates/spt/src/api/reporting.rs:278:    fn history_log_appends() {
crates/spt/src/api/reporting.rs:281:        append_history("alice", "{\"role\":\"user\",\"text\":\"hi\"}").unwrap();
crates/spt/src/api/reporting.rs:282:        append_history("alice", "{\"role\":\"assistant\",\"text\":\"yo\"}").unwrap();
crates/spt/src/api/reporting.rs:283:        let log = perch::resolve_perch_path("alice", ParentHint::Infer).join("history.log");
crates/spt/src/api/startup.rs:12://! 3. Alternatively a just-spawned binary fires `api bind <id>` to associate


Path-B store exists (`append_history` → `<perch>/history.log`). Now spt-term exports + PtySession spawn API + existing contract_e2e + frame.rs for IPC handshake.


1	//! spt-core session-surface mechanism — the broker's PTY half (M3a).
2	//!
3	//! `spt-term` is the **multiplatform session-surface mechanism** that every hosted
4	//! agent session runs inside: a real child under a native PTY — ConPTY on Windows
5	//! 10+, `forkpty(3)` on Unix — behind one [`SessionSurface`] trait, with input
6	//! injection, byte-stream output, and the live activity buffer (PTY digest) parser
7	//! primitive layered on top. It is the sixth crate in the acyclic stack
8	//! (`spt-proto -> spt-store -> spt-msg -> spt-term`), a sibling of `spt-runtime`.
9	//!
10	//! M3a delivers the *library mechanism*; the **M3b** `spt-daemon` broker delivers
11	//! the *process* that hosts many of these behind versioned IPC. `spt-term` is
12	//! deliberately **single-surface-per-handle** — it owns one child's PTY, not the
13	//! multiplexed name-addressable fleet (that supervisor is M3b). See `M3a-PLAN.md`.
14	//!
15	//! ## OS stream-contract neutrality (Spike #4 / #5, binding)
16	//!
17	//! The single biggest design risk this crate carries: `forkpty` is a **raw ordered
18	//! pipe**; ConPTY is a **screen buffer** (repaint-on-resize reorders + duplicates
19	//! the stream; a mid-session attach sees the viewport, not scrollback). The
20	//! [`SessionSurface`] trait and the byte-stream / digest contracts therefore must
21	//! **not** bake in either OS's semantics — the surface is modeled as a byte stream
22	//! with explicit, OS-independent attach/replay semantics.
23	//
24	// spt-term is internal broker machinery, NOT part of the public semver SDK
25	// surface (R-ARCH-2 stays spt-proto/spt-store/spt-msg/spt-runtime). The M3b
26	// daemon programs against it; external harness authors do not.
27
28	// Surfaces land across M3a (see M3a-PLAN.md task table):
29	//   A1  SessionSurface trait + ConPTY/forkpty backends     (REQ-TERM-1)       ← landed
30	//   A2  ConPTY-DSR auto-answer drain thread                 (REQ-HAZARD-CONPTY-DSR) ← landed
31	//   A3  send-keys / send-line injection                     (REQ-TERM-2)
32	//   A4  byte-stream local streaming (bounded backpressure)  (REQ-TERM-3)       ← landed
33	//   A5  live activity buffer (PTY digest) parser primitive  (REQ-TERM-4)       ← landed
34
35	pub mod digest;
36	pub mod pty;
37	pub mod reader;
38	pub mod stream;
39	pub mod surface;
40
41	pub use digest::{
42	    Digest, DigestConfig, DigestEntry, DigestError, DigestParser, DigestPatterns, ToolUse, Turn,
43	};
44	pub use portable_pty::CommandBuilder;
45	pub use pty::PtySession;
46	pub use reader::Drain;
47	pub use stream::OutputStream;
48	pub use surface::{SessionSurface, SurfaceError, SurfaceSize};
49

//! spt-core session-surface mechanism — the broker's PTY half (M3a).
//!
//! `spt-term` is the **multiplatform session-surface mechanism** that every hosted
//! agent session runs inside: a real child under a native PTY — ConPTY on Windows
//! 10+, `forkpty(3)` on Unix — behind one [`SessionSurface`] trait, with input
//! injection, byte-stream output, and the live activity buffer (PTY digest) parser
//! primitive layered on top. It is the sixth crate in the acyclic stack
//! (`spt-proto -> spt-store -> spt-msg -> spt-term`), a sibling of `spt-runtime`.
//!
//! M3a delivers the *library mechanism*; the **M3b** `spt-daemon` broker delivers
//! the *process* that hosts many of these behind versioned IPC. `spt-term` is
//! deliberately **single-surface-per-handle** — it owns one child's PTY, not the
//! multiplexed name-addressable fleet (that supervisor is M3b). See `M3a-PLAN.md`.
//!
//! ## OS stream-contract neutrality (Spike #4 / #5, binding)
//!
//! The single biggest design risk this crate carries: `forkpty` is a **raw ordered
//! pipe**; ConPTY is a **screen buffer** (repaint-on-resize reorders + duplicates
//! the stream; a mid-session attach sees the viewport, not scrollback). The
//! [`SessionSurface`] trait and the byte-stream / digest contracts therefore must
//! **not** bake in either OS's semantics — the surface is modeled as a byte stream
//! with explicit, OS-independent attach/replay semantics.
//
// spt-term is internal broker machinery, NOT part of the public semver SDK
// surface (R-ARCH-2 stays spt-proto/spt-store/spt-msg/spt-runtime). The M3b
// daemon programs against it; external harness authors do not.

// Surfaces land across M3a (see M3a-PLAN.md task table):
//   A1  SessionSurface trait + ConPTY/forkpty backends     (REQ-TERM-1)       ← landed
//   A2  ConPTY-DSR auto-answer drain thread                 (REQ-HAZARD-CONPTY-DSR) ← landed
//   A3  send-keys / send-line injection                     (REQ-TERM-2)
//   A4  byte-stream local streaming (bounded backpressure)  (REQ-TERM-3)       ← landed
//   A5  live activity buffer (PTY digest) parser primitive  (REQ-TERM-4)       ← landed

pub mod digest;
pub mod pty;
pub mod reader;
pub mod stream;
pub mod surface;

pub use digest::{
    Digest, DigestConfig, DigestEntry, DigestError, DigestParser, DigestPatterns, ToolUse, Turn,
};
pub use portable_pty::CommandBuilder;
pub use pty::PtySession;
pub use reader::Drain;
pub use stream::OutputStream;
pub use surface::{SessionSurface, SurfaceError, SurfaceSize};