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	/// Parse + dispatch the `api` group. Returns the process exit code.
200	pub fn run(args: ApiArgs) -> i32 {
201	    // Resolve the manifest up front so a bad path/content fails fast and
202	    // uniformly, before any state mutation.
203	    let manifest = match &args.manifest {
204	        Some(path) => match load_manifest(path, &args.adapter) {
205	            Ok(m) => Some(m),
206	            Err(code) => return code,
207	        },
208	        None => None,
209	    };
210	    let ctx = Ctx { adapter: args.adapter, manifest };
211
212	    match args.cmd {
213	        ApiCmd::Capability => reporting::cmd_capability(&ctx),
214	        // Harness-hosted startup (T4). Seed establishes; listen consumes+binds;
215	        // bind is the post-spawn establishing call (intrinsic auth).
216	        ApiCmd::Seed { pid, session_id } => startup::cmd_seed(&ctx, pid, session_id),
217	        ApiCmd::Listen { id, parent_pid, once } => {
218	            startup::cmd_listen(&id, parent_pid, once, ctx.manifest.as_ref())
219	        }
220	        ApiCmd::Bind { id, bind_session, auth } => {
221	            startup::cmd_bind(&id, bind_session, auth.session_id)
222	        }
223	        // Delivery + activity (T5), gated by auth.
224	        ApiCmd::State { state, id, no_gate, auth } => {
225	            gated(&id, &auth, |id| delivery::cmd_state(id, &state, no_gate))
226	        }
227	        ApiCmd::EchoGate { action, id, auth } => {
228	            gated(&id, &auth, |id| delivery::cmd_echo_gate(id, &action))
229	        }
230	        ApiCmd::Poll { id, include_deferred, auth } => gated(&id, &auth, |id| {
231	            delivery::cmd_poll(id, include_deferred, ctx.manifest.as_ref())
232	        }),
233	        // Worker perches (T6). worker-start authenticates against the parent.
234	        ApiCmd::WorkerStart { parent, id, auth } => {
235	            gated(&parent, &auth, |parent| worker::cmd_worker_start(parent, &id))
236	        }
237	        ApiCmd::WorkerStop { id, auth } => gated(&id, &auth, worker::cmd_worker_stop),
238	        ApiCmd::WorkerPoll { id, auth } => {
239	            gated(&id, &auth, |id| worker::cmd_worker_poll(id, false))
240	        }
241	        // Reporting + boundary (T7), gated by auth.
242	        ApiCmd::Boundary { mode, id, to_session, auth } => {
243	            gated(&id, &auth, |id| reporting::cmd_boundary(id, &mode, &to_session))
244	        }
245	        ApiCmd::SessionEnd { id, erase, auth } => {
246	            gated(&id, &auth, |id| reporting::cmd_session_end(id, erase))
247	        }
248	        ApiCmd::Presence { id, auth } => gated(&id, &auth, reporting::cmd_presence),
249	        ApiCmd::HistoryLog { id, auth } => gated(&id, &auth, reporting::cmd_history_log),
250	        ApiCmd::Shutdown { id, auth } => {
251	            let manifest = ctx.manifest.as_ref();
252	            gated(&id, &auth, |id| live::cmd_shutdown(id, manifest))
253	        }
254	        ApiCmd::Emit { id, r#type, auth } => {
255	            gated(&id, &auth, |id| reporting::cmd_emit(id, &r#type))
256	        }
257	    }
258	}
259
260	/// Load + validate a manifest, checking it matches the declared adapter_name.
261	/// Returns an exit code on failure (already reported to stderr).
262	fn load_manifest(path: &PathBuf, adapter: &str) -> Result<Manifest, i32> {
263	    let text = std::fs::read_to_string(path).map_err(|e| {
264	        eprintln!("MANIFEST_READ:{}: {e}", path.display());
265	        EXIT_REFUSED
266	    })?;
267	    let manifest = Manifest::from_toml_str(&text).map_err(|e| {
268	        eprintln!("MANIFEST_INVALID:{e}");
269	        EXIT_REFUSED
270	    })?;
271	    if manifest.adapter.name != adapter {
272	        eprintln!(
273	            "ADAPTER_MISMATCH: --adapter {adapter} but manifest declares {}",
274	            manifest.adapter.name
275	        );
276	        return Err(EXIT_REFUSED);
277	    }
278	    Ok(manifest)
279	}
280
281	/// Run `f` only if the proof authenticates against `target_id`; otherwise print
282	/// the refusal and return [`EXIT_REFUSED`].
283	// [impl->REQ-HAZARD-LOCAL-API-AUTH]
284	fn gated<F: FnOnce(&str) -> i32>(target_id: &str, auth: &AuthFlags, f: F) -> i32 {
285	    match authenticate(target_id, &auth.proof()) {
286	        AuthResult::Ok => f(target_id),
287	        AuthResult::NoEndpoint => {
288	            eprintln!("NO_ENDPOINT:{target_id} has no perch to authenticate against");
289	            EXIT_REFUSED
290	        }
291	        AuthResult::Refused => {
292	            eprintln!("AUTH_REFUSED:{target_id} (need --token or matching --session-id)");
293	            EXIT_REFUSED
294	        }
295	    }
296	}
297
298	#[cfg(test)]
299	mod tests {
300	    use super::*;
301	    use clap::Parser;
302
303	    /// A tiny parent Cli wrapping just the api group, for parse tests.
304	    #[derive(Parser)]
305	    struct Probe {
306	        #[command(flatten)]
307	        api: ApiArgs,
308	    }
309
310	    fn parse(args: &[&str]) -> Result<Probe, clap::Error> {
311	        Probe::try_parse_from(args)
312	    }
313
314	    #[test] // [unit->REQ-API-1] adapter_name is mandatory on every api call.
315	    fn adapter_is_required() {
316	        assert!(parse(&["spt", "capability"]).is_err());
317	        assert!(parse(&["spt", "--adapter", "mock", "capability"]).is_ok());
318	    }
319
320	    #[test] // [unit->REQ-API-1] the surface parses its documented subcommands.
321	    fn surface_parses() {
322	        assert!(parse(&["spt", "--adapter", "m", "seed", "--pid", "10", "--session-id", "s"]).is_ok());
323	        assert!(parse(&["spt", "--adapter", "m", "listen", "alice"]).is_ok());
324	        assert!(parse(&["spt", "--adapter", "m", "state", "idle", "alice"]).is_ok());
325	        assert!(parse(&["spt", "--adapter", "m", "poll", "alice", "--include-deferred"]).is_ok());
326	        assert!(parse(&["spt", "--adapter", "m", "worker-start", "alice", "alice-w1"]).is_ok());
327	        assert!(parse(&["spt", "--adapter", "m", "boundary", "compact", "alice", "--to-session-id", "s2"]).is_ok());
328	        assert!(parse(&["spt", "--adapter", "m", "session-end", "alice", "--erase"]).is_ok());
329	    }
330
331	    #[test] // [unit->REQ-API-1] closed-vocabulary args reject bad values.
332	    fn state_value_is_validated() {
333	        assert!(parse(&["spt", "--adapter", "m", "state", "sideways", "alice"]).is_err());
334	        assert!(parse(&["spt", "--adapter", "m", "boundary", "explode", "alice", "--to-session-id", "s"]).is_err());
335	    }
336	}
337

//! 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>,
}

/// Parse + dispatch the `api` group. Returns the process exit code.
pub fn run(args: ApiArgs) -> i32 {
    // 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());
    }
}


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>` → an ephemeral seed file keyed by the stable parent
7	//!    pid (REQ-START-1).
8	//! 2. The agent runs its `$SPT listen <id>` alias → `api listen` self-discovers
9	//!    its parent pid, matches the seed, validates it against pid-recycle
10	//!    (KNOWN-HAZARDS 5.1), binds the perch skeleton→live, and relays.
11	//! 3. Alternatively a just-spawned binary fires `api bind <id>` to associate
12	//!    itself post-spawn (REQ-SEAM-POSTSPAWN) — establishing the live perch on
13	//!    first contact, or flipping an existing skeleton.
14	//
15	// [impl->REQ-START-1]
16	// [impl->REQ-START-2]
17	// [impl->REQ-SEAM-POSTSPAWN]
18
19	use std::time::Duration;
20
21	use spt_msg::listener::PollListener;
22	use spt_store::info::{self, InfoJson};
23	use spt_store::perch::{self, ParentHint};
24	use spt_store::proc;
25	use spt_store::seed::{self, Seed};
26	use spt_store::spool;
27
28	use super::auth;
29	use super::{Ctx, EXIT_REFUSED};
30
31	/// Poll window for the relay loop — bounds each blocking accept so the loop
32	/// stays responsive (mirrors M1's listener cadence).
33	const RELAY_POLL: Duration = Duration::from_millis(200);
34
35	/// `api seed` — record the ephemeral startup seed keyed by parent pid. No prior
36	/// perch exists, so this call is not auth-gated (it *establishes* the binding
37	/// material the gated calls later authenticate against).
38	// [impl->REQ-START-1]
39	pub fn cmd_seed(ctx: &Ctx, parent_pid: u32, session_id: String) -> i32 {
40	    if session_id.is_empty() {
41	        eprintln!("USAGE: seed requires a non-empty --session-id");
42	        return EXIT_REFUSED;
43	    }
44	    let seed = Seed {
45	        parent_pid,
46	        session_id,
47	        adapter: ctx.adapter.clone(),
48	        cwd: std::env::current_dir()
49	            .ok()
50	            .map(|p| p.to_string_lossy().replace('\\', "/")),
51	        created: now_stamp(),
52	    };
53	    match seed::write_seed(&seed) {
54	        Ok(()) => {
55	            eprintln!("SEEDED:{parent_pid}");
56	            0
57	        }
58	        Err(e) => {
59	            eprintln!("SEED_FAIL:{parent_pid}: {e}");
60	            EXIT_REFUSED
61	        }
62	    }
63	}
64
65	/// Why a seed-driven bind was refused. Each variant is a distinct diagnostic so
66	/// a stale/recycled seed never silently binds the wrong session.
67	#[derive(Debug, PartialEq, Eq)]
68	pub enum BindError {
69	    NoSeed(u32),
70	    /// The seed's anchor process is dead → recycled/abandoned (5.1).
71	    StaleSeed(u32),
72	    EmptySession,
73	    /// A *live* perch already holds this id under a different session (5.1).
74	    Conflict { id: String, held: String },
75	    Io(String),
76	}
77
78	/// Consume the seed for `parent_pid` and bind/revive the perch skeleton→live.
79	/// Pure of any listener I/O so it is unit-testable without spawning processes.
80	/// Returns the perch's capability token on success.
81	// [impl->REQ-START-2]
82	// [impl->REQ-HAZARD-WINDOWS-PID-RECYCLE]
83	pub fn bind_from_seed(id: &str, parent_pid: u32) -> Result<String, BindError> {
84	    let seed = seed::read_seed(parent_pid).ok_or(BindError::NoSeed(parent_pid))?;
85	    if seed.session_id.is_empty() {
86	        return Err(BindError::EmptySession);
87	    }
88	    // Pid-recycle defense (5.1): anchor liveness on the stable parent pid. A
89	    // dead anchor means the harness that wrote the seed is gone — a recycled or
90	    // abandoned seed must never bind.
91	    if !proc::is_process_alive(seed.parent_pid) {
92	        return Err(BindError::StaleSeed(seed.parent_pid));
93	    }
94	    let token = establish_perch(id, &seed.session_id, seed.parent_pid)?;
95	    seed::remove_seed(parent_pid); // consumed; refreshed on the next SessionStart
96	    Ok(token)
97	}
98
99	/// Write the perch record (skeleton→live) + ready marker + capability token.
100	/// Reviving an existing perch keeps its token; a live perch held under a
101	/// different session id is a conflict (pid-recycle / id collision guard).
102	fn establish_perch(id: &str, session_id: &str, parent_pid: u32) -> Result<String, BindError> {
103	    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
104
105	    if let Some(existing) = info::read_info(&perch_path) {
106	        let owner_alive = info::read_pid(&perch_path)
107	            .map(proc::is_process_alive)
108	            .unwrap_or(false);
109	        if owner_alive
110	            && !existing.session_id.is_empty()
111	            && existing.session_id != session_id
112	        {
113	            return Err(BindError::Conflict {
114	                id: id.to_string(),
115	                held: existing.session_id,
116	            });
117	        }
118	    }
119
120	    std::fs::create_dir_all(&perch_path).map_err(|e| BindError::Io(e.to_string()))?;
121	    let mut rec = InfoJson::new(id, &now_stamp(), std::process::id(), session_id, "live_agent");
122	    rec.parent_pid = Some(parent_pid);
123	    info::write_info(&perch_path, &rec).map_err(|e| BindError::Io(e.to_string()))?;
124	    std::fs::write(perch::resolve_ready_file(id, ParentHint::Infer), "")
125	        .map_err(|e| BindError::Io(e.to_string()))?;
126
127	    // Keep an existing token across a revive; mint one for a fresh perch.
128	    let token = auth::read_token(&perch_path).unwrap_or_else(auth::mint_token);
129	    auth::write_token(&perch_path, &token).map_err(|e| BindError::Io(e.to_string()))?;
130	    Ok(token)
131	}
132
133	/// `api listen <id>` — consume the seed, bind the perch, then run the relay
134	/// loop (heir to `$LIVE start`'s Monitor-bound poll loop). `--once` drains and
135	/// exits (testability); otherwise blocks streaming messages to stdout.
136	///
137	/// When the manifest is **live-capable** (declares `[session.psyche_init]`), the
138	/// loop also hosts the LiveAgent lifecycle over the M2b `spt-live` seams: sweep
139	/// stale signoff on start (3.2), spawn the Psyche (T2), and run a pulse tick
140	/// each cycle (drop ingest + echo fires — T3/T4/T5). The graceful signoff (T6)
141	/// is driven by the shutdown path; M3's daemon re-hosts all of it.
142	// [impl->REQ-START-2]
143	// [impl->REQ-SEAM-PSYCHE]
144	pub fn cmd_listen(
145	    id: &str,
146	    parent_pid_override: Option<u32>,
147	    once: bool,
148	    manifest: Option<&spt_runtime::Manifest>,
149	) -> i32 {
150	    // [impl->REQ-HAZARD-ID-CHARSET] reject malformed ids at the creation seam.
151	    if let Err(e) = spt_proto::id::validate_endpoint_id(id) {
152	        eprintln!("INVALID_ID:{id}: {e}");
153	        return EXIT_REFUSED;
154	    }
155	    let parent_pid = match parent_pid_override.or_else(proc::parent_pid) {
156	        Some(p) => p,
157	        None => {
158	            eprintln!("NO_PARENT_PID: could not resolve the parent pid anchor");
159	            return EXIT_REFUSED;
160	        }
161	    };
162	    let token = match bind_from_seed(id, parent_pid) {
163	        Ok(t) => t,
164	        Err(e) => {
165	            report_bind_error(&e);
166	            return EXIT_REFUSED;
167	        }
168	    };
169	    eprintln!("BOUND:{id} token={token}");
170
171	    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
172	    let listener = match PollListener::bind_blocking(id, RELAY_POLL) {
173	        Ok(l) => l,
174	        Err(e) => {
175	            eprintln!("LISTEN_FAIL:{id}: {e}");
176	            return EXIT_REFUSED;
177	        }
178	    };
179
180	    // The bound session id (from the seed) — fills the lifecycle templates.
181	    let session_id = info::read_info(&perch_path)
182	        .map(|i| i.session_id)
183	        .filter(|s| !s.is_empty());
184
185	    // Live-agent lifecycle host (interim; M3's daemon re-hosts). Sweep a stale
186	    // signoff sentinel (3.2) then spawn the Psyche companion (T2) before READY.
187	    let live = super::live::LiveHost::new_opt(manifest, id);
188	    if let Some(host) = &live {
189	        host.sweep_on_start();
190	        match host.spawn_psyche(session_id.as_deref(), None) {
191	            Ok(p) => eprintln!("PSYCHE_SPAWNED:{} pid={}", p.id, p.pid),
192	            Err(e) => eprintln!("PSYCHE_SPAWN_FAIL:{id}: {e}"),
193	        }
194	    }
195
196	    eprintln!("READY:{id}");
197
198	    // Drain the offline backlog first (non-deferred only; deferred rows wait
199	    // for the hook channel).
200	    for msg in spool::drain_non_deferred_at(&perch_path).unwrap_or_default() {
201	        emit(&msg);
202	    }
203	    // One pulse tick: ingest drops + fire a pending echo (T4/T3/T5).
204	    let pulse = |host: &Option<super::live::LiveHost>| {
205	        if let Some(h) = host {
206	            if let Err(e) = h.pulse_tick(session_id.as_deref()) {
207	                eprintln!("PULSE_FAIL:{id}: {e}");
208	            }
209	        }
210	    };
211	    if once {
212	        if let Some(msg) = listener.check_message_blocking() {
213	            emit(&msg);
214	        }
215	        for msg in spool::drain_non_deferred_at(&perch_path).unwrap_or_default() {
216	            emit(&msg);
217	        }
218	        pulse(&live);
219	        return 0;
220	    }
221	    loop {
222	        if let Some(msg) = listener.check_message_blocking() {
223	            emit(&msg);
224	        }
225	        for msg in spool::drain_non_deferred_at(&perch_path).unwrap_or_default() {
226	            emit(&msg);
227	        }
228	        pulse(&live);
229	    }
230	}
231
232	/// `api bind <id>` — post-spawn boot bind (REQ-SEAM-POSTSPAWN). Establishes the
233	/// live perch on first contact, or flips an existing skeleton, keyed to the
234	/// session id the binary reports. Auth is intrinsic: an existing *live* perch
235	/// under a different session id is refused (you can only bind your own).
236	// [impl->REQ-SEAM-POSTSPAWN]
237	pub fn cmd_bind(id: &str, set_session_id: Option<String>, proof_session: Option<String>) -> i32 {
238	    // [impl->REQ-HAZARD-ID-CHARSET] reject malformed ids at the creation seam.
239	    if let Err(e) = spt_proto::id::validate_endpoint_id(id) {
240	        eprintln!("INVALID_ID:{id}: {e}");
241	        return EXIT_REFUSED;
242	    }
243	    let session_id = set_session_id
244	        .or(proof_session)
245	        .or_else(|| std::env::var("OWL_SESSION_ID").ok())
246	        .filter(|s| !s.is_empty());
247	    let Some(session_id) = session_id else {
248	        eprintln!("USAGE: bind needs --set-session-id (or $OWL_SESSION_ID)");
249	        return EXIT_REFUSED;
250	    };
251	    // The session's own anchor: the process that fired this hook (the session).
252	    let parent_pid = proc::parent_pid().unwrap_or_else(std::process::id);
253	    match establish_perch(id, &session_id, parent_pid) {
254	        Ok(token) => {
255	            eprintln!("BOUND:{id} token={token}");
256	            0
257	        }
258	        Err(e) => {
259	            report_bind_error(&e);
260	            EXIT_REFUSED
261	        }
262	    }
263	}
264
265	fn report_bind_error(e: &BindError) {
266	    match e {
267	        BindError::NoSeed(p) => eprintln!("NO_SEED:{p} (no seed for this parent pid)"),
268	        BindError::StaleSeed(p) => eprintln!("STALE_SEED:{p} (anchor process is dead)"),
269	        BindError::EmptySession => eprintln!("EMPTY_SESSION: seed carried no session id"),
270	        BindError::Conflict { id, held } => {
271	            eprintln!("CONFLICT:{id} is live under session {held}")
272	        }
273	        BindError::Io(m) => eprintln!("BIND_IO: {m}"),
274	    }
275	}
276
277	/// Print a framed message to stdout and flush (one delivery per emit).
278	fn emit(msg: &str) {
279	    use std::io::Write;
280	    print!("{msg}");
281	    let _ = std::io::stdout().flush();
282	}
283
284	/// Coarse creation timestamp (epoch seconds as a string), matching the
285	/// `info.json` `started` producer-format convention.
286	fn now_stamp() -> String {
287	    std::time::SystemTime::now()
288	        .duration_since(std::time::UNIX_EPOCH)
289	        .map(|d| d.as_secs().to_string())
290	        .unwrap_or_else(|_| "0".to_string())
291	}
292
293	#[cfg(test)]
294	mod tests {
295	    use super::*;
296	    use crate::testutil::isolated_home;
297
298	    fn write_seed(parent_pid: u32, session_id: &str) {
299	        seed::write_seed(&Seed {
300	            parent_pid,
301	            session_id: session_id.into(),
302	            adapter: "mock".into(),
303	            cwd: None,
304	            created: "0".into(),
305	        })
306	        .unwrap();
307	    }
308
309	    #[test] // [unit->REQ-START-2] a valid seed (live anchor) binds a live perch.
310	    fn valid_seed_binds_live_perch() {
311	        let _h = isolated_home();
312	        // Use this very process as the (alive) anchor.
313	        let anchor = std::process::id();
314	        write_seed(anchor, "sid-1");
315	        let token = bind_from_seed("alice", anchor).expect("bind");
316	        assert_eq!(token.len(), 32);
317
318	        let perch_path = perch::resolve_perch_path("alice", ParentHint::Infer);
319	        let rec = info::read_info(&perch_path).expect("info.json");
320	        assert_eq!(rec.session_id, "sid-1");
321	        assert_eq!(rec.state, "live_agent");
322	        assert_eq!(rec.parent_pid, Some(anchor));
323	        assert!(perch::resolve_ready_file("alice", ParentHint::Infer).exists());
324	        // Seed is consumed.
325	        assert!(seed::read_seed(anchor).is_none());
326	    }
327
328	    #[test] // [unit->REQ-HAZARD-WINDOWS-PID-RECYCLE] a dead-anchor seed never binds.
329	    fn stale_seed_with_dead_anchor_refused() {
330	        let _h = isolated_home();
331	        const DEAD: u32 = 2_000_000_000;
332	        write_seed(DEAD, "sid-1");
333	        let err = bind_from_seed("alice", DEAD).unwrap_err();
334	        assert_eq!(err, BindError::StaleSeed(DEAD));
335	        // No perch was created.
336	        assert!(info::read_info(&perch::resolve_perch_path("alice", ParentHint::Infer)).is_none());
337	    }
338
339	    #[test] // [unit->REQ-START-2] absent seed is refused, not bound.
340	    fn missing_seed_refused() {
341	        let _h = isolated_home();
342	        let err = bind_from_seed("alice", std::process::id()).unwrap_err();
343	        assert!(matches!(err, BindError::NoSeed(_)));
344	    }
345
346	    #[test] // [unit->REQ-START-2] empty session id in the seed is refused.
347	    fn empty_session_seed_refused() {
348	        let _h = isolated_home();
349	        let anchor = std::process::id();
350	        write_seed(anchor, "");
351	        assert_eq!(bind_from_seed("alice", anchor).unwrap_err(), BindError::EmptySession);
352	    }
353
354	    #[test] // [unit->REQ-HAZARD-WINDOWS-PID-RECYCLE] a live perch under another
355	    // session is not silently rebound (id-collision guard).
356	    fn live_perch_under_other_session_conflicts() {
357	        let _h = isolated_home();
358	        let anchor = std::process::id();
359	        // First bind establishes alice@sid-1 owned by this (live) process.
360	        write_seed(anchor, "sid-1");
361	        bind_from_seed("alice", anchor).unwrap();
362	        // A second seed tries to claim alice for a different session.
363	        write_seed(anchor, "sid-2");
364	        let err = bind_from_seed("alice", anchor).unwrap_err();
365	        assert_eq!(err, BindError::Conflict { id: "alice".into(), held: "sid-1".into() });
366	    }
367
368	    #[test] // [unit->REQ-SEAM-POSTSPAWN] first-contact bind establishes a live perch.
369	    fn post_spawn_bind_establishes_perch() {
370	        let _h = isolated_home();
371	        let code = cmd_bind("bob", Some("sid-boot".into()), None);
372	        assert_eq!(code, 0);
373	        let rec = info::read_info(&perch::resolve_perch_path("bob", ParentHint::Infer)).unwrap();
374	        assert_eq!(rec.session_id, "sid-boot");
375	        assert_eq!(rec.state, "live_agent");
376	    }
377
378	    #[test] // [unit->REQ-SEAM-POSTSPAWN] re-bind with the same session is idempotent.
379	    fn rebind_same_session_ok() {
380	        let _h = isolated_home();
381	        assert_eq!(cmd_bind("bob", Some("sid-boot".into()), None), 0);
382	        assert_eq!(cmd_bind("bob", Some("sid-boot".into()), None), 0);
383	    }
384	}
385

//! 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>` → an ephemeral seed file keyed by the stable parent
//!    pid (REQ-START-1).
//! 2. The agent runs its `$SPT listen <id>` alias → `api listen` self-discovers
//!    its parent pid, matches the seed, 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-1]
// [impl->REQ-START-2]
// [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::{self, 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).
// [impl->REQ-START-1]
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(),
    };
    match seed::write_seed(&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 },
    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.
// [impl->REQ-START-2]
// [impl->REQ-HAZARD-WINDOWS-PID-RECYCLE]
pub fn bind_from_seed(id: &str, parent_pid: u32) -> Result<String, BindError> {
    let seed = seed::read_seed(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));
    }
    let token = establish_perch(id, &seed.session_id, seed.parent_pid)?;
    seed::remove_seed(parent_pid); // consumed; refreshed on the next SessionStart
    Ok(token)
}

/// 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).
fn establish_perch(id: &str, session_id: &str, parent_pid: u32) -> Result<String, BindError> {
    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);

    if let Some(existing) = info::read_info(&perch_path) {
        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,
            });
        }
    }

    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);
    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>,
) -> 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) {
        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}");

    // 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>) -> 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) {
        Ok(token) => {
            eprintln!("BOUND:{id} token={token}");
            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::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;

    fn write_seed(parent_pid: u32, session_id: &str) {
        seed::write_seed(&Seed {
            parent_pid,
            session_id: session_id.into(),
            adapter: "mock".into(),
            cwd: None,
            created: "0".into(),
        })
        .unwrap();
    }

    #[test] // [unit->REQ-START-2] a valid seed (live anchor) binds a live perch.
    fn valid_seed_binds_live_perch() {
        let _h = isolated_home();
        // Use this very process as the (alive) anchor.
        let anchor = std::process::id();
        write_seed(anchor, "sid-1");
        let token = bind_from_seed("alice", anchor).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.
        assert!(seed::read_seed(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();
        const DEAD: u32 = 2_000_000_000;
        write_seed(DEAD, "sid-1");
        let err = bind_from_seed("alice", DEAD).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());
    }

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

    #[test] // [unit->REQ-START-2] empty session id in the seed is refused.
    fn empty_session_seed_refused() {
        let _h = isolated_home();
        let anchor = std::process::id();
        write_seed(anchor, "");
        assert_eq!(bind_from_seed("alice", anchor).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 anchor = std::process::id();
        // First bind establishes alice@sid-1 owned by this (live) process.
        write_seed(anchor, "sid-1");
        bind_from_seed("alice", anchor).unwrap();
        // A second seed tries to claim alice for a different session.
        write_seed(anchor, "sid-2");
        let err = bind_from_seed("alice", anchor).unwrap_err();
        assert_eq!(err, BindError::Conflict { id: "alice".into(), held: "sid-1".into() });
    }

    #[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);
        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), 0);
        assert_eq!(cmd_bind("bob", Some("sid-boot".into()), None), 0);
    }
}


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

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

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

#[cfg(test)]
mod test_home;

pub use brain::{Brain, BrainState, BrokerEvent};
pub use broker::Broker;
pub use config::DaemonConfig;
pub use effect::{EffectJournal, EffectKey, EffectKind, Outcome};
pub use lifecycle::{BrainLifecycle, TickReport};
pub use relay::Relay;
pub use frame::{
    accept_hello, Envelope, HandshakeError, Hello, Role, IPC_PROTOCOL_VERSION,
    MIN_COMPATIBLE_VERSION,
};
pub use transport::{DaemonTransport, LocalSocketTransport};


NOTE FROM THE USER: proceed with nextsteps.


Now read daemon brain/lifecycle/relay/msg + spt main to see daemon entrypoint + how IPC connects.


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

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

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

use interprocess::local_socket::Stream;

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

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

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

/// A demuxed broker→brain event.
#[derive(Debug)]
pub enum BrokerEvent {
    /// A contiguous chunk of a session's output (the cursor has advanced past it).
    Output { session_id: u64, seq: u64, bytes: Vec<u8> },
    /// A spawn succeeded.
    Spawned { session_id: u64, pid: Option<u32> },
    /// A session's child exited.
    Exit { session_id: u64, code: Option<i32> },
    /// An idempotent effect is durably applied (or was deduped — `applied_now`
    /// distinguishes). The exactly-once ack for a [`Brain::send_effect`] (B5).
    Applied { session_id: u64, op_id: u64, applied_now: bool },
    /// The broker reported a command error.
    Error { message: String },
    /// A frame of an unrecognized kind (forward-compat: tolerated, surfaced).
    Other(Envelope),
}

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

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

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

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

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

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

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

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

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

    /// Inject input bytes into the current session (send-keys), the **legacy
    /// direct** path: no durable id, no dedup, no ack. For exactly-once delivery
    /// of an effect that originates from a durable source (a spool row, a
    /// sequenced injection), use [`Brain::send_effect`].
    pub fn send_input(&mut self, bytes: &[u8]) -> io::Result<()> {
        let sid = self.require_session()?;
        self.send(
            KIND_INPUT,
            serde_json::to_value(InputReq { session_id: sid, data_b64: encode_bytes(bytes), op_id: None })
                .expect("InputReq serializes"),
        )
    }

    /// Inject input **idempotently**: the write carries the durable `op_id` so a
    /// replay after a brain crash is deduped at the broker (exactly-once, B5).
    /// `op_id` must come from a durable source and be reused verbatim on replay —
    /// the same logical effect must always carry the same id, so the broker's
    /// applied-set recognizes the re-drive. The broker answers with a
    /// [`BrokerEvent::Applied`] for `(session, op_id)`.
    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
    pub fn send_effect(&mut self, op_id: u64, bytes: &[u8]) -> io::Result<()> {
        let sid = self.require_session()?;
        self.send(
            KIND_INPUT,
            serde_json::to_value(InputReq {
                session_id: sid,
                data_b64: encode_bytes(bytes),
                op_id: Some(op_id),
            })
            .expect("InputReq serializes"),
        )
    }

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

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

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

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

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

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

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

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