/// `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.
// [impl->REQ-START-2]
pub fn cmd_listen(id: &str, parent_pid_override: Option<u32>, once: bool) -> i32 {
    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;
        }
    };
    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);
    }
    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);
        }
        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);
        }
    }
}

/// `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 {
    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;
        }
    };

    // 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(None) {
            Ok(p) => eprintln!("PSYCHE_SPAWNED:{} pid={}", p.id, p.pid),
            Err(e) => eprintln!("PSYCHE_SPAWN_FAIL:{id}: {e}"),
        }
    }
    let session_id = info::read_info(&perch_path).map(|i| i.session_id);

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


The file C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\startup.rs has been updated successfully. (file state is current in your context — no need to Read it back)

/// `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.
// [impl->REQ-START-2]
pub fn cmd_listen(id: &str, parent_pid_override: Option<u32>, once: bool) -> i32 {
    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;
        }
    };
    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);
    }
    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);
        }
        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);
        }
    }
}

/// `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 {
    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;
        }
    };

    // 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(None) {
            Ok(p) => eprintln!("PSYCHE_SPAWNED:{} pid={}", p.id, p.pid),
            Err(e) => eprintln!("PSYCHE_SPAWN_FAIL:{id}: {e}"),
        }
    }
    let session_id = info::read_info(&perch_path).map(|i| i.session_id);

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

+/// 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.

-pub fn cmd_listen(id: &str, parent_pid_override: Option<u32>, once: bool) -> i32 {

+    manifest: Option<&spt_runtime::Manifest>,

+    // 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);

+        match host.spawn_psyche(None) {

+            Ok(p) => eprintln!("PSYCHE_SPAWNED:{} pid={}", p.id, p.pid),

+            Err(e) => eprintln!("PSYCHE_SPAWN_FAIL:{id}: {e}"),

+    let session_id = info::read_info(&perch_path).map(|i| i.session_id);

+    // One pulse tick: ingest drops + fire a pending echo (T4/T3/T5).

+    let pulse = |host: &Option<super::live::LiveHost>| {

+            if let Err(e) = h.pulse_tick(session_id.as_deref()) {

+                eprintln!("PULSE_FAIL:{id}: {e}");


`LiveHost::new_opt` referenced — add it. Check how contract_e2e drives the binary (bin vs lib).


1	//! M2a contract E2E (T9): the inbound + outbound halves of the harness contract
2	//! proven end-to-end through the **generic mock adapter** — no vendor harness.
3	//!
4	//! Flow: load the mock manifest → `ManifestRuntime` spawns the `mock-session`
5	//! helper → the helper fires `spt api bind` (skeleton→live) then
6	//! `spt api state idle` → a message is spooled to the perch → `spt api poll`
7	//! drains it. Asserts the round-trip.
8	//!
9	//! A second test exercises the **harness-hosted startup** topology proper —
10	//! `api seed` → `api listen` consumes the seed, binds, and relays a delivered
11	//! message — the REQ-START-2 path.
12	//
13	// [int->REQ-API-1]
14	// [int->REQ-API-2]
15
16	use std::path::PathBuf;
17	use std::process::Command;
18	use std::sync::Mutex;
19	use std::time::Duration;
20
21	/// Both tests mutate the process-global `SPT_HOME` (the in-test spt-store calls
22	/// and the spawned children read it). Serialize them so their homes don't race.
23	static E2E_LOCK: Mutex<()> = Mutex::new(());
24
25	use spt_runtime::{Manifest, ManifestRuntime, AgentRuntime};
26	use spt_store::perch::{self, ParentHint};
27	use spt_store::spool;
28
29	/// Absolute path to a sibling binary in the same target dir as the `spt` test bin.
30	fn sibling_bin(name: &str) -> PathBuf {
31	    let spt = PathBuf::from(env!("CARGO_BIN_EXE_spt"));
32	    spt.with_file_name(format!("{name}{}", std::env::consts::EXE_SUFFIX))
33	}
34
35	fn keys(pairs: &[(&str, &str)]) -> std::collections::BTreeMap<String, String> {
36	    pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
37	}
38
39	#[test]
40	fn mock_adapter_drives_the_full_contract() {
41	    let _serial = E2E_LOCK.lock().unwrap_or_else(|e| e.into_inner());
42	    // Isolated home shared by this process *and* the child `spt` invocations
43	    // (inherited via env). This test owns the process, so a plain set_var is safe.
44	    let home = tempfile::tempdir().unwrap();
45	    std::env::set_var("SPT_HOME", home.path());
46
47	    let spt_bin = PathBuf::from(env!("CARGO_BIN_EXE_spt"));
48	    let mock_bin = sibling_bin("mock-session");
49	    assert!(mock_bin.exists(), "mock-session must be built: {}", mock_bin.display());
50
51	    // Load the canonical mock manifest, then point its [session.self] template at
52	    // the freshly-built helper + spt binary (PATH isn't set up in the test rig).
53	    let manifest_path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../adapters/mock/manifest.toml");
54	    let mut manifest = Manifest::from_toml_str(
55	        &std::fs::read_to_string(manifest_path).expect("read mock manifest"),
56	    )
57	    .expect("mock manifest valid");
58	    manifest.session.self_.as_mut().unwrap().command = format!(
59	        "\"{}\" --id {{id}} --session-id {{session_id}} --spt \"{}\"",
60	        mock_bin.display(),
61	        spt_bin.display(),
62	    );
63
64	    let runtime = ManifestRuntime::new(manifest);
65	    let id = "mock-e2e";
66	    let session_id = "sid-e2e-1";
67
68	    // Outbound: spawn the session. Bounded (not detached) so the test waits for
69	    // the helper's bind + state-idle to complete before asserting.
70	    let out = runtime
71	        .run_bounded("self", &keys(&[("id", id), ("session_id", session_id)]), Duration::from_secs(30))
72	        .expect("spawn mock session");
73	    assert!(
74	        out.success(),
75	        "mock-session failed: code={:?}\nstdout={}\nstderr={}",
76	        out.status_code, out.stdout, out.stderr
77	    );
78
79	    // Inbound assertion 1 — `api bind` flipped the perch live (REQ-START-2 /
80	    // REQ-SEAM-POSTSPAWN): info.json exists with the reported session id.
81	    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
82	    let rec = spt_store::info::read_info(&perch_path).expect("perch info.json after bind");
83	    assert_eq!(rec.session_id, session_id);
84	    assert_eq!(rec.state, "live_agent");
85
86	    // Inbound assertion 2 — `api state idle` wrote the activity sentinel.
87	    assert!(perch_path.join(".idle").exists(), "state idle must write the .idle sentinel");
88
89	    // A peer spools a message to the now-live perch.
90	    spool::spool_message_at(&perch_path, "tester", "hello over the contract").unwrap();

//! M2a contract E2E (T9): the inbound + outbound halves of the harness contract
//! proven end-to-end through the **generic mock adapter** — no vendor harness.
//!
//! Flow: load the mock manifest → `ManifestRuntime` spawns the `mock-session`
//! helper → the helper fires `spt api bind` (skeleton→live) then
//! `spt api state idle` → a message is spooled to the perch → `spt api poll`
//! drains it. Asserts the round-trip.
//!
//! A second test exercises the **harness-hosted startup** topology proper —
//! `api seed` → `api listen` consumes the seed, binds, and relays a delivered
//! message — the REQ-START-2 path.
//
// [int->REQ-API-1]
// [int->REQ-API-2]

use std::path::PathBuf;
use std::process::Command;
use std::sync::Mutex;
use std::time::Duration;

/// Both tests mutate the process-global `SPT_HOME` (the in-test spt-store calls
/// and the spawned children read it). Serialize them so their homes don't race.
static E2E_LOCK: Mutex<()> = Mutex::new(());

use spt_runtime::{Manifest, ManifestRuntime, AgentRuntime};
use spt_store::perch::{self, ParentHint};
use spt_store::spool;

/// Absolute path to a sibling binary in the same target dir as the `spt` test bin.
fn sibling_bin(name: &str) -> PathBuf {
    let spt = PathBuf::from(env!("CARGO_BIN_EXE_spt"));
    spt.with_file_name(format!("{name}{}", std::env::consts::EXE_SUFFIX))
}

fn keys(pairs: &[(&str, &str)]) -> std::collections::BTreeMap<String, String> {
    pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
}

#[test]
fn mock_adapter_drives_the_full_contract() {
    let _serial = E2E_LOCK.lock().unwrap_or_else(|e| e.into_inner());
    // Isolated home shared by this process *and* the child `spt` invocations
    // (inherited via env). This test owns the process, so a plain set_var is safe.
    let home = tempfile::tempdir().unwrap();
    std::env::set_var("SPT_HOME", home.path());

    let spt_bin = PathBuf::from(env!("CARGO_BIN_EXE_spt"));
    let mock_bin = sibling_bin("mock-session");
    assert!(mock_bin.exists(), "mock-session must be built: {}", mock_bin.display());

    // Load the canonical mock manifest, then point its [session.self] template at
    // the freshly-built helper + spt binary (PATH isn't set up in the test rig).
    let manifest_path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../adapters/mock/manifest.toml");
    let mut manifest = Manifest::from_toml_str(
        &std::fs::read_to_string(manifest_path).expect("read mock manifest"),
    )
    .expect("mock manifest valid");
    manifest.session.self_.as_mut().unwrap().command = format!(
        "\"{}\" --id {{id}} --session-id {{session_id}} --spt \"{}\"",
        mock_bin.display(),
        spt_bin.display(),
    );

    let runtime = ManifestRuntime::new(manifest);
    let id = "mock-e2e";
    let session_id = "sid-e2e-1";

    // Outbound: spawn the session. Bounded (not detached) so the test waits for
    // the helper's bind + state-idle to complete before asserting.
    let out = runtime
        .run_bounded("self", &keys(&[("id", id), ("session_id", session_id)]), Duration::from_secs(30))
        .expect("spawn mock session");
    assert!(
        out.success(),
        "mock-session failed: code={:?}\nstdout={}\nstderr={}",
        out.status_code, out.stdout, out.stderr
    );

    // Inbound assertion 1 — `api bind` flipped the perch live (REQ-START-2 /
    // REQ-SEAM-POSTSPAWN): info.json exists with the reported session id.
    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
    let rec = spt_store::info::read_info(&perch_path).expect("perch info.json after bind");
    assert_eq!(rec.session_id, session_id);
    assert_eq!(rec.state, "live_agent");

    // Inbound assertion 2 — `api state idle` wrote the activity sentinel.
    assert!(perch_path.join(".idle").exists(), "state idle must write the .idle sentinel");

    // A peer spools a message to the now-live perch.
    spool::spool_message_at(&perch_path, "tester", "hello over the contract").unwrap();


T0 — Scaffold spt-live crate

Add crates/spt-live (deps spt-proto/store/msg/runtime + serde); add to workspace members; acyclic layering …→spt-runtime→spt-live; check green.

T1 — History subsystem

fetch_history over three [history] strategies (fetcher/locate_normalize/native, reuse T7 history-log); normalized records; timeout (5.3) never hangs. REQ-SEAM-HISTORY impl+unit.

T2 — spawn-psyche seam

Spawn Psyche from [session.psyche_init] as nested <id>-psyche perch via ManifestRuntime; compose spt-core-owned $psyche_prompt; set recursion_guard_env. REQ-SEAM-PSYCHE; REQ-HAZARD-SUBPROCESS-TIMEOUT.

T3 — echo-commune seam

Spawn [session.echo_commune] consuming T1 history; recursion-guard; write commune drop-file tagged project-primary/live-conservative (Source:). REQ-SEAM-HISTORY; REQ-HAZARD-DROP-FILE-SINGLE-WRITER.

T4 — drop-file ingest

Watch manifest commune/signoff dirs; ingest <id>-commune.md/<id>-signoff.md; spt-core single-writer (ingest→delete); direct-write precedence marker. REQ-API-3 (int); HAZARD 6.4 + 6.5.

T5 — pulse heartbeat

Interim loop in api listen runner (period = global config knob, per-endpoint override) driving drop-file polling + pending-echo fires + idle checks. Default no new req (mechanism under REQ-SEAM-PSYCHE).

T6 — signoff + boundary lifecycle

Graceful signoff runs echo BEFORE INIT_SIGNOFF (3.3); grace recheck binds still_gone before any write (1.1); stale-signoff sweep on start (3.2); boundary authors Self-resume commune. Ordering-assertion tests.

T7 — resume-session seam

fresh-with-preload (cleared session + $psyche-context) and continue-existing (resume $session_id); wire into spawn + boundary re-entry; wrong/missing key errors before spawn. REQ-SEAM-RESUME.

T8 — api listen lifecycle wiring

Extend M2a relay runner to host LiveAgent lifecycle: spawn Psyche (T2), run pulse (T5), ingest drops (T4), fire echoes (T3), graceful signoff (T6). Keep thin. REQ-SEAM-PSYCHE, REQ-API-2.

T9 — LiveAgent E2E

Extend adapters/mock/manifest.toml with psyche_init/resume/echo_commune/signoff + [history] native + commune/signoff dirs; tests/ integration proving full lifecycle harness-agnostic. REQ-SEAM-PSYCHE/HISTORY/API-3 int.

T10 — Activation sweep

Activate M2b reqs + hazards; REQ-EP-1/2 live-evidence; check green + CI; amend ROADMAP (M2b delivered) + CONTEXT notes; author M3 plan stub.


E2E drives the real binary, so graceful signoff needs a command. Check `gated` signature to add `api shutdown`.


Bash

grep -n "fn gated\|fn cmd_session_end\|Shutdown\|Suspend\|HistoryLog {" crates/spt/src/api/mod.rs crates/spt/src/api/auth.rs | head; echo "---"; sed -n '/fn gated/,/^}/p' crates/spt/src/api/mod.rs | head -30

Check gated signature + session-end


crates/spt/src/api/mod.rs:150:    HistoryLog {
crates/spt/src/api/mod.rs:242:        ApiCmd::HistoryLog { id, auth } => gated(&id, &auth, reporting::cmd_history_log),
crates/spt/src/api/mod.rs:273:fn gated<F: FnOnce(&str) -> i32>(target_id: &str, auth: &AuthFlags, f: F) -> i32 {
---
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
        }
    }
}


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	}
166
167	/// Auth proof flags shared by every mutating subcommand.
168	#[derive(Args)]
169	pub struct AuthFlags {
170	    /// Capability token proving association to the target perch.
171	    #[arg(long)]
172	    token: Option<String>,
173	    /// Session id proving association (matches the perch's info.json).
174	    #[arg(long)]
175	    session_id: Option<String>,
176	}
177
178	impl AuthFlags {

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

/// 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 {


Edit

    /// Print the adapter's declared capability (`hostable_types`) (T7).
    Capability,
}

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


The file C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs has been updated successfully. (file state is current in your context — no need to Read it back)

    /// Print the adapter's declared capability (`hostable_types`) (T7).
    Capability,
}

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

+    /// Graceful live-agent signoff (M2b): run the final echo-commune BEFORE

+    /// teardown (3.3), then soft-stop. The `spt shutdown` lifecycle path.

/// Auth proof flags shared by every mutating subcommand.


ApiCmd::HistoryLog { id, auth } => gated(&id, &auth, reporting::cmd_history_log),

        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::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::Presence { id, auth } => gated(&id, &auth, reporting::cmd_presence),

+        ApiCmd::Shutdown { id, auth } => {

+            let manifest = ctx.manifest.as_ref();

+            gated(&id, &auth, |id| live::cmd_shutdown(id, manifest))

gated(&id, &auth, |id| reporting::cmd_emit(id, &r#type))


Now fix live.rs: add `new_opt`, `cmd_shutdown`, drop unused helpers:


/// Whether the manifest declares a live-agent lifecycle worth hosting (a Psyche
/// to spawn). A manifest with no `psyche_init` is a plain relay endpoint.
pub fn is_live_capable(manifest: Option<&Manifest>) -> bool {
    manifest
        .map(|m| m.session.psyche_init.is_some())
        .unwrap_or(false)
}

/// The LiveAgent lifecycle host: the runtime + the watched dirs, built from a

/// The LiveAgent lifecycle host: the runtime + the watched dirs, built from a


The file C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\live.rs has been updated successfully. (file state is current in your context — no need to Read it back)

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

/// Whether the manifest declares a live-agent lifecycle worth hosting (a Psyche
/// to spawn). A manifest with no `psyche_init` is a plain relay endpoint.
pub fn is_live_capable(manifest: Option<&Manifest>) -> bool {
    manifest
        .map(|m| m.session.psyche_init.is_some())
        .unwrap_or(false)
}

/// 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` otherwise.
    pub fn new(manifest: &Manifest, id: &str) -> Option<LiveHost> {
        if manifest.session.psyche_init.is_none() {
            return None;
        }
        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, event: Option<&str>) -> Result<PsycheHandle, String> {
        spawn_psyche(
            &self.runtime,
            &self.id,
            &self.base_keys(None),
            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)
    }
}

/// Whether the echo-gate is armed for `id` (used by the relay loop to decide if
/// a pulse tick has echo work). Thin re-export over the perch sentinel.
pub fn echo_gate_armed(id: &str) -> bool {
    resolve_echo_gate_file(id, ParentHint::Infer).exists()
}

-/// Whether the manifest declares a live-agent lifecycle worth hosting (a Psyche

-/// to spawn). A manifest with no `psyche_init` is a plain relay endpoint.

-pub fn is_live_capable(manifest: Option<&Manifest>) -> bool {

-        .map(|m| m.session.psyche_init.is_some())


    /// Build a host for `id` from a live-capable manifest; `None` otherwise.
    pub fn new(manifest: &Manifest, id: &str) -> Option<LiveHost> {
        if manifest.session.psyche_init.is_none() {
            return None;
        }
        Some(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> {
        if manifest.session.psyche_init.is_none() {
            return None;
        }
        Some(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> {
        if manifest.session.psyche_init.is_none() {
            return None;
        }
        Some(LiveHost {

//! 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, resolve_echo_gate_file, 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` otherwise.
    pub fn new(manifest: &Manifest, id: &str) -> Option<LiveHost> {
        if manifest.session.psyche_init.is_none() {
            return None;
        }
        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, event: Option<&str>) -> Result<PsycheHandle, String> {
        spawn_psyche(
            &self.runtime,
            &self.id,
            &self.base_keys(None),
            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)
    }
}

/// Whether the echo-gate is armed for `id` (used by the relay loop to decide if
/// a pulse tick has echo work). Thin re-export over the perch sentinel.
pub fn echo_gate_armed(id: &str) -> bool {
    resolve_echo_gate_file(id, ParentHint::Infer).exists()
}

+    /// 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> {


Now replace the trailing `echo_gate_armed` with `cmd_shutdown`:


/// Whether the echo-gate is armed for `id` (used by the relay loop to decide if
/// a pulse tick has echo work). Thin re-export over the perch sentinel.
pub fn echo_gate_armed(id: &str) -> bool {
    resolve_echo_gate_file(id, ParentHint::Infer).exists()
}

/// `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())
}


/// `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())
}

//! 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, resolve_echo_gate_file, 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> {
        if manifest.session.psyche_init.is_none() {
            return None;
        }
        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, event: Option<&str>) -> Result<PsycheHandle, String> {
        spawn_psyche(
            &self.runtime,
            &self.id,
            &self.base_keys(None),
            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)
    }
}

/// Whether the echo-gate is armed for `id` (used by the relay loop to decide if
/// a pulse tick has echo work). Thin re-export over the perch sentinel.
pub fn echo_gate_armed(id: &str) -> bool {
    resolve_echo_gate_file(id, ParentHint::Infer).exists()
}

-/// Whether the echo-gate is armed for `id` (used by the relay loop to decide if

-/// a pulse tick has echo work). Thin re-export over the perch sentinel.

-pub fn echo_gate_armed(id: &str) -> bool {

-    resolve_echo_gate_file(id, ParentHint::Infer).exists()

+/// `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()) {

+                    "SHUTDOWN:{id} echo_ran={} signed_off={}",

+                    outcome.echo_ran, outcome.signed_off

+                eprintln!("SHUTDOWN_FAIL:{id}: {e}");

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

+/// 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);


T0 — Scaffold spt-live crate

T1 — History subsystem

T2 — spawn-psyche seam

T3 — echo-commune seam

T4 — drop-file ingest

T5 — pulse heartbeat

T6 — signoff + boundary lifecycle

T7 — resume-session seam

T8 — api listen lifecycle wiring

T9 — LiveAgent E2E

T10 — Activation sweep


Remove now-unused import + build.


use spt_store::perch::{self, resolve_echo_gate_file, ParentHint};


//! 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, resolve_echo_gate_file, 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> {
        if manifest.session.psyche_init.is_none() {
            return None;
        }
        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, event: Option<&str>) -> Result<PsycheHandle, String> {
        spawn_psyche(
            &self.runtime,
            &self.id,
            &self.base_keys(None),
            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())
}

-use spt_store::perch::{self, resolve_echo_gate_file, ParentHint};


Bash

cargo build -p spt 2>&1 | grep -E "error|warning" | head -30 || echo "clean build"

Build spt binary


(Bash completed with no output)


cargo clippy -p spt --all-targets -- -D warnings 2>&1 | tail -20

Clippy spt


    Checking spt v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt)
error: this block may be rewritten with the `?` operator
  --> crates\spt\src\api\live.rs:61:9
   |
61 | /         if manifest.session.psyche_init.is_none() {
62 | |             return None;
63 | |         }
   | |_________^ help: replace it with: `manifest.session.psyche_init.as_ref()?;`
   |
   = help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.93.0/index.html#question_mark
   = note: `-D clippy::question-mark` implied by `-D warnings`
   = help: to override `-D warnings` add `#[allow(clippy::question_mark)]`

error: could not compile `spt` (bin "spt") due to 1 previous error
warning: build failed, waiting for other jobs to finish...
error: could not compile `spt` (bin "spt" test) due to 1 previous error


Edit

    /// Build a host for `id` from a live-capable manifest; `None` otherwise.
    pub fn new(manifest: &Manifest, id: &str) -> Option<LiveHost> {
        if manifest.session.psyche_init.is_none() {
            return None;
        }
        Some(LiveHost {

    /// 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 {


    /// 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 {

//! 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> {
        if manifest.session.psyche_init.is_none() {
            return None;
        }
        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, event: Option<&str>) -> Result<PsycheHandle, String> {
        spawn_psyche(
            &self.runtime,
            &self.id,
            &self.base_keys(None),
            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())
}

-        if manifest.session.psyche_init.is_none() {

+        manifest.session.psyche_init.as_ref()?;


Bash

cargo clippy -p spt --all-targets -- -D warnings 2>&1 | tail -12

Re-clippy spt


    Checking spt v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.58s


Clippy clean. Run spt tests + check the dispatcher tests still pass.


cargo test -p spt 2>&1 | grep -E "test result|error|FAILED" | grep -v "0 passed; 0 failed" | head

Test spt crate


test cli::tests::invalid_usage_errors ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.70s


cargo test -p spt 2>&1 | grep "test result"; echo "=== TRACE ==="; traceable-reqs check > /dev/null 2>&1; echo "check_exit=$?"

Full spt test counts + check


test result: ok. 40 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.26s
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.36s
=== TRACE ===
check_exit=0


40+2 green, check 0. Commit T8.


git add -A && git commit -q -F - <<'EOF'
feat(spt): M2b T8 — api listen lifecycle wiring + graceful shutdown

api/live.rs hosts the LiveAgent lifecycle over the spt-live seams (interim
no-daemon; M3 re-hosts into the daemon). LiveHost — built from a live-capable
manifest (declares psyche_init) — wraps the runtime + watched dirs and exposes:
sweep_on_start (3.2), spawn_psyche (T2), pulse_tick (ingest drops + fire one
gated echo — T3/T4/T5), and graceful_signoff (echo-before-teardown 3.3, soft
6.2 cleanup).

cmd_listen now takes the manifest: on start it sweeps the stale signoff, spawns
the Psyche companion, then runs a pulse tick each relay cycle. New `api shutdown
<id>` (auth-gated) drives the graceful path so the lifecycle is observable
end-to-end (the T9 hook). A non-live manifest stays a plain relay/soft-stop.
Surface kept thin — the signal-driven teardown + durable pulse period are M3's.

42 spt tests green (cmd_listen signature threaded through dispatch). Evidence
tagged on REQ-SEAM-PSYCHE (listen wiring) + REQ-API-2; int lands at T9.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
git log --oneline -1

Commit T8


warning: in the working copy of 'Cargo.lock', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt/Cargo.toml', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt/src/api/mod.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt/src/api/startup.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt/src/api/live.rs', LF will be replaced by CRLF the next time Git touches it
b91168c feat(spt): M2b T8 — api listen lifecycle wiring + graceful shutdown


Updated task #9 status


9 shipped. T9 — LiveAgent E2E. Read the mock manifest + mock-session helper.


cat adapters/mock/manifest.toml; echo "=== HELPER ==="; ls adapters/mock/src/; echo "---"; sed -n '1,60p' adapters/mock/src/main.rs 2>/dev/null || ls adapters/mock/

Read mock manifest + helper


# Generic mock harness adapter — the canonical, harness-AGNOSTIC exercise of the
# spt-core contract. ZERO vendor harness conventions: no vendor binary name, no
# vendor config dir, no vendor hook names. The command templates spawn the
# trivial `mock-session` helper; the [hooks.*] table binds generic event names to
# the inbound `api` surface.
#
# This is the only adapter-shaped artifact in spt-core (PRD R-DOCS-2). A real
# harness adapter (e.g. the downstream spt-plugin) lives in its own repo.

[adapter]
name = "mock"
kind = "harness"
version = "1.0.0"
min_spt_core_version = "1.0.0"
hostable_types = ["LiveAgent", "ReadyAgent", "Worker"]

# Inbound hooks: generic event names → the `api` command each fires. SessionStart
# seeds; Idle reports idle (and arms the echo-gate); SessionEnd soft-tears-down.
[hooks.SessionStart]
fires = "api seed --pid {parent_pid} --session-id {session_id} --adapter {adapter_name}"
reads = ["session_id", "parent_pid"]
can_inject = true

[hooks.Idle]
fires = "api state idle"
reads = []
can_inject = false              # no inject channel → sentinel/relay fallback

[hooks.SessionEnd]
fires = "api session-end"
reads = []
can_inject = false

# Watched dirs: commune/signoff are file-drops (filenames spt-core-fixed), not
# commands.
[session]
commune_dir = ".mock"
signoff_dir = ".mock"

# Outbound session role: the opaque template ManifestRuntime spawns. `mock-session`
# stands in for a real harness binary; spt-core just runs it with keys filled.
[session.self]
command = "mock-session --id {id} --session-id {session_id}"
detach = true
keys = ["id", "session_id"]

# Env aliases the adapter injects for in-session use ($SPT = spt api, etc.).
[env.SPT_AGENT_ID]
direction = "inject"
value = "{id}"

# Inject methods per activity state (PTY is spt-core/M3, never offered here).
[inject]
activity = ["hook"]
idle = ["hook", "relay"]

[identity]
session_id_source = "post_spawn"
parent_ancestor_name = "mock-session"
=== HELPER ===
main.rs
---
//! `mock-session` — the generic mock adapter's "agent session". A trivial,
//! cross-platform stand-in for a real harness session (no `claude`, no `.claude`,
//! no CC hooks). When `ManifestRuntime` spawns it from the mock manifest's
//! `[session.self]` template, it does what a real session's boot hook would do:
//! fire `spt api bind` to flip its perch live, then `spt api state idle`.
//!
//! It shells out to the `spt` binary exactly as a real adapter would — resolved
//! from `--spt <path>`, else `$SPT_BIN`, else `spt` on PATH. This is what proves
//! the inbound contract is harness-agnostic: a dumb helper drives it with only
//! `spt api` calls.

use std::process::Command;

fn arg(args: &[String], flag: &str) -> Option<String> {
    args.iter().position(|a| a == flag).and_then(|i| args.get(i + 1).cloned())
}

fn main() {
    let args: Vec<String> = std::env::args().skip(1).collect();
    let id = arg(&args, "--id").unwrap_or_else(|| {
        eprintln!("mock-session: --id required");
        std::process::exit(2);
    });
    let session_id = arg(&args, "--session-id").unwrap_or_else(|| "mock-session-1".to_string());
    let spt = arg(&args, "--spt")
        .or_else(|| std::env::var("SPT_BIN").ok())
        .unwrap_or_else(|| "spt".to_string());

    // 1. Post-spawn bind: flip the perch skeleton→live under this session id.
    let bind = Command::new(&spt)
        .args(["api", "--adapter", "mock", "bind", &id, "--set-session-id", &session_id])
        .status();
    if !matches!(bind, Ok(s) if s.success()) {
        eprintln!("mock-session: api bind failed: {bind:?}");
        std::process::exit(1);
    }

    // 2. Report idle (activity sentinel + echo-gate), authenticating by session.
    let state = Command::new(&spt)
        .args(["api", "--adapter", "mock", "state", "idle", &id, "--session-id", &session_id])
        .status();
    if !matches!(state, Ok(s) if s.success()) {
        eprintln!("mock-session: api state idle failed: {state:?}");
        std::process::exit(1);
    }

    println!("MOCK_SESSION_READY:{id}");
}


Read the rest of contract_e2e — the seed→listen flow I'll mirror for the live E2E.


96	        .output()
97	        .expect("run api poll");
98	    assert!(poll.status.success(), "api poll exit: {:?}", poll.status);
99	    let stdout = String::from_utf8_lossy(&poll.stdout);
100	    assert!(
101	        stdout.contains("hello over the contract"),
102	        "api poll must deliver the spooled message, got: {stdout:?}"
103	    );
104
105	    // Auth negative — a poll with no proof is refused (REQ-HAZARD-LOCAL-API-AUTH
106	    // exercised through the real binary).
107	    let unauth = Command::new(&spt_bin)
108	        .args(["api", "--adapter", "mock", "poll", id])
109	        .env("SPT_HOME", home.path())
110	        .env_remove("OWL_SESSION_ID")
111	        .output()
112	        .expect("run unauth poll");
113	    assert!(!unauth.status.success(), "poll without proof must be refused");
114	}
115
116	/// Harness-hosted startup proper: `api seed` then `api listen` consumes the seed
117	/// (matched by parent pid, validated against pid-recycle), binds the perch
118	/// skeleton→live, and relays a delivered message.
119	#[test]
120	fn seed_then_listen_binds_and_relays() {
121	    // [int->REQ-START-2]
122	    // [int->REQ-API-2]
123	    let _serial = E2E_LOCK.lock().unwrap_or_else(|e| e.into_inner());
124	    let home = tempfile::tempdir().unwrap();
125	    let spt_bin = PathBuf::from(env!("CARGO_BIN_EXE_spt"));
126	    let id = "listen-e2e";
127	    let session_id = "sid-listen-1";
128	    // The anchor must be a live process; the test runner itself qualifies.
129	    let anchor = std::process::id().to_string();
130
131	    // Pre-spool a message (creates the perch spool dir) so listen drains it on bind.
132	    std::env::set_var("SPT_HOME", home.path());
133	    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
134	    spool::spool_message_at(&perch_path, "tester", "delivered via listen").unwrap();
135
136	    // Seed the startup record keyed by the anchor pid.
137	    let seed = Command::new(&spt_bin)
138	        .args(["api", "--adapter", "mock", "seed", "--pid", &anchor, "--session-id", session_id])
139	        .env("SPT_HOME", home.path())
140	        .status()
141	        .expect("run api seed");
142	    assert!(seed.success(), "api seed exit: {seed:?}");
143
144	    // Listen consumes the seed (explicit --parent-pid for a deterministic match),
145	    // binds the perch, drains the backlog, and exits (--once).
146	    let listen = Command::new(&spt_bin)
147	        .args(["api", "--adapter", "mock", "listen", id, "--parent-pid", &anchor, "--once"])
148	        .env("SPT_HOME", home.path())
149	        .output()
150	        .expect("run api listen");
151	    assert!(listen.status.success(), "api listen exit: {:?}", listen.status);
152	    let stdout = String::from_utf8_lossy(&listen.stdout);
153	    assert!(
154	        stdout.contains("delivered via listen"),
155	        "listen must relay the spooled message, got: {stdout:?}"
156	    );
157
158	    // The perch was bound live with the seed's session id.
159	    let rec = spt_store::info::read_info(&perch_path).expect("perch info.json after listen");
160	    assert_eq!(rec.session_id, session_id);
161	    assert_eq!(rec.state, "live_agent");
162	}
163

        .output()
        .expect("run api poll");
    assert!(poll.status.success(), "api poll exit: {:?}", poll.status);
    let stdout = String::from_utf8_lossy(&poll.stdout);
    assert!(
        stdout.contains("hello over the contract"),
        "api poll must deliver the spooled message, got: {stdout:?}"
    );

    // Auth negative — a poll with no proof is refused (REQ-HAZARD-LOCAL-API-AUTH
    // exercised through the real binary).
    let unauth = Command::new(&spt_bin)
        .args(["api", "--adapter", "mock", "poll", id])
        .env("SPT_HOME", home.path())
        .env_remove("OWL_SESSION_ID")
        .output()
        .expect("run unauth poll");
    assert!(!unauth.status.success(), "poll without proof must be refused");
}

/// Harness-hosted startup proper: `api seed` then `api listen` consumes the seed
/// (matched by parent pid, validated against pid-recycle), binds the perch
/// skeleton→live, and relays a delivered message.
#[test]
fn seed_then_listen_binds_and_relays() {
    // [int->REQ-START-2]
    // [int->REQ-API-2]
    let _serial = E2E_LOCK.lock().unwrap_or_else(|e| e.into_inner());
    let home = tempfile::tempdir().unwrap();
    let spt_bin = PathBuf::from(env!("CARGO_BIN_EXE_spt"));
    let id = "listen-e2e";
    let session_id = "sid-listen-1";
    // The anchor must be a live process; the test runner itself qualifies.
    let anchor = std::process::id().to_string();

    // Pre-spool a message (creates the perch spool dir) so listen drains it on bind.
    std::env::set_var("SPT_HOME", home.path());
    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
    spool::spool_message_at(&perch_path, "tester", "delivered via listen").unwrap();

    // Seed the startup record keyed by the anchor pid.
    let seed = Command::new(&spt_bin)
        .args(["api", "--adapter", "mock", "seed", "--pid", &anchor, "--session-id", session_id])
        .env("SPT_HOME", home.path())
        .status()
        .expect("run api seed");
    assert!(seed.success(), "api seed exit: {seed:?}");

    // Listen consumes the seed (explicit --parent-pid for a deterministic match),
    // binds the perch, drains the backlog, and exits (--once).
    let listen = Command::new(&spt_bin)
        .args(["api", "--adapter", "mock", "listen", id, "--parent-pid", &anchor, "--once"])
        .env("SPT_HOME", home.path())
        .output()
        .expect("run api listen");
    assert!(listen.status.success(), "api listen exit: {:?}", listen.status);
    let stdout = String::from_utf8_lossy(&listen.stdout);
    assert!(
        stdout.contains("delivered via listen"),
        "listen must relay the spooled message, got: {stdout:?}"
    );

    // The perch was bound live with the seed's session id.
    let rec = spt_store::info::read_info(&perch_path).expect("perch info.json after listen");
    assert_eq!(rec.session_id, session_id);
    assert_eq!(rec.state, "live_agent");
}


Full pattern clear. Extend mock-session with `--mode psyche|echo`, then the manifest, then the E2E. Update the helper:


Edit

fn main() {
    let args: Vec<String> = std::env::args().skip(1).collect();
    let id = arg(&args, "--id").unwrap_or_else(|| {
        eprintln!("mock-session: --id required");
        std::process::exit(2);
    });

fn main() {
    let args: Vec<String> = std::env::args().skip(1).collect();

    // M2b lifecycle stand-in modes. `psyche` is the Psyche companion (spt-core
    // binds its nested perch; the child just needs to launch). `echo` is the
    // cheap-model echo-commune: read history on stdin, emit a brief on stdout.
    match arg(&args, "--mode").as_deref() {
        Some("psyche") => {
            let id = arg(&args, "--id").unwrap_or_else(|| "mock-psyche".to_string());
            println!("MOCK_PSYCHE_UP:{id}");
            return;
        }
        Some("echo") => {
            use std::io::Read;
            let mut history = String::new();
            let _ = std::io::stdin().read_to_string(&mut history);
            // A deterministic brief; a real echo summarizes the history it read.
            println!("mock echo brief ({} history bytes)", history.len());
            return;
        }
        _ => {} // default: the agent-session stand-in below.
    }

    let id = arg(&args, "--id").unwrap_or_else(|| {
        eprintln!("mock-session: --id required");
        std::process::exit(2);
    });


<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>

Error: File has not been read yet. Read it first before writing to it.


12	use std::process::Command;
13
14	fn arg(args: &[String], flag: &str) -> Option<String> {
15	    args.iter().position(|a| a == flag).and_then(|i| args.get(i + 1).cloned())
16	}
17
18	fn main() {
19	    let args: Vec<String> = std::env::args().skip(1).collect();
20	    let id = arg(&args, "--id").unwrap_or_else(|| {
21	        eprintln!("mock-session: --id required");
22	        std::process::exit(2);
23	    });
24	    let session_id = arg(&args, "--session-id").unwrap_or_else(|| "mock-session-1".to_string());
25	    let spt = arg(&args, "--spt")

use std::process::Command;

fn arg(args: &[String], flag: &str) -> Option<String> {
    args.iter().position(|a| a == flag).and_then(|i| args.get(i + 1).cloned())
}

fn main() {
    let args: Vec<String> = std::env::args().skip(1).collect();
    let id = arg(&args, "--id").unwrap_or_else(|| {
        eprintln!("mock-session: --id required");
        std::process::exit(2);
    });
    let session_id = arg(&args, "--session-id").unwrap_or_else(|| "mock-session-1".to_string());
    let spt = arg(&args, "--spt")


fn main() {
    let args: Vec<String> = std::env::args().skip(1).collect();
    let id = arg(&args, "--id").unwrap_or_else(|| {
        eprintln!("mock-session: --id required");
        std::process::exit(2);
    });

fn main() {
    let args: Vec<String> = std::env::args().skip(1).collect();

    // M2b lifecycle stand-in modes. `psyche` is the Psyche companion (spt-core
    // binds its nested perch; the child just needs to launch). `echo` is the
    // cheap-model echo-commune: read history on stdin, emit a brief on stdout.
    match arg(&args, "--mode").as_deref() {
        Some("psyche") => {
            let id = arg(&args, "--id").unwrap_or_else(|| "mock-psyche".to_string());
            println!("MOCK_PSYCHE_UP:{id}");
            return;
        }
        Some("echo") => {
            use std::io::Read;
            let mut history = String::new();
            let _ = std::io::stdin().read_to_string(&mut history);
            // A deterministic brief; a real echo summarizes the history it read.
            println!("mock echo brief ({} history bytes)", history.len());
            return;
        }
        _ => {} // default: the agent-session stand-in below.
    }

    let id = arg(&args, "--id").unwrap_or_else(|| {
        eprintln!("mock-session: --id required");
        std::process::exit(2);
    });