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);
    }
}


<owl_messages>
<owl_message from="doyle-psyche" priority="normal"><EVENT type="echo_commune" from="doyle-psyche" timestamp="2026-06-02T05:12:11-07:00" machine="HFENDULEAM" project="spt-core" branch="dev-freeform" head_sha="c40524f8a2f92fd72a795d7a386031c1953fae51" head_subject="feat(M3b-B2): brain + gapless handoff (kill/restart survives, fresh gen_…" note="Echo commune brief — auto-fired">&lt;live-context&gt;
&lt;/live-context&gt;
&lt;project-context&gt;
&lt;/project-context&gt;</EVENT></owl_message>
</owl_messages>


1	//! pulse heartbeat (M2b T5) — the interim per-tick driver.
2	//!
3	//! In the no-daemon interim model, the long-running `api listen` process
4	//! (T8) hosts a pulse loop. Each tick does the lightweight, recurring work:
5	//! - **poll the drop dirs** → fold any commune/signoff drops into context
6	//!   (single-writer ingest→delete — T4);
7	//! - **fire the pending echo** → if the echo-gate (`.more-done`, armed by
8	//!   `api state idle`) is set, signal that an echo is due and **clear the gate**
9	//!   so it fires *exactly once*;
10	//! - surface the **idle** flag for the caller's capsule/idle decisions.
11	//!
12	//! The pulse is deliberately thin — it decides *when* an echo is due and
13	//! guarantees once-only; the heavy echo run ([`crate::echo::run_echo_commune`])
14	//! is the caller's (T8), which holds the runtime + history + commune dir. Keep
15	//! all of this behind this one module so M3's daemon re-host (Psyche-as-loop,
16	//! daemon-owned period) is a localized swap, not a rewrite (mirrors M2a's
17	//! seed-file discipline).
18	//
19	// Interim only: the durable pulse loop + its global/per-endpoint period config
20	// belong to the M3 daemon (CONTEXT R-DAEMON-1). No dedicated REQ — the pulse is
21	// throwaway glue under REQ-SEAM-PSYCHE (see M2b-PLAN §New requirements).
22
23	use crate::context::DEFAULT_PROTECTION_WINDOW_MS;
24	use crate::ingest::{ingest_drops, Ingested};
25	use spt_store::perch::{resolve_echo_gate_file, resolve_idle_file, ParentHint};
26	use std::path::Path;
27	use std::time::Duration;
28
29	/// Interim default pulse period. The durable, configurable knob (global +
30	/// per-endpoint override) is the M3 daemon's; this is the interim constant.
31	pub const DEFAULT_PULSE_PERIOD: Duration = Duration::from_secs(5);
32
33	/// Pulse tuning. `period` paces the `api listen` loop; `protection_window_ms`
34	/// is threaded into the drop-file precedence guard (6.5).
35	#[derive(Debug, Clone, Copy)]
36	pub struct PulseConfig {
37	    pub period: Duration,
38	    pub protection_window_ms: u64,
39	}
40
41	impl Default for PulseConfig {
42	    fn default() -> Self {
43	        PulseConfig {
44	            period: DEFAULT_PULSE_PERIOD,
45	            protection_window_ms: DEFAULT_PROTECTION_WINDOW_MS,
46	        }
47	    }
48	}
49
50	/// What one tick observed/did. `echo_due` means the gate was armed and has now
51	/// been cleared — the caller should fire exactly one echo-commune.
52	#[derive(Debug, Clone)]
53	pub struct TickOutcome {
54	    pub ingested: Vec<Ingested>,
55	    pub echo_due: bool,
56	    pub idle: bool,
57	}
58
59	/// Run one pulse tick for `id`: ingest drops from `drop_dir`, then read+clear
60	/// the echo-gate (once-only fire) and the idle flag. `now_ms` stamps the
61	/// precedence markers of any ingested writes.
62	pub fn tick(
63	    id: &str,
64	    hint: ParentHint<'_>,
65	    drop_dir: &Path,
66	    now_ms: u64,
67	    cfg: &PulseConfig,
68	) -> std::io::Result<TickOutcome> {
69	    let ingested = ingest_drops(drop_dir, id, hint.clone(), now_ms, cfg.protection_window_ms)?;
70	    let echo_due = take_echo_gate(id, hint.clone());
71	    let idle = resolve_idle_file(id, hint).exists();
72	    Ok(TickOutcome {
73	        ingested,
74	        echo_due,
75	        idle,
76	    })
77	}
78
79	/// Read-and-clear the echo-gate: returns whether it was armed, removing the
80	/// sentinel so the echo fires exactly once. Clearing a missing gate is a no-op.
81	pub fn take_echo_gate(id: &str, hint: ParentHint<'_>) -> bool {
82	    let gate = resolve_echo_gate_file(id, hint);
83	    if gate.exists() {
84	        // Best-effort clear; if removal fails the next tick re-fires (at-least-
85	        // once degrades safely — a duplicate echo is harmless, a lost one less so).
86	        let _ = std::fs::remove_file(&gate);
87	        true
88	    } else {
89	        false
90	    }
91	}
92
93	#[cfg(test)]
94	mod tests {
95	    use super::*;
96	    use crate::test_support::with_home;
97	    use spt_store::perch::resolve_perch_path;
98
99	    fn arm_gate(id: &str) {
100	        let perch = resolve_perch_path(id, ParentHint::Infer);
101	        std::fs::create_dir_all(&perch).unwrap();
102	        std::fs::write(resolve_echo_gate_file(id, ParentHint::Infer), "").unwrap();
103	    }
104
105	    #[test] // [unit->REQ-SEAM-PSYCHE] an armed echo-gate fires exactly once, then clears.
106	    fn echo_gate_fires_exactly_once() {
107	        with_home(|_| {
108	            let drops = tempfile::tempdir().unwrap();
109	            arm_gate("doyle");
110
111	            let t1 = tick("doyle", ParentHint::Infer, drops.path(), 1000, &PulseConfig::default())
112	                .expect("tick");
113	            assert!(t1.echo_due, "armed gate should fire");
114
115	            // Second tick: gate cleared, no re-fire.
116	            let t2 = tick("doyle", ParentHint::Infer, drops.path(), 2000, &PulseConfig::default())
117	                .expect("tick");
118	            assert!(!t2.echo_due, "gate must clear after one fire");
119	        });
120	    }
121
122	    #[test] // [unit->REQ-SEAM-PSYCHE] a tick polls + ingests the drop dir.
123	    fn tick_ingests_drops() {
124	        with_home(|_| {
125	            let drops = tempfile::tempdir().unwrap();
126	            std::fs::write(drops.path().join("doyle-commune.md"), "brief").unwrap();
127
128	            let t = tick("doyle", ParentHint::Infer, drops.path(), 1000, &PulseConfig::default())
129	                .expect("tick");
130	            assert_eq!(t.ingested.len(), 1);
131	            assert!(!t.echo_due, "no gate armed");
132	            assert!(!drops.path().join("doyle-commune.md").exists(), "drop consumed");
133	        });
134	    }
135
136	    #[test] // [unit->REQ-SEAM-PSYCHE] idle sentinel surfaces in the tick outcome.
137	    fn tick_reports_idle() {
138	        with_home(|_| {
139	            let drops = tempfile::tempdir().unwrap();
140	            let perch = resolve_perch_path("doyle", ParentHint::Infer);
141	            std::fs::create_dir_all(&perch).unwrap();
142	            std::fs::write(resolve_idle_file("doyle", ParentHint::Infer), "").unwrap();
143
144	            let t = tick("doyle", ParentHint::Infer, drops.path(), 1000, &PulseConfig::default())
145	                .expect("tick");
146	            assert!(t.idle);
147	        });
148	    }
149	}
150

//! pulse heartbeat (M2b T5) — the interim per-tick driver.
//!
//! In the no-daemon interim model, the long-running `api listen` process
//! (T8) hosts a pulse loop. Each tick does the lightweight, recurring work:
//! - **poll the drop dirs** → fold any commune/signoff drops into context
//!   (single-writer ingest→delete — T4);
//! - **fire the pending echo** → if the echo-gate (`.more-done`, armed by
//!   `api state idle`) is set, signal that an echo is due and **clear the gate**
//!   so it fires *exactly once*;
//! - surface the **idle** flag for the caller's capsule/idle decisions.
//!
//! The pulse is deliberately thin — it decides *when* an echo is due and
//! guarantees once-only; the heavy echo run ([`crate::echo::run_echo_commune`])
//! is the caller's (T8), which holds the runtime + history + commune dir. Keep
//! all of this behind this one module so M3's daemon re-host (Psyche-as-loop,
//! daemon-owned period) is a localized swap, not a rewrite (mirrors M2a's
//! seed-file discipline).
//
// Interim only: the durable pulse loop + its global/per-endpoint period config
// belong to the M3 daemon (CONTEXT R-DAEMON-1). No dedicated REQ — the pulse is
// throwaway glue under REQ-SEAM-PSYCHE (see M2b-PLAN §New requirements).

use crate::context::DEFAULT_PROTECTION_WINDOW_MS;
use crate::ingest::{ingest_drops, Ingested};
use spt_store::perch::{resolve_echo_gate_file, resolve_idle_file, ParentHint};
use std::path::Path;
use std::time::Duration;

/// Interim default pulse period. The durable, configurable knob (global +
/// per-endpoint override) is the M3 daemon's; this is the interim constant.
pub const DEFAULT_PULSE_PERIOD: Duration = Duration::from_secs(5);

/// Pulse tuning. `period` paces the `api listen` loop; `protection_window_ms`
/// is threaded into the drop-file precedence guard (6.5).
#[derive(Debug, Clone, Copy)]
pub struct PulseConfig {
    pub period: Duration,
    pub protection_window_ms: u64,
}

impl Default for PulseConfig {
    fn default() -> Self {
        PulseConfig {
            period: DEFAULT_PULSE_PERIOD,
            protection_window_ms: DEFAULT_PROTECTION_WINDOW_MS,
        }
    }
}

/// What one tick observed/did. `echo_due` means the gate was armed and has now
/// been cleared — the caller should fire exactly one echo-commune.
#[derive(Debug, Clone)]
pub struct TickOutcome {
    pub ingested: Vec<Ingested>,
    pub echo_due: bool,
    pub idle: bool,
}

/// Run one pulse tick for `id`: ingest drops from `drop_dir`, then read+clear
/// the echo-gate (once-only fire) and the idle flag. `now_ms` stamps the
/// precedence markers of any ingested writes.
pub fn tick(
    id: &str,
    hint: ParentHint<'_>,
    drop_dir: &Path,
    now_ms: u64,
    cfg: &PulseConfig,
) -> std::io::Result<TickOutcome> {
    let ingested = ingest_drops(drop_dir, id, hint.clone(), now_ms, cfg.protection_window_ms)?;
    let echo_due = take_echo_gate(id, hint.clone());
    let idle = resolve_idle_file(id, hint).exists();
    Ok(TickOutcome {
        ingested,
        echo_due,
        idle,
    })
}

/// Read-and-clear the echo-gate: returns whether it was armed, removing the
/// sentinel so the echo fires exactly once. Clearing a missing gate is a no-op.
pub fn take_echo_gate(id: &str, hint: ParentHint<'_>) -> bool {
    let gate = resolve_echo_gate_file(id, hint);
    if gate.exists() {
        // Best-effort clear; if removal fails the next tick re-fires (at-least-
        // once degrades safely — a duplicate echo is harmless, a lost one less so).
        let _ = std::fs::remove_file(&gate);
        true
    } else {
        false
    }
}

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

    fn arm_gate(id: &str) {
        let perch = resolve_perch_path(id, ParentHint::Infer);
        std::fs::create_dir_all(&perch).unwrap();
        std::fs::write(resolve_echo_gate_file(id, ParentHint::Infer), "").unwrap();
    }

    #[test] // [unit->REQ-SEAM-PSYCHE] an armed echo-gate fires exactly once, then clears.
    fn echo_gate_fires_exactly_once() {
        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            arm_gate("doyle");

            let t1 = tick("doyle", ParentHint::Infer, drops.path(), 1000, &PulseConfig::default())
                .expect("tick");
            assert!(t1.echo_due, "armed gate should fire");

            // Second tick: gate cleared, no re-fire.
            let t2 = tick("doyle", ParentHint::Infer, drops.path(), 2000, &PulseConfig::default())
                .expect("tick");
            assert!(!t2.echo_due, "gate must clear after one fire");
        });
    }

    #[test] // [unit->REQ-SEAM-PSYCHE] a tick polls + ingests the drop dir.
    fn tick_ingests_drops() {
        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            std::fs::write(drops.path().join("doyle-commune.md"), "brief").unwrap();

            let t = tick("doyle", ParentHint::Infer, drops.path(), 1000, &PulseConfig::default())
                .expect("tick");
            assert_eq!(t.ingested.len(), 1);
            assert!(!t.echo_due, "no gate armed");
            assert!(!drops.path().join("doyle-commune.md").exists(), "drop consumed");
        });
    }

    #[test] // [unit->REQ-SEAM-PSYCHE] idle sentinel surfaces in the tick outcome.
    fn tick_reports_idle() {
        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            let perch = resolve_perch_path("doyle", ParentHint::Infer);
            std::fs::create_dir_all(&perch).unwrap();
            std::fs::write(resolve_idle_file("doyle", ParentHint::Infer), "").unwrap();

            let t = tick("doyle", ParentHint::Infer, drops.path(), 1000, &PulseConfig::default())
                .expect("tick");
            assert!(t.idle);
        });
    }
}


NOTE fROM THE USER: proceedwithnextsteps.


Echo commune empty, FYI. Read LiveHost seam next.


1	//! LiveAgent lifecycle hosting (M2b T8) — the `api listen` runner's live half.
2	//!
3	//! In the interim no-daemon model, the long-running `api listen` process hosts
4	//! the LiveAgent lifecycle over the M2b `spt-live` seams: spawn the Psyche (T2),
5	//! pulse-driven drop ingest + echo fires (T3/T4/T5), and the graceful signoff
6	//! ordering (T6). The surface stays **thin** — M3's daemon re-hosts all of this
7	//! (Psyche-as-loop, daemon-owned period); keep the wiring localized so that move
8	//! is a re-host, not a rewrite.
9	//
10	// [impl->REQ-SEAM-PSYCHE]
11
12	use std::collections::BTreeMap;
13	use std::path::PathBuf;
14	use std::time::{Duration, SystemTime, UNIX_EPOCH};
15
16	use spt_live::{
17	    fetch_history, graceful_signoff, ingest_drops, run_echo_commune, spawn_psyche,
18	    sweep_stale_signoff, HistoryRecord, Ingested, PsycheHandle, PulseConfig, SignoffOutcome,
19	};
20	use spt_runtime::{Manifest, ManifestRuntime};
21	use spt_store::perch::{self, ParentHint};
22
23	/// Bound for a bounded history/echo invocation in the host.
24	const LIVE_BOUNDED_TIMEOUT: Duration = Duration::from_secs(30);
25
26	/// Epoch-millis stamp for precedence markers (monotonic enough for the local
27	/// guard; the wall clock is fine for the interim single-node window).
28	fn now_ms() -> u64 {
29	    SystemTime::now()
30	        .duration_since(UNIX_EPOCH)
31	        .map(|d| d.as_millis() as u64)
32	        .unwrap_or(0)
33	}
34
35	/// ISO-ish stamp for the psyche prompt (opaque to the Psyche; just needs to be
36	/// a timestamp). Reuses epoch-millis to avoid a date dependency.
37	fn now_stamp() -> String {
38	    format!("epoch-ms:{}", now_ms())
39	}
40
41	/// The LiveAgent lifecycle host: the runtime + the watched dirs, built from a
42	/// live-capable manifest. `None` for a non-live manifest.
43	pub struct LiveHost {
44	    runtime: ManifestRuntime,
45	    id: String,
46	    manifest: Manifest,
47	    commune_dir: Option<PathBuf>,
48	    signoff_dir: Option<PathBuf>,
49	    cfg: PulseConfig,
50	}
51
52	impl LiveHost {
53	    /// Build a host for `id` from a live-capable manifest; `None` if the
54	    /// manifest is absent or declares no Psyche (a plain relay endpoint).
55	    pub fn new_opt(manifest: Option<&Manifest>, id: &str) -> Option<LiveHost> {
56	        LiveHost::new(manifest?, id)
57	    }
58
59	    /// Build a host for `id` from a live-capable manifest; `None` otherwise.
60	    pub fn new(manifest: &Manifest, id: &str) -> Option<LiveHost> {
61	        manifest.session.psyche_init.as_ref()?;
62	        Some(LiveHost {
63	            runtime: ManifestRuntime::new(manifest.clone()),
64	            id: id.to_string(),
65	            commune_dir: manifest.session.commune_dir.as_ref().map(PathBuf::from),
66	            signoff_dir: manifest.session.signoff_dir.as_ref().map(PathBuf::from),
67	            manifest: manifest.clone(),
68	            cfg: PulseConfig::default(),
69	        })
70	    }
71
72	    /// Base substitution keys spt-core can fill for the lifecycle templates.
73	    fn base_keys(&self, session_id: Option<&str>) -> BTreeMap<String, String> {
74	        let mut k = BTreeMap::new();
75	        k.insert("id".to_string(), self.id.clone());
76	        if let Some(sid) = session_id {
77	            k.insert("session_id".to_string(), sid.to_string());
78	        }
79	        k
80	    }
81
82	    /// The unique watched drop dirs (commune + signoff; usually the same dir).
83	    fn drop_dirs(&self) -> Vec<PathBuf> {
84	        let mut dirs: Vec<PathBuf> = Vec::new();
85	        for d in [self.commune_dir.as_ref(), self.signoff_dir.as_ref()]
86	            .into_iter()
87	            .flatten()
88	        {
89	            if !dirs.contains(d) {
90	                dirs.push(d.clone());
91	            }
92	        }
93	        dirs
94	    }
95
96	    /// Sweep a stale signoff sentinel on listener start (3.2). No-op if no
97	    /// signoff dir is declared.
98	    pub fn sweep_on_start(&self) {
99	        if let Some(dir) = &self.signoff_dir {
100	            let _ = sweep_stale_signoff(dir, &self.id);
101	        }
102	    }
103
104	    /// Spawn the Psyche companion (T2). `event` is the incoming envelope (if the
105	    /// spawn is event-driven); `None` at cold start.
106	    pub fn spawn_psyche(
107	        &self,
108	        session_id: Option<&str>,
109	        event: Option<&str>,
110	    ) -> Result<PsycheHandle, String> {
111	        spawn_psyche(
112	            &self.runtime,
113	            &self.id,
114	            &self.base_keys(session_id),
115	            event,
116	            &now_stamp(),
117	        )
118	        .map_err(|e| e.to_string())
119	    }
120
121	    /// One pulse tick (T5): ingest any drops from the watched dirs (T4), then if
122	    /// the echo-gate is armed, fire exactly one echo-commune (T3). Returns the
123	    /// ingested drops and whether an echo fired.
124	    pub fn pulse_tick(&self, session_id: Option<&str>) -> Result<(Vec<Ingested>, bool), String> {
125	        let now = now_ms();
126	        let mut ingested = Vec::new();
127	        for dir in self.drop_dirs() {
128	            let mut got = ingest_drops(&dir, &self.id, ParentHint::Infer, now, self.cfg.protection_window_ms)
129	                .map_err(|e| e.to_string())?;
130	            ingested.append(&mut got);
131	        }
132	        // Read+clear the echo-gate: fire exactly once.
133	        let echo_fired = if spt_live::pulse::take_echo_gate(&self.id, ParentHint::Infer) {
134	            self.fire_echo(session_id)?;
135	            true
136	        } else {
137	            false
138	        };
139	        Ok((ingested, echo_fired))
140	    }
141
142	    /// Fetch history per the manifest `[history]` strategy (empty if none).
143	    fn history(&self, session_id: Option<&str>) -> Vec<HistoryRecord> {
144	        let Some(h) = &self.manifest.history else {
145	            return Vec::new();
146	        };
147	        fetch_history(
148	            h,
149	            &self.base_keys(session_id),
150	            &self.id,
151	            ParentHint::Infer,
152	            LIVE_BOUNDED_TIMEOUT,
153	        )
154	        .unwrap_or_default()
155	    }
156
157	    /// Run the echo-commune into the commune dir (T3). No-op (Ok) if no commune
158	    /// dir is declared.
159	    fn fire_echo(&self, session_id: Option<&str>) -> Result<(), String> {
160	        let Some(dir) = &self.commune_dir else {
161	            return Ok(());
162	        };
163	        let history = self.history(session_id);
164	        run_echo_commune(
165	            &self.runtime,
166	            &self.id,
167	            &self.base_keys(session_id),
168	            &history,
169	            dir,
170	            LIVE_BOUNDED_TIMEOUT,
171	        )
172	        .map(|_| ())
173	        .map_err(|e| e.to_string())
174	    }
175
176	    /// Execute the graceful signoff (T6): echo-commune BEFORE teardown (3.3),
177	    /// grace recheck before any signoff write (1.1). The interim graceful path
178	    /// uses a zero grace + always-gone recheck (an intentional shutdown); M3's
179	    /// orphan-watch supplies the real grace/liveness recheck. Teardown is the
180	    /// soft cleanup (remove the `ready` marker — 6.2), preserving state.
181	    pub fn graceful_signoff(&self, session_id: Option<&str>) -> Result<SignoffOutcome, String> {
182	        let mut echo_err: Option<String> = None;
183	        let mut teardown_err: Option<String> = None;
184	        let outcome = graceful_signoff::<std::convert::Infallible>(
185	            session_id,
186	            Duration::ZERO,
187	            || {
188	                if let Err(e) = self.fire_echo(session_id) {
189	                    echo_err = Some(e);
190	                }
191	                Ok(())
192	            },
193	            || true, // graceful, intentional shutdown — the Self is leaving on purpose
194	            || {
195	                // Soft teardown: drop the online marker, preserve info+spool (6.2).
196	                let ready = perch::resolve_ready_file(&self.id, ParentHint::Infer);
197	                if let Err(e) = std::fs::remove_file(&ready) {
198	                    if e.kind() != std::io::ErrorKind::NotFound {
199	                        teardown_err = Some(e.to_string());
200	                    }
201	                }
202	                Ok(())
203	            },
204	        )
205	        .unwrap_or(SignoffOutcome { echo_ran: false, signed_off: false });
206	        if let Some(e) = echo_err {
207	            return Err(format!("echo failed during signoff: {e}"));
208	        }
209	        if let Some(e) = teardown_err {
210	            return Err(format!("teardown failed during signoff: {e}"));
211	        }
212	        Ok(outcome)
213	    }
214	}
215
216	/// `api shutdown <id>` — the graceful live-agent signoff path. Builds the live
217	/// host from the manifest and runs the echo-before-teardown ordering (T6). A
218	/// non-live manifest (no Psyche) is a plain soft-stop: just drop the `ready`
219	/// marker. Auth-gated by the dispatcher.
220	pub fn cmd_shutdown(id: &str, manifest: Option<&Manifest>) -> i32 {
221	    let session_id = perch_session_id(id);
222	    match LiveHost::new_opt(manifest, id) {
223	        Some(host) => match host.graceful_signoff(session_id.as_deref()) {
224	            Ok(outcome) => {
225	                eprintln!(
226	                    "SHUTDOWN:{id} echo_ran={} signed_off={}",
227	                    outcome.echo_ran, outcome.signed_off
228	                );
229	                0
230	            }
231	            Err(e) => {
232	                eprintln!("SHUTDOWN_FAIL:{id}: {e}");
233	                super::EXIT_REFUSED
234	            }
235	        },
236	        None => {
237	            // Non-live endpoint: soft-stop only (remove the online marker).
238	            let _ = std::fs::remove_file(perch::resolve_ready_file(id, ParentHint::Infer));
239	            eprintln!("SHUTDOWN:{id} (non-live soft-stop)");
240	            0
241	        }
242	    }
243	}
244
245	/// The bound session id from the perch's info.json, if any.
246	fn perch_session_id(id: &str) -> Option<String> {
247	    let path = perch::resolve_perch_path(id, ParentHint::Infer);
248	    spt_store::info::read_info(&path)
249	        .map(|i| i.session_id)
250	        .filter(|s| !s.is_empty())
251	}
252

//! LiveAgent lifecycle hosting (M2b T8) — the `api listen` runner's live half.
//!
//! In the interim no-daemon model, the long-running `api listen` process hosts
//! the LiveAgent lifecycle over the M2b `spt-live` seams: spawn the Psyche (T2),
//! pulse-driven drop ingest + echo fires (T3/T4/T5), and the graceful signoff
//! ordering (T6). The surface stays **thin** — M3's daemon re-hosts all of this
//! (Psyche-as-loop, daemon-owned period); keep the wiring localized so that move
//! is a re-host, not a rewrite.
//
// [impl->REQ-SEAM-PSYCHE]

use std::collections::BTreeMap;
use std::path::PathBuf;
use std::time::{Duration, SystemTime, UNIX_EPOCH};

use spt_live::{
    fetch_history, graceful_signoff, ingest_drops, run_echo_commune, spawn_psyche,
    sweep_stale_signoff, HistoryRecord, Ingested, PsycheHandle, PulseConfig, SignoffOutcome,
};
use spt_runtime::{Manifest, ManifestRuntime};
use spt_store::perch::{self, ParentHint};

/// Bound for a bounded history/echo invocation in the host.
const LIVE_BOUNDED_TIMEOUT: Duration = Duration::from_secs(30);

/// Epoch-millis stamp for precedence markers (monotonic enough for the local
/// guard; the wall clock is fine for the interim single-node window).
fn now_ms() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0)
}

/// ISO-ish stamp for the psyche prompt (opaque to the Psyche; just needs to be
/// a timestamp). Reuses epoch-millis to avoid a date dependency.
fn now_stamp() -> String {
    format!("epoch-ms:{}", now_ms())
}

/// The LiveAgent lifecycle host: the runtime + the watched dirs, built from a
/// live-capable manifest. `None` for a non-live manifest.
pub struct LiveHost {
    runtime: ManifestRuntime,
    id: String,
    manifest: Manifest,
    commune_dir: Option<PathBuf>,
    signoff_dir: Option<PathBuf>,
    cfg: PulseConfig,
}

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

    /// Build a host for `id` from a live-capable manifest; `None` otherwise.
    pub fn new(manifest: &Manifest, id: &str) -> Option<LiveHost> {
        manifest.session.psyche_init.as_ref()?;
        Some(LiveHost {
            runtime: ManifestRuntime::new(manifest.clone()),
            id: id.to_string(),
            commune_dir: manifest.session.commune_dir.as_ref().map(PathBuf::from),
            signoff_dir: manifest.session.signoff_dir.as_ref().map(PathBuf::from),
            manifest: manifest.clone(),
            cfg: PulseConfig::default(),
        })
    }

    /// Base substitution keys spt-core can fill for the lifecycle templates.
    fn base_keys(&self, session_id: Option<&str>) -> BTreeMap<String, String> {
        let mut k = BTreeMap::new();
        k.insert("id".to_string(), self.id.clone());
        if let Some(sid) = session_id {
            k.insert("session_id".to_string(), sid.to_string());
        }
        k
    }

    /// The unique watched drop dirs (commune + signoff; usually the same dir).
    fn drop_dirs(&self) -> Vec<PathBuf> {
        let mut dirs: Vec<PathBuf> = Vec::new();
        for d in [self.commune_dir.as_ref(), self.signoff_dir.as_ref()]
            .into_iter()
            .flatten()
        {
            if !dirs.contains(d) {
                dirs.push(d.clone());
            }
        }
        dirs
    }

    /// Sweep a stale signoff sentinel on listener start (3.2). No-op if no
    /// signoff dir is declared.
    pub fn sweep_on_start(&self) {
        if let Some(dir) = &self.signoff_dir {
            let _ = sweep_stale_signoff(dir, &self.id);
        }
    }

    /// Spawn the Psyche companion (T2). `event` is the incoming envelope (if the
    /// spawn is event-driven); `None` at cold start.
    pub fn spawn_psyche(
        &self,
        session_id: Option<&str>,
        event: Option<&str>,
    ) -> Result<PsycheHandle, String> {
        spawn_psyche(
            &self.runtime,
            &self.id,
            &self.base_keys(session_id),
            event,
            &now_stamp(),
        )
        .map_err(|e| e.to_string())
    }

    /// One pulse tick (T5): ingest any drops from the watched dirs (T4), then if
    /// the echo-gate is armed, fire exactly one echo-commune (T3). Returns the
    /// ingested drops and whether an echo fired.
    pub fn pulse_tick(&self, session_id: Option<&str>) -> Result<(Vec<Ingested>, bool), String> {
        let now = now_ms();
        let mut ingested = Vec::new();
        for dir in self.drop_dirs() {
            let mut got = ingest_drops(&dir, &self.id, ParentHint::Infer, now, self.cfg.protection_window_ms)
                .map_err(|e| e.to_string())?;
            ingested.append(&mut got);
        }
        // Read+clear the echo-gate: fire exactly once.
        let echo_fired = if spt_live::pulse::take_echo_gate(&self.id, ParentHint::Infer) {
            self.fire_echo(session_id)?;
            true
        } else {
            false
        };
        Ok((ingested, echo_fired))
    }

    /// Fetch history per the manifest `[history]` strategy (empty if none).
    fn history(&self, session_id: Option<&str>) -> Vec<HistoryRecord> {
        let Some(h) = &self.manifest.history else {
            return Vec::new();
        };
        fetch_history(
            h,
            &self.base_keys(session_id),
            &self.id,
            ParentHint::Infer,
            LIVE_BOUNDED_TIMEOUT,
        )
        .unwrap_or_default()
    }

    /// Run the echo-commune into the commune dir (T3). No-op (Ok) if no commune
    /// dir is declared.
    fn fire_echo(&self, session_id: Option<&str>) -> Result<(), String> {
        let Some(dir) = &self.commune_dir else {
            return Ok(());
        };
        let history = self.history(session_id);
        run_echo_commune(
            &self.runtime,
            &self.id,
            &self.base_keys(session_id),
            &history,
            dir,
            LIVE_BOUNDED_TIMEOUT,
        )
        .map(|_| ())
        .map_err(|e| e.to_string())
    }

    /// Execute the graceful signoff (T6): echo-commune BEFORE teardown (3.3),
    /// grace recheck before any signoff write (1.1). The interim graceful path
    /// uses a zero grace + always-gone recheck (an intentional shutdown); M3's
    /// orphan-watch supplies the real grace/liveness recheck. Teardown is the
    /// soft cleanup (remove the `ready` marker — 6.2), preserving state.
    pub fn graceful_signoff(&self, session_id: Option<&str>) -> Result<SignoffOutcome, String> {
        let mut echo_err: Option<String> = None;
        let mut teardown_err: Option<String> = None;
        let outcome = graceful_signoff::<std::convert::Infallible>(
            session_id,
            Duration::ZERO,
            || {
                if let Err(e) = self.fire_echo(session_id) {
                    echo_err = Some(e);
                }
                Ok(())
            },
            || true, // graceful, intentional shutdown — the Self is leaving on purpose
            || {
                // Soft teardown: drop the online marker, preserve info+spool (6.2).
                let ready = perch::resolve_ready_file(&self.id, ParentHint::Infer);
                if let Err(e) = std::fs::remove_file(&ready) {
                    if e.kind() != std::io::ErrorKind::NotFound {
                        teardown_err = Some(e.to_string());
                    }
                }
                Ok(())
            },
        )
        .unwrap_or(SignoffOutcome { echo_ran: false, signed_off: false });
        if let Some(e) = echo_err {
            return Err(format!("echo failed during signoff: {e}"));
        }
        if let Some(e) = teardown_err {
            return Err(format!("teardown failed during signoff: {e}"));
        }
        Ok(outcome)
    }
}

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

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


Seams clear. Now check daemon deps, broker, spt-live exports, test layout.


1	[package]
2	name = "spt-daemon"
3	version = "0.0.0"
4	edition = "2021"
5	publish = false
6	description = "spt-core broker/brain daemon (M3b): the per-machine supervisor process. A stable broker kernel holds the un-transferable resources (PTY masters from spt-term, harness children, sockets) while a restartable brain holds all logic; a versioned local IPC lets a newer brain talk to an older broker so a self-update swaps the brain with zero endpoint interruption (ADR-0004). Internal supervisor (NOT public SDK, R-ARCH-2) — the `spt` binary is a thin CLI over it. B0 lands the crate + the versioned local-IPC transport."
7
8	[dependencies]
9	# Layer-below set (R-ARCH-1 acyclic): …→spt-live→spt-daemon→spt. The broker
10	# re-hosts spt-term PTYs + the spt-live lifecycle seams; these edges fix the
11	# crate's position in the workspace layering. Tasks B1–B8 consume them.
12	spt-proto = { path = "../spt-proto" }
13	spt-store = { path = "../spt-store" }
14	spt-msg = { path = "../spt-msg" }
15	spt-runtime = { path = "../spt-runtime" }
16	spt-term = { path = "../spt-term" }
17	spt-live = { path = "../spt-live" }
18	serde = { version = "1", features = ["derive"] }
19	serde_json = { version = "1", features = ["preserve_order"] }
20	# PTY output is arbitrary bytes; the forward-compat JSON envelope carries it as
21	# a base64 string in the payload rather than forking the codec for binary.
22	base64 = "0.22"
23	# Cross-platform local IPC: named pipe (Windows) / Unix-domain socket (unix)
24	# behind one API — the OS-split substrate the DaemonTransport trait wraps,
25	# mirroring how spt-term abstracts ConPTY vs forkpty behind SessionSurface.
26	interprocess = "2"
27
28	[dev-dependencies]
29	tempfile = "3"
30

[package]
name = "spt-daemon"
version = "0.0.0"
edition = "2021"
publish = false
description = "spt-core broker/brain daemon (M3b): the per-machine supervisor process. A stable broker kernel holds the un-transferable resources (PTY masters from spt-term, harness children, sockets) while a restartable brain holds all logic; a versioned local IPC lets a newer brain talk to an older broker so a self-update swaps the brain with zero endpoint interruption (ADR-0004). Internal supervisor (NOT public SDK, R-ARCH-2) — the `spt` binary is a thin CLI over it. B0 lands the crate + the versioned local-IPC transport."

[dependencies]
# Layer-below set (R-ARCH-1 acyclic): …→spt-live→spt-daemon→spt. The broker
# re-hosts spt-term PTYs + the spt-live lifecycle seams; these edges fix the
# crate's position in the workspace layering. Tasks B1–B8 consume them.
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
spt-runtime = { path = "../spt-runtime" }
spt-term = { path = "../spt-term" }
spt-live = { path = "../spt-live" }
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["preserve_order"] }
# PTY output is arbitrary bytes; the forward-compat JSON envelope carries it as
# a base64 string in the payload rather than forking the codec for binary.
base64 = "0.22"
# Cross-platform local IPC: named pipe (Windows) / Unix-domain socket (unix)
# behind one API — the OS-split substrate the DaemonTransport trait wraps,
# mirroring how spt-term abstracts ConPTY vs forkpty behind SessionSurface.
interprocess = "2"

[dev-dependencies]
tempfile = "3"


1	//! spt-core live-agent lifecycle executor (M2b).
2	//!
3	//! `spt-live` *executes* the lifecycle seams that `spt-runtime` (M2a) only
4	//! declared and parsed. It is the fifth layer in the acyclic stack
5	//! (`spt-proto -> spt-store -> spt-msg -> spt-runtime -> spt-live`); the `spt`
6	//! binary's `api listen` runner hosts it.
7	//!
8	//! Unlike `spt-runtime`, `spt-live` is **not** part of the public SDK surface
9	//! (R-ARCH-2 is proto/runtime/msg) — it is the daemon-bound executor M3
10	//! consolidates into `spt-daemon`. Keep every seam thin so that move is a
11	//! re-host, not a rewrite (mirror M2a's seed-file discipline).
12	//!
13	//! Subsystems land across M2b (see `M2b-PLAN.md`):
14	//! - **history** (T1, [`REQ-SEAM-HISTORY`]) — the three `[history]` strategies
15	//!   feeding the echo-commune + resume briefs.
16	//! - **spawn-psyche** (T2, `REQ-SEAM-PSYCHE`) — the nested `<id>-psyche` perch.
17	//! - **echo-commune** (T3) — the cheap-model brief consuming history.
18	//! - **drop-file ingest** (T4) — single-writer commune/signoff ingest (6.4/6.5).
19	//! - **pulse** (T5) — the interim heartbeat in the `api listen` runner.
20	//! - **signoff/boundary** (T6) — the orphan/grace ordering invariants (1.1/3.2/3.3).
21	//! - **resume** (T7, `REQ-SEAM-RESUME`) — fresh-with-preload + continue-existing.
22	//
23	// The interim no-daemon model holds (decision 2026-06-01): the Psyche is a real
24	// subprocess and the `api listen` process hosts the pulse/ingest loops. M3 moves
25	// both into the daemon. See `M2b-PLAN.md` §Interim-model note.
26
27	pub mod context;
28	pub mod echo;
29	pub mod history;
30	pub mod ingest;
31	pub mod psyche;
32	pub mod pulse;
33	pub mod resume;
34	pub mod signoff;
35
36	#[cfg(test)]
37	mod test_support;
38
39	pub use context::{write_context, WriteOutcome, WriteSource};
40	pub use echo::{run_echo_commune, stamp_provenance, EchoError, EchoResult};
41	pub use history::{fetch_history, HistoryError, HistoryRecord};
42	pub use ingest::{ingest_drops, DropKind, Ingested};
43	pub use psyche::{compose_psyche_prompt, spawn_psyche, PsycheError, PsycheHandle};
44	pub use pulse::{tick, PulseConfig, TickOutcome};
45	pub use resume::{download_psyche_context, resume_psyche, ResumeError, ResumeMode};
46	pub use signoff::{
47	    graceful_signoff, sweep_stale_signoff, write_resume_commune, SignoffOutcome,
48	};
49

//! spt-core live-agent lifecycle executor (M2b).
//!
//! `spt-live` *executes* the lifecycle seams that `spt-runtime` (M2a) only
//! declared and parsed. It is the fifth layer in the acyclic stack
//! (`spt-proto -> spt-store -> spt-msg -> spt-runtime -> spt-live`); the `spt`
//! binary's `api listen` runner hosts it.
//!
//! Unlike `spt-runtime`, `spt-live` is **not** part of the public SDK surface
//! (R-ARCH-2 is proto/runtime/msg) — it is the daemon-bound executor M3
//! consolidates into `spt-daemon`. Keep every seam thin so that move is a
//! re-host, not a rewrite (mirror M2a's seed-file discipline).
//!
//! Subsystems land across M2b (see `M2b-PLAN.md`):
//! - **history** (T1, [`REQ-SEAM-HISTORY`]) — the three `[history]` strategies
//!   feeding the echo-commune + resume briefs.
//! - **spawn-psyche** (T2, `REQ-SEAM-PSYCHE`) — the nested `<id>-psyche` perch.
//! - **echo-commune** (T3) — the cheap-model brief consuming history.
//! - **drop-file ingest** (T4) — single-writer commune/signoff ingest (6.4/6.5).
//! - **pulse** (T5) — the interim heartbeat in the `api listen` runner.
//! - **signoff/boundary** (T6) — the orphan/grace ordering invariants (1.1/3.2/3.3).
//! - **resume** (T7, `REQ-SEAM-RESUME`) — fresh-with-preload + continue-existing.
//
// The interim no-daemon model holds (decision 2026-06-01): the Psyche is a real
// subprocess and the `api listen` process hosts the pulse/ingest loops. M3 moves
// both into the daemon. See `M2b-PLAN.md` §Interim-model note.

pub mod context;
pub mod echo;
pub mod history;
pub mod ingest;
pub mod psyche;
pub mod pulse;
pub mod resume;
pub mod signoff;

#[cfg(test)]
mod test_support;

pub use context::{write_context, WriteOutcome, WriteSource};
pub use echo::{run_echo_commune, stamp_provenance, EchoError, EchoResult};
pub use history::{fetch_history, HistoryError, HistoryRecord};
pub use ingest::{ingest_drops, DropKind, Ingested};
pub use psyche::{compose_psyche_prompt, spawn_psyche, PsycheError, PsycheHandle};
pub use pulse::{tick, PulseConfig, TickOutcome};
pub use resume::{download_psyche_context, resume_psyche, ResumeError, ResumeMode};
pub use signoff::{
    graceful_signoff, sweep_stale_signoff, write_resume_commune, SignoffOutcome,
};