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

/// Shared spawn+bind core for the Psyche roles (`psyche_init` — fresh; and
/// `psyche_resume` — re-entry, T7), DETACHED. Injects the three spt-core-owned keys
/// (`id` / `psyche_dir` / `psyche_prompt`), spawns the role, and binds the nested
/// `<id>-psyche` perch. The single detach path for non-hosted psyche callers — it
/// spawns OWNED then drops the child handle (a dropped `Child` detaches, never kills).
pub(crate) fn spawn_and_bind(
    runtime: &dyn AgentRuntime,
    role: &str,
    parent_id: &str,
    keys: &BTreeMap<String, String>,
    event: Option<&str>,
    now: &str,
) -> Result<PsycheHandle, PsycheError> {
    spawn_and_bind_owned(runtime, role, parent_id, keys, event, now).map(|(h, _child)| h)
}

/// Owned variant of [`spawn_and_bind`]: RETAINS the spawned child handle alongside
/// the bound [`PsycheHandle`] so a HOSTED caller can reap the Psyche BY HANDLE
/// (`child.kill()` + `wait()`) on un-host — recycle-proof, never a bare-pid kill
/// that could hit a recycled sibling on a shared box (REQ-HAZARD-UNHOST-PSYCHE-REAP).
// [impl->REQ-HAZARD-UNHOST-PSYCHE-REAP]
pub(crate) fn spawn_and_bind_owned(
    runtime: &dyn AgentRuntime,
    role: &str,
    parent_id: &str,
    keys: &BTreeMap<String, String>,
    event: Option<&str>,
    now: &str,
) -> Result<(PsycheHandle, std::process::Child), PsycheError> {
    let psyche_id = format!("{parent_id}-psyche");
    let perch_path = resolve_perch_path(&psyche_id, ParentHint::Explicit(parent_id));
    // The nested perch dir must exist before spawn — the role's `cwd` is it.
    std::fs::create_dir_all(&perch_path).map_err(PsycheError::Bind)?;

    let mut k = keys.clone();
    k.insert("id".to_string(), psyche_id.clone());
    k.insert(
        "psyche_dir".to_string(),
        perch_path.to_string_lossy().to_string(),
    );
    k.insert(
        "psyche_prompt".to_string(),
        compose_psyche_prompt(now, event),
    );

    let owned = runtime.spawn_session_owned(role, &k)?;

    // Bind the nested perch: spt-core writes the Psyche's identity/status record.
    // Interim per-pid liveness (M3's daemon makes `status` authoritative).
    let session_id = keys.get("session_id").map(String::as_str).unwrap_or("");
    let mut rec = InfoJson::new(
        &psyche_id,
        now,
        owned.pid,
        session_id,
        &EndpointType::Psyche.as_tag(),
    );
    rec.parent_pid = None;
    write_info(&perch_path, &rec).map_err(PsycheError::Bind)?;

    Ok((
        PsycheHandle {
            id: psyche_id,
            pid: owned.pid,
            perch_path,
        },
        owned.child,
    ))
}

//! spawn-psyche seam (M2b T2) — bring a Self's Psyche companion to life.
//!
//! The Psyche is spawned from the `[session.psyche_init]` template (fresh) or
//! `[session.psyche_resume]` (re-entry — T7) as a **nested `<id>-psyche` perch**
//! (M0 layout: `owlery/<parent>/nested/<id>-psyche/`). spt-core owns exactly one
//! piece of the command's content — the `{psyche_prompt}` substitution: the
//! revival essentials (timestamp + the incoming event envelope) per CONTEXT
//! §spawn-psyche seam. Everything else — model selection, harness preamble —
//! lives in the adapter's opaque template; spt-core selects no model.
//!
//! The recursion guard (`recursion_guard_env`) is set on the child by
//! `ManifestRuntime` so the Psyche's own harness hooks bail instead of spawning
//! a Psyche-of-the-Psyche (KNOWN-HAZARDS — echo-commune recursion).
//
// Clean-room over the M2a spawn seam (the sister's psyche wrapper owns a
// `claude -p` subprocess + `.claude/` paths). Interim no-daemon model: the
// Psyche is a real detached subprocess and spt-core writes its nested perch
// record; M3's daemon re-hosts it as a loop with daemon-authoritative liveness.
// [impl->REQ-SEAM-PSYCHE]

use spt_proto::endpoint::EndpointType;
use spt_runtime::{AgentRuntime, RuntimeError};
use spt_store::info::{write_info, InfoJson};
use spt_store::perch::{resolve_perch_path, ParentHint};
use std::collections::BTreeMap;
use std::path::PathBuf;

/// The role spawned for a fresh Psyche.
pub const PSYCHE_INIT_ROLE: &str = "psyche_init";

/// A spawned Psyche companion: its nested id, OS pid, and perch directory.
#[derive(Debug, Clone)]
pub struct PsycheHandle {
    pub id: String,
    pub pid: u32,
    pub perch_path: PathBuf,
}

/// A spawn-psyche failure. Carries an operator message; never panics.
#[derive(Debug)]
pub enum PsycheError {
    /// Template fill / spawn / role-lookup failure from the runtime.
    Runtime(RuntimeError),
    /// Creating the nested perch dir or writing its `info.json` failed.
    Bind(std::io::Error),
}

impl std::fmt::Display for PsycheError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            PsycheError::Runtime(e) => write!(f, "psyche spawn failed: {e}"),
            PsycheError::Bind(e) => write!(f, "binding the psyche perch failed: {e}"),
        }
    }
}

impl std::error::Error for PsycheError {}

impl From<RuntimeError> for PsycheError {
    fn from(e: RuntimeError) -> Self {
        PsycheError::Runtime(e)
    }
}

/// Compose the **spt-core-owned** `{psyche_prompt}`: the revival essentials fed
/// to every Psyche regardless of adapter — a timestamp and the incoming event
/// envelope (CONTEXT §spawn-psyche seam). Consistent across adapters so the
/// live/project authoring split stays uniform (`docs/CONTEXT-MEMORY.md`); the
/// adapter prepends its own static preamble around this.
pub fn compose_psyche_prompt(now: &str, event: Option<&str>) -> String {
    let mut s = String::with_capacity(64);
    s.push_str("PSYCHE REVIVAL\n");
    s.push_str("time: ");
    s.push_str(now);
    s.push('\n');
    match event {
        Some(ev) if !ev.trim().is_empty() => {
            s.push_str("incoming event:\n");
            s.push_str(ev.trim_end());
            s.push('\n');
        }
        _ => s.push_str("incoming event: (none)\n"),
    }
    s
}

/// Spawn a fresh Psyche for `parent_id` and bind its nested perch.
///
/// `keys` supplies the adapter-template substitution values the caller owns
/// (e.g. `session_name`); spt-core overrides the three it owns for a Psyche:
/// `id` (the nested `<id>-psyche`), `psyche_dir` (the created perch dir, the
/// role's `cwd`), and `psyche_prompt` (composed here). The role's
/// `recursion_guard_env` is applied to the child by the runtime.
pub fn spawn_psyche(
    runtime: &dyn AgentRuntime,
    parent_id: &str,
    keys: &BTreeMap<String, String>,
    event: Option<&str>,
    now: &str,
) -> Result<PsycheHandle, PsycheError> {
    spawn_and_bind(runtime, PSYCHE_INIT_ROLE, parent_id, keys, event, now)
}

/// Shared spawn+bind core for the Psyche roles (`psyche_init` — fresh; and
/// `psyche_resume` — re-entry, T7). Injects the three spt-core-owned keys
/// (`id` / `psyche_dir` / `psyche_prompt`), spawns the role, and binds the
/// nested `<id>-psyche` perch.
pub(crate) fn spawn_and_bind(
    runtime: &dyn AgentRuntime,
    role: &str,
    parent_id: &str,
    keys: &BTreeMap<String, String>,
    event: Option<&str>,
    now: &str,
) -> Result<PsycheHandle, PsycheError> {
    let psyche_id = format!("{parent_id}-psyche");
    let perch_path = resolve_perch_path(&psyche_id, ParentHint::Explicit(parent_id));
    // The nested perch dir must exist before spawn — the role's `cwd` is it.
    std::fs::create_dir_all(&perch_path).map_err(PsycheError::Bind)?;

    let mut k = keys.clone();
    k.insert("id".to_string(), psyche_id.clone());
    k.insert(
        "psyche_dir".to_string(),
        perch_path.to_string_lossy().to_string(),
    );
    k.insert(
        "psyche_prompt".to_string(),
        compose_psyche_prompt(now, event),
    );

    let spawned = runtime.spawn_session(role, &k)?;

    // Bind the nested perch: spt-core writes the Psyche's identity/status record.
    // Interim per-pid liveness (M3's daemon makes `status` authoritative).
    let session_id = keys.get("session_id").map(String::as_str).unwrap_or("");
    let mut rec = InfoJson::new(
        &psyche_id,
        now,
        spawned.pid,
        session_id,
        &EndpointType::Psyche.as_tag(),
    );
    rec.parent_pid = None;
    write_info(&perch_path, &rec).map_err(PsycheError::Bind)?;

    Ok(PsycheHandle {
        id: psyche_id,
        pid: spawned.pid,
        perch_path,
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_support::with_home;
    use spt_runtime::{Manifest, ManifestRuntime};
    use std::time::{Duration, Instant};

    #[test] // [unit->REQ-SEAM-PSYCHE] the prompt carries the timestamp + event envelope.
    fn prompt_includes_time_and_event() {
        let p = compose_psyche_prompt("2026-06-01T00:00:00Z", Some("MSG from alice: hi"));
        assert!(p.contains("time: 2026-06-01T00:00:00Z"));
        assert!(p.contains("MSG from alice: hi"));
    }

    #[test] // [unit->REQ-SEAM-PSYCHE] no event ⇒ an explicit "(none)" envelope.
    fn prompt_without_event() {
        let p = compose_psyche_prompt("t0", None);
        assert!(p.contains("incoming event: (none)"));
    }

    // A psyche_init template that records the recursion-guard env value into a
    // marker file in its cwd (the nested perch dir) — proves both that the perch
    // is the cwd and that the guard env reached the child.
    #[cfg(windows)]
    const GUARD_CMD: &str = "cmd /C echo %SPT_GUARD% > guard.txt";
    #[cfg(unix)]
    const GUARD_CMD: &str = "sh -c \"echo $SPT_GUARD > guard.txt\"";

    fn psyche_manifest() -> Manifest {
        let toml = format!(
            "[adapter]\nname=\"mock\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
             [session.psyche_init]\ncommand='{GUARD_CMD}'\ncwd=\"{{psyche_dir}}\"\n\
             recursion_guard_env=\"SPT_GUARD\"\nkeys=[\"psyche_dir\",\"psyche_prompt\"]\n"
        );
        Manifest::from_toml_str(&toml).unwrap()
    }

    #[test] // [unit->REQ-SEAM-PSYCHE] spawning binds a nested psyche perch + sets the guard env.
    fn spawn_binds_nested_perch_and_sets_guard() {
        with_home(|_| {
            let rt = ManifestRuntime::new(psyche_manifest());
            let h = spawn_psyche(&rt, "doyle", &BTreeMap::new(), Some("pulse"), "t0")
                .expect("spawn psyche");

            // Nested layout: owlery/doyle/nested/doyle-psyche/.
            assert_eq!(h.id, "doyle-psyche");
            assert_eq!(
                h.perch_path,
                resolve_perch_path("doyle-psyche", ParentHint::Explicit("doyle"))
            );

            // info.json records the psyche endpoint type + the spawned pid.
            let rec = spt_store::info::read_info(&h.perch_path).expect("info.json");
            assert_eq!(rec.state, "psyche");
            assert_eq!(rec.id, "doyle-psyche");

            // The detached child writes the guard value into its cwd; poll for it.
            let marker = h.perch_path.join("guard.txt");
            let start = Instant::now();
            let contents = loop {
                if let Ok(s) = std::fs::read_to_string(&marker) {
                    if !s.trim().is_empty() {
                        break s;
                    }
                }
                assert!(
                    start.elapsed() < Duration::from_secs(5),
                    "psyche child never wrote its guard marker"
                );
                std::thread::sleep(Duration::from_millis(20));
            };
            assert!(
                contents.contains('1'),
                "guard env not set on child: {contents:?}"
            );
        });
    }
}

-/// `psyche_resume` — re-entry, T7). Injects the three spt-core-owned keys

-/// (`id` / `psyche_dir` / `psyche_prompt`), spawns the role, and binds the

+/// `psyche_resume` — re-entry, T7), DETACHED. Injects the three spt-core-owned keys

+/// (`id` / `psyche_dir` / `psyche_prompt`), spawns the role, and binds the nested

+/// `<id>-psyche` perch. The single detach path for non-hosted psyche callers — it

+/// spawns OWNED then drops the child handle (a dropped `Child` detaches, never kills).

+    spawn_and_bind_owned(runtime, role, parent_id, keys, event, now).map(|(h, _child)| h)

+/// Owned variant of [`spawn_and_bind`]: RETAINS the spawned child handle alongside

+/// the bound [`PsycheHandle`] so a HOSTED caller can reap the Psyche BY HANDLE

+/// (`child.kill()` + `wait()`) on un-host — recycle-proof, never a bare-pid kill

+/// that could hit a recycled sibling on a shared box (REQ-HAZARD-UNHOST-PSYCHE-REAP).

+) -> Result<(PsycheHandle, std::process::Child), PsycheError> {

-    let spawned = runtime.spawn_session(role, &k)?;

+    let owned = runtime.spawn_session_owned(role, &k)?;


Add the public `spawn_psyche_owned` entry + export it:


Edit

pub fn spawn_psyche(
    runtime: &dyn AgentRuntime,
    parent_id: &str,
    keys: &BTreeMap<String, String>,
    event: Option<&str>,
    now: &str,
) -> Result<PsycheHandle, PsycheError> {
    spawn_and_bind(runtime, PSYCHE_INIT_ROLE, parent_id, keys, event, now)
}

pub fn spawn_psyche(
    runtime: &dyn AgentRuntime,
    parent_id: &str,
    keys: &BTreeMap<String, String>,
    event: Option<&str>,
    now: &str,
) -> Result<PsycheHandle, PsycheError> {
    spawn_and_bind(runtime, PSYCHE_INIT_ROLE, parent_id, keys, event, now)
}

/// Owned [`spawn_psyche`] — RETAINS the child handle so the daemon-hosted lifecycle
/// can reap the Psyche BY HANDLE on un-host (recycle-proof, REQ-HAZARD-UNHOST-PSYCHE-REAP).
/// Returns the bound [`PsycheHandle`] plus the owned [`std::process::Child`].
// [impl->REQ-HAZARD-UNHOST-PSYCHE-REAP]
pub fn spawn_psyche_owned(
    runtime: &dyn AgentRuntime,
    parent_id: &str,
    keys: &BTreeMap<String, String>,
    event: Option<&str>,
    now: &str,
) -> Result<(PsycheHandle, std::process::Child), PsycheError> {
    spawn_and_bind_owned(runtime, PSYCHE_INIT_ROLE, parent_id, keys, event, now)
}


pub fn spawn_psyche(
    runtime: &dyn AgentRuntime,
    parent_id: &str,
    keys: &BTreeMap<String, String>,
    event: Option<&str>,
    now: &str,
) -> Result<PsycheHandle, PsycheError> {
    spawn_and_bind(runtime, PSYCHE_INIT_ROLE, parent_id, keys, event, now)
}

/// Owned [`spawn_psyche`] — RETAINS the child handle so the daemon-hosted lifecycle
/// can reap the Psyche BY HANDLE on un-host (recycle-proof, REQ-HAZARD-UNHOST-PSYCHE-REAP).
/// Returns the bound [`PsycheHandle`] plus the owned [`std::process::Child`].
// [impl->REQ-HAZARD-UNHOST-PSYCHE-REAP]
pub fn spawn_psyche_owned(
    runtime: &dyn AgentRuntime,
    parent_id: &str,
    keys: &BTreeMap<String, String>,
    event: Option<&str>,
    now: &str,
) -> Result<(PsycheHandle, std::process::Child), PsycheError> {
    spawn_and_bind_owned(runtime, PSYCHE_INIT_ROLE, parent_id, keys, event, now)
}

//! spawn-psyche seam (M2b T2) — bring a Self's Psyche companion to life.
//!
//! The Psyche is spawned from the `[session.psyche_init]` template (fresh) or
//! `[session.psyche_resume]` (re-entry — T7) as a **nested `<id>-psyche` perch**
//! (M0 layout: `owlery/<parent>/nested/<id>-psyche/`). spt-core owns exactly one
//! piece of the command's content — the `{psyche_prompt}` substitution: the
//! revival essentials (timestamp + the incoming event envelope) per CONTEXT
//! §spawn-psyche seam. Everything else — model selection, harness preamble —
//! lives in the adapter's opaque template; spt-core selects no model.
//!
//! The recursion guard (`recursion_guard_env`) is set on the child by
//! `ManifestRuntime` so the Psyche's own harness hooks bail instead of spawning
//! a Psyche-of-the-Psyche (KNOWN-HAZARDS — echo-commune recursion).
//
// Clean-room over the M2a spawn seam (the sister's psyche wrapper owns a
// `claude -p` subprocess + `.claude/` paths). Interim no-daemon model: the
// Psyche is a real detached subprocess and spt-core writes its nested perch
// record; M3's daemon re-hosts it as a loop with daemon-authoritative liveness.
// [impl->REQ-SEAM-PSYCHE]

use spt_proto::endpoint::EndpointType;
use spt_runtime::{AgentRuntime, RuntimeError};
use spt_store::info::{write_info, InfoJson};
use spt_store::perch::{resolve_perch_path, ParentHint};
use std::collections::BTreeMap;
use std::path::PathBuf;

/// The role spawned for a fresh Psyche.
pub const PSYCHE_INIT_ROLE: &str = "psyche_init";

/// A spawned Psyche companion: its nested id, OS pid, and perch directory.
#[derive(Debug, Clone)]
pub struct PsycheHandle {
    pub id: String,
    pub pid: u32,
    pub perch_path: PathBuf,
}

/// A spawn-psyche failure. Carries an operator message; never panics.
#[derive(Debug)]
pub enum PsycheError {
    /// Template fill / spawn / role-lookup failure from the runtime.
    Runtime(RuntimeError),
    /// Creating the nested perch dir or writing its `info.json` failed.
    Bind(std::io::Error),
}

impl std::fmt::Display for PsycheError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            PsycheError::Runtime(e) => write!(f, "psyche spawn failed: {e}"),
            PsycheError::Bind(e) => write!(f, "binding the psyche perch failed: {e}"),
        }
    }
}

impl std::error::Error for PsycheError {}

impl From<RuntimeError> for PsycheError {
    fn from(e: RuntimeError) -> Self {
        PsycheError::Runtime(e)
    }
}

/// Compose the **spt-core-owned** `{psyche_prompt}`: the revival essentials fed
/// to every Psyche regardless of adapter — a timestamp and the incoming event
/// envelope (CONTEXT §spawn-psyche seam). Consistent across adapters so the
/// live/project authoring split stays uniform (`docs/CONTEXT-MEMORY.md`); the
/// adapter prepends its own static preamble around this.
pub fn compose_psyche_prompt(now: &str, event: Option<&str>) -> String {
    let mut s = String::with_capacity(64);
    s.push_str("PSYCHE REVIVAL\n");
    s.push_str("time: ");
    s.push_str(now);
    s.push('\n');
    match event {
        Some(ev) if !ev.trim().is_empty() => {
            s.push_str("incoming event:\n");
            s.push_str(ev.trim_end());
            s.push('\n');
        }
        _ => s.push_str("incoming event: (none)\n"),
    }
    s
}

/// Spawn a fresh Psyche for `parent_id` and bind its nested perch.
///
/// `keys` supplies the adapter-template substitution values the caller owns
/// (e.g. `session_name`); spt-core overrides the three it owns for a Psyche:
/// `id` (the nested `<id>-psyche`), `psyche_dir` (the created perch dir, the
/// role's `cwd`), and `psyche_prompt` (composed here). The role's
/// `recursion_guard_env` is applied to the child by the runtime.
pub fn spawn_psyche(
    runtime: &dyn AgentRuntime,
    parent_id: &str,
    keys: &BTreeMap<String, String>,
    event: Option<&str>,
    now: &str,
) -> Result<PsycheHandle, PsycheError> {
    spawn_and_bind(runtime, PSYCHE_INIT_ROLE, parent_id, keys, event, now)
}

/// Shared spawn+bind core for the Psyche roles (`psyche_init` — fresh; and
/// `psyche_resume` — re-entry, T7), DETACHED. Injects the three spt-core-owned keys
/// (`id` / `psyche_dir` / `psyche_prompt`), spawns the role, and binds the nested
/// `<id>-psyche` perch. The single detach path for non-hosted psyche callers — it
/// spawns OWNED then drops the child handle (a dropped `Child` detaches, never kills).
pub(crate) fn spawn_and_bind(
    runtime: &dyn AgentRuntime,
    role: &str,
    parent_id: &str,
    keys: &BTreeMap<String, String>,
    event: Option<&str>,
    now: &str,
) -> Result<PsycheHandle, PsycheError> {
    spawn_and_bind_owned(runtime, role, parent_id, keys, event, now).map(|(h, _child)| h)
}

/// Owned variant of [`spawn_and_bind`]: RETAINS the spawned child handle alongside
/// the bound [`PsycheHandle`] so a HOSTED caller can reap the Psyche BY HANDLE
/// (`child.kill()` + `wait()`) on un-host — recycle-proof, never a bare-pid kill
/// that could hit a recycled sibling on a shared box (REQ-HAZARD-UNHOST-PSYCHE-REAP).
// [impl->REQ-HAZARD-UNHOST-PSYCHE-REAP]
pub(crate) fn spawn_and_bind_owned(
    runtime: &dyn AgentRuntime,
    role: &str,
    parent_id: &str,
    keys: &BTreeMap<String, String>,
    event: Option<&str>,
    now: &str,
) -> Result<(PsycheHandle, std::process::Child), PsycheError> {
    let psyche_id = format!("{parent_id}-psyche");
    let perch_path = resolve_perch_path(&psyche_id, ParentHint::Explicit(parent_id));
    // The nested perch dir must exist before spawn — the role's `cwd` is it.
    std::fs::create_dir_all(&perch_path).map_err(PsycheError::Bind)?;

    let mut k = keys.clone();
    k.insert("id".to_string(), psyche_id.clone());
    k.insert(
        "psyche_dir".to_string(),
        perch_path.to_string_lossy().to_string(),
    );
    k.insert(
        "psyche_prompt".to_string(),
        compose_psyche_prompt(now, event),
    );

    let owned = runtime.spawn_session_owned(role, &k)?;

    // Bind the nested perch: spt-core writes the Psyche's identity/status record.
    // Interim per-pid liveness (M3's daemon makes `status` authoritative).
    let session_id = keys.get("session_id").map(String::as_str).unwrap_or("");
    let mut rec = InfoJson::new(
        &psyche_id,
        now,
        owned.pid,
        session_id,
        &EndpointType::Psyche.as_tag(),
    );
    rec.parent_pid = None;
    write_info(&perch_path, &rec).map_err(PsycheError::Bind)?;

    Ok((
        PsycheHandle {
            id: psyche_id,
            pid: owned.pid,
            perch_path,
        },
        owned.child,
    ))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_support::with_home;
    use spt_runtime::{Manifest, ManifestRuntime};
    use std::time::{Duration, Instant};

    #[test] // [unit->REQ-SEAM-PSYCHE] the prompt carries the timestamp + event envelope.
    fn prompt_includes_time_and_event() {
        let p = compose_psyche_prompt("2026-06-01T00:00:00Z", Some("MSG from alice: hi"));
        assert!(p.contains("time: 2026-06-01T00:00:00Z"));
        assert!(p.contains("MSG from alice: hi"));
    }

    #[test] // [unit->REQ-SEAM-PSYCHE] no event ⇒ an explicit "(none)" envelope.
    fn prompt_without_event() {
        let p = compose_psyche_prompt("t0", None);
        assert!(p.contains("incoming event: (none)"));
    }

    // A psyche_init template that records the recursion-guard env value into a
    // marker file in its cwd (the nested perch dir) — proves both that the perch
    // is the cwd and that the guard env reached the child.
    #[cfg(windows)]
    const GUARD_CMD: &str = "cmd /C echo %SPT_GUARD% > guard.txt";
    #[cfg(unix)]
    const GUARD_CMD: &str = "sh -c \"echo $SPT_GUARD > guard.txt\"";

    fn psyche_manifest() -> Manifest {
        let toml = format!(
            "[adapter]\nname=\"mock\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
             [session.psyche_init]\ncommand='{GUARD_CMD}'\ncwd=\"{{psyche_dir}}\"\n\
             recursion_guard_env=\"SPT_GUARD\"\nkeys=[\"psyche_dir\",\"psyche_prompt\"]\n"
        );
        Manifest::from_toml_str(&toml).unwrap()
    }

    #[test] // [unit->REQ-SEAM-PSYCHE] spawning binds a nested psyche perch + sets the guard env.
    fn spawn_binds_nested_perch_and_sets_guard() {
        with_home(|_| {
            let rt = ManifestRuntime::new(psyche_manifest());
            let h = spawn_psyche(&rt, "doyle", &BTreeMap::new(), Some("pulse"), "t0")
                .expect("spawn psyche");

            // Nested layout: owlery/doyle/nested/doyle-psyche/.
            assert_eq!(h.id, "doyle-psyche");
            assert_eq!(
                h.perch_path,
                resolve_perch_path("doyle-psyche", ParentHint::Explicit("doyle"))
            );

            // info.json records the psyche endpoint type + the spawned pid.
            let rec = spt_store::info::read_info(&h.perch_path).expect("info.json");
            assert_eq!(rec.state, "psyche");
            assert_eq!(rec.id, "doyle-psyche");

            // The detached child writes the guard value into its cwd; poll for it.
            let marker = h.perch_path.join("guard.txt");
            let start = Instant::now();
            let contents = loop {
                if let Ok(s) = std::fs::read_to_string(&marker) {
                    if !s.trim().is_empty() {
                        break s;
                    }
                }
                assert!(
                    start.elapsed() < Duration::from_secs(5),
                    "psyche child never wrote its guard marker"
                );
                std::thread::sleep(Duration::from_millis(20));
            };
            assert!(
                contents.contains('1'),
                "guard env not set on child: {contents:?}"
            );
        });
    }
}

+/// Owned [`spawn_psyche`] — RETAINS the child handle so the daemon-hosted lifecycle

+/// can reap the Psyche BY HANDLE on un-host (recycle-proof, REQ-HAZARD-UNHOST-PSYCHE-REAP).

+/// Returns the bound [`PsycheHandle`] plus the owned [`std::process::Child`].

+    spawn_and_bind_owned(runtime, PSYCHE_INIT_ROLE, parent_id, keys, event, now)


pub use psyche::{compose_psyche_prompt, spawn_psyche, PsycheError, PsycheHandle};

pub use psyche::{
    compose_psyche_prompt, spawn_psyche, spawn_psyche_owned, PsycheError, PsycheHandle,
};


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


53	};
54	pub use outbound::{parse_psyche_intents, PsycheIntent};
55	pub use psyche::{compose_psyche_prompt, spawn_psyche, PsycheError, PsycheHandle};
56	pub use pulse::{tick, PulseConfig, TickOutcome};

};
pub use outbound::{parse_psyche_intents, PsycheIntent};
pub use psyche::{compose_psyche_prompt, spawn_psyche, PsycheError, PsycheHandle};
pub use pulse::{tick, PulseConfig, TickOutcome};


pub use psyche::{compose_psyche_prompt, spawn_psyche, PsycheError, PsycheHandle};

pub use psyche::{
    compose_psyche_prompt, spawn_psyche, spawn_psyche_owned, PsycheError, PsycheHandle,
};


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

pub use psyche::{
    compose_psyche_prompt, spawn_psyche, spawn_psyche_owned, PsycheError, PsycheHandle,
};

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

pub mod context;
pub mod digest;
pub mod echo;
pub mod history;
pub mod inject;
pub mod ingest;
pub mod outbound;
pub mod psyche;
pub mod pulse;
pub mod reconcile;
pub mod resume;
pub mod signoff;
pub mod turn;

#[cfg(test)]
mod test_support;

pub use context::{write_context, write_context_joined, WriteOutcome, WriteSource};
pub use digest::{extract_digest, resolve_source_template, DigestExtractError};
pub use echo::{run_echo_commune, stamp_provenance, EchoError, EchoResult};
pub use history::{fetch_history, HistoryError, HistoryRecord};
pub use inject::{
    record_context_injection, KIND_ECHO_MIRROR, KIND_OWL_MESSAGE, KIND_PSYCHE_DOWNLOAD,
};
pub use ingest::{
    ingest_drops, route_slices, route_two_slice, DropKind, Ingested, Tier, TierWrite,
};
pub use outbound::{parse_psyche_intents, PsycheIntent};
pub use psyche::{compose_psyche_prompt, spawn_psyche, PsycheError, PsycheHandle};
pub use pulse::{tick, PulseConfig, TickOutcome};
pub use reconcile::{reconcile_file, ReconcileOutcome, ReconcileRequest};
pub use resume::{download_psyche_context, resume_psyche, ResumeError, ResumeMode};
pub use signoff::{graceful_signoff, sweep_stale_signoff, write_resume_commune, SignoffOutcome};
pub use turn::{run_psyche_turn, TurnError, PSYCHE_TURN_ROLE};

-pub use psyche::{compose_psyche_prompt, spawn_psyche, PsycheError, PsycheHandle};

+    compose_psyche_prompt, spawn_psyche, spawn_psyche_owned, PsycheError, PsycheHandle,


Now lifecycle.rs (spt-daemon) — read `spawn_psyche` to add the owned variant:


160	    }
161
162	    /// Sweep a stale signoff sentinel on start (3.2). No-op without a signoff dir.
163	    pub fn sweep_on_start(&self) {
164	        if let Some(dir) = &self.signoff_dir {
165	            let _ = sweep_stale_signoff(dir, &self.id);
166	        }
167	    }
168
169	    /// Spawn the Psyche companion (T2). `event` is the incoming envelope when the
170	    /// spawn is event-driven; `None` at cold start.
171	    ///
172	    /// The Psyche is **daemon-hosted**: its liveness is daemon-authoritative, not
173	    /// the ephemeral summarizer pid `spawn_psyche` records (2.5). So immediately
174	    /// after binding, mark the perch `online` — from here liveness reads the
175	    /// daemon `status`, and a dead summarizer subprocess no longer flips the perch
176	    /// offline. [`mark_offline`](Self::mark_offline) is the teardown counterpart.
177	    // [impl->REQ-HAZARD-DAEMON-HOSTED-LIVENESS]
178	    pub fn spawn_psyche(
179	        &self,
180	        session_id: Option<&str>,
181	        event: Option<&str>,
182	    ) -> Result<PsycheHandle, String> {
183	        let handle = spawn_psyche(
184	            &self.runtime,
185	            &self.id,
186	            &self.base_keys(session_id),
187	            event,
188	            &now_stamp(),
189	        )
190	        .map_err(|e| e.to_string())?;
191	        spt_store::info::set_status(&handle.perch_path, spt_store::liveness::STATUS_ONLINE)
192	            .map_err(|e| format!("mark psyche online: {e}"))?;
193	        Ok(handle)
194	    }
195
196	    /// Mark a daemon-hosted perch `offline` (teardown counterpart of the
197	    /// `online` stamp in [`spawn_psyche`](Self::spawn_psyche)). From this point
198	    /// the single liveness resolver reports the perch dead regardless of any pid
199	    /// still recorded in its `info.json` (2.5). Best-effort: a missing record
200	    /// (perch already gone) is not an error.
201	    // [impl->REQ-HAZARD-DAEMON-HOSTED-LIVENESS]
202	    pub fn mark_offline(perch_path: &std::path::Path) {
203	        let _ = spt_store::info::set_status(perch_path, spt_store::liveness::STATUS_OFFLINE);
204	    }
205
206	    /// Stamp this endpoint's `last_active_ms` recency marker (REQ-UPD-4) — the
207	    /// signal the consent gate resolves "most-recently-active live session"
208	    /// against ([`crate::consent::most_recently_active`]). Best-effort: a hosted
209	    /// perch that has not been bound yet (no `info.json`) is silently skipped, so
210	    /// a pre-bind tick never errors. Stamps the Self perch for this brain's id.
211	    // [impl->REQ-UPD-4]
212	    pub fn touch_active(&self) {
213	        let perch_path = perch::resolve_perch_path(&self.id, ParentHint::Infer);
214	        let _ = spt_store::info::set_last_active(&perch_path, now_ms());
215	    }
216
217	    /// One pulse tick (T5): refresh the activity stamp (REQ-UPD-4), ingest drops
218	    /// from the watched dirs (T4), then if the echo-gate is armed fire exactly one
219	    /// echo-commune (T3).
220	    pub fn pulse_tick(&self, session_id: Option<&str>) -> Result<TickReport, String> {
221	        let now = now_ms();
222	        // Recency follows the resting model (D9-2 — the D8 gap closed): only
223	        // an ACTIVE (or pre-D9-2 recordless) instance refreshes
224	        // `last_active_ms` — a resting seat ticking its pulse must never win
225	        // a "most-recently-active" resolution (REQ-UPD-4's consent target,
226	        // the notif first-fire). A dormant seat instead checks the opt-in
227	        // auto-suspend threshold (counted from its dormancy-onset anchor);
228	        // a suspended seat just keeps ingesting.
229	        // [impl->REQ-INST-3]
230	        let perch_path = perch::resolve_perch_path(&self.id, ParentHint::Infer);
231	        match crate::resting::read_rest(&perch_path).map(|r| r.state) {
232	            None | Some(crate::resting::RestState::Active) => self.touch_active(),
233	            Some(crate::resting::RestState::Dormant) => {
234	                let _ = self.rest_event(
235	                    session_id,
236	                    crate::resting::RestEvent::AutoSuspendTick { now_ms: now },
237	                );
238	            }
239	            Some(crate::resting::RestState::Suspended) => {}
240	        }
241	        let mut ingested = Vec::new();
242	        for dir in self.drop_dirs() {
243	            let mut got = ingest_drops(
244	                &dir,
245	                &self.id,
246	                &self.project_id,
247	                now,
248	                self.cfg.protection_window_ms,
249	            )
250	            .map_err(|e| e.to_string())?;
251	            ingested.append(&mut got);
252	        }
253	        let echo_fired = if spt_live::pulse::take_echo_gate(&self.id, ParentHint::Infer) {
254	            self.fire_echo(session_id)?;
255	            true
256	        } else {
257	            false
258	        };
259	        Ok(TickReport {
260	            ingested,
261	            echo_fired,
262	        })
263	    }
264
265	    /// Run the scheduled pulse loop until `stop` is set, calling `on_tick` after
266	    /// each tick (so a caller — or a test — observes cadence and can decide to
267	    /// stop). The period comes from the durable config, never a constant.
268	    ///
269	    /// **Restoration D5-2 (ADR-0018 Q4/V4): the cadence is a disk-anchored
270	    /// absolute-deadline grid, not a phase-relative flat sleep.** The old loop
271	    /// `pulse_tick`-then-`sleep(pulse_period)` silently re-phased the grid to the
272	    /// restart instant on every restart. Now the loop rehydrates a
273	    /// [`DeadlineAnchor`](crate::deadline::DeadlineAnchor) keyed by the session
274	    /// (the daemon pulse uses `"pulse"`; the live-agent adapter's per-agent

    }

    /// Sweep a stale signoff sentinel on start (3.2). No-op without a signoff dir.
    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 when the
    /// spawn is event-driven; `None` at cold start.
    ///
    /// The Psyche is **daemon-hosted**: its liveness is daemon-authoritative, not
    /// the ephemeral summarizer pid `spawn_psyche` records (2.5). So immediately
    /// after binding, mark the perch `online` — from here liveness reads the
    /// daemon `status`, and a dead summarizer subprocess no longer flips the perch
    /// offline. [`mark_offline`](Self::mark_offline) is the teardown counterpart.
    // [impl->REQ-HAZARD-DAEMON-HOSTED-LIVENESS]
    pub fn spawn_psyche(
        &self,
        session_id: Option<&str>,
        event: Option<&str>,
    ) -> Result<PsycheHandle, String> {
        let handle = spawn_psyche(
            &self.runtime,
            &self.id,
            &self.base_keys(session_id),
            event,
            &now_stamp(),
        )
        .map_err(|e| e.to_string())?;
        spt_store::info::set_status(&handle.perch_path, spt_store::liveness::STATUS_ONLINE)
            .map_err(|e| format!("mark psyche online: {e}"))?;
        Ok(handle)
    }

    /// Mark a daemon-hosted perch `offline` (teardown counterpart of the
    /// `online` stamp in [`spawn_psyche`](Self::spawn_psyche)). From this point
    /// the single liveness resolver reports the perch dead regardless of any pid
    /// still recorded in its `info.json` (2.5). Best-effort: a missing record
    /// (perch already gone) is not an error.
    // [impl->REQ-HAZARD-DAEMON-HOSTED-LIVENESS]
    pub fn mark_offline(perch_path: &std::path::Path) {
        let _ = spt_store::info::set_status(perch_path, spt_store::liveness::STATUS_OFFLINE);
    }

    /// Stamp this endpoint's `last_active_ms` recency marker (REQ-UPD-4) — the
    /// signal the consent gate resolves "most-recently-active live session"
    /// against ([`crate::consent::most_recently_active`]). Best-effort: a hosted
    /// perch that has not been bound yet (no `info.json`) is silently skipped, so
    /// a pre-bind tick never errors. Stamps the Self perch for this brain's id.
    // [impl->REQ-UPD-4]
    pub fn touch_active(&self) {
        let perch_path = perch::resolve_perch_path(&self.id, ParentHint::Infer);
        let _ = spt_store::info::set_last_active(&perch_path, now_ms());
    }

    /// One pulse tick (T5): refresh the activity stamp (REQ-UPD-4), ingest drops
    /// from the watched dirs (T4), then if the echo-gate is armed fire exactly one
    /// echo-commune (T3).
    pub fn pulse_tick(&self, session_id: Option<&str>) -> Result<TickReport, String> {
        let now = now_ms();
        // Recency follows the resting model (D9-2 — the D8 gap closed): only
        // an ACTIVE (or pre-D9-2 recordless) instance refreshes
        // `last_active_ms` — a resting seat ticking its pulse must never win
        // a "most-recently-active" resolution (REQ-UPD-4's consent target,
        // the notif first-fire). A dormant seat instead checks the opt-in
        // auto-suspend threshold (counted from its dormancy-onset anchor);
        // a suspended seat just keeps ingesting.
        // [impl->REQ-INST-3]
        let perch_path = perch::resolve_perch_path(&self.id, ParentHint::Infer);
        match crate::resting::read_rest(&perch_path).map(|r| r.state) {
            None | Some(crate::resting::RestState::Active) => self.touch_active(),
            Some(crate::resting::RestState::Dormant) => {
                let _ = self.rest_event(
                    session_id,
                    crate::resting::RestEvent::AutoSuspendTick { now_ms: now },
                );
            }
            Some(crate::resting::RestState::Suspended) => {}
        }
        let mut ingested = Vec::new();
        for dir in self.drop_dirs() {
            let mut got = ingest_drops(
                &dir,
                &self.id,
                &self.project_id,
                now,
                self.cfg.protection_window_ms,
            )
            .map_err(|e| e.to_string())?;
            ingested.append(&mut got);
        }
        let echo_fired = if spt_live::pulse::take_echo_gate(&self.id, ParentHint::Infer) {
            self.fire_echo(session_id)?;
            true
        } else {
            false
        };
        Ok(TickReport {
            ingested,
            echo_fired,
        })
    }

    /// Run the scheduled pulse loop until `stop` is set, calling `on_tick` after
    /// each tick (so a caller — or a test — observes cadence and can decide to
    /// stop). The period comes from the durable config, never a constant.
    ///
    /// **Restoration D5-2 (ADR-0018 Q4/V4): the cadence is a disk-anchored
    /// absolute-deadline grid, not a phase-relative flat sleep.** The old loop
    /// `pulse_tick`-then-`sleep(pulse_period)` silently re-phased the grid to the
    /// restart instant on every restart. Now the loop rehydrates a
    /// [`DeadlineAnchor`](crate::deadline::DeadlineAnchor) keyed by the session
    /// (the daemon pulse uses `"pulse"`; the live-agent adapter's per-agent


Add `spawn_psyche_owned` import + a `BrainLifecycle::spawn_psyche_owned` method. Read the import block:


20	use std::sync::atomic::{AtomicBool, Ordering};
21	use std::time::{Duration, SystemTime, UNIX_EPOCH};
22
23	use spt_live::{
24	    fetch_history, graceful_signoff, ingest_drops, run_echo_commune, spawn_psyche,
25	    sweep_stale_signoff, HistoryRecord, Ingested, PsycheHandle, SignoffOutcome,
26	};
27	use spt_runtime::{Manifest, ManifestRuntime};

use std::sync::atomic::{AtomicBool, Ordering};
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, SignoffOutcome,
};
use spt_runtime::{Manifest, ManifestRuntime};


use spt_live::{
    fetch_history, graceful_signoff, ingest_drops, run_echo_commune, spawn_psyche,
    sweep_stale_signoff, HistoryRecord, Ingested, PsycheHandle, SignoffOutcome,
};

use spt_live::{
    fetch_history, graceful_signoff, ingest_drops, run_echo_commune, spawn_psyche,
    spawn_psyche_owned, sweep_stale_signoff, HistoryRecord, Ingested, PsycheHandle,
    SignoffOutcome,
};


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

use spt_live::{
    fetch_history, graceful_signoff, ingest_drops, run_echo_commune, spawn_psyche,
    sweep_stale_signoff, HistoryRecord, Ingested, PsycheHandle, SignoffOutcome,
};

use spt_live::{
    fetch_history, graceful_signoff, ingest_drops, run_echo_commune, spawn_psyche,
    spawn_psyche_owned, sweep_stale_signoff, HistoryRecord, Ingested, PsycheHandle,
    SignoffOutcome,
};

-    sweep_stale_signoff, HistoryRecord, Ingested, PsycheHandle, SignoffOutcome,

+    spawn_psyche_owned, sweep_stale_signoff, HistoryRecord, Ingested, PsycheHandle,


        spt_store::info::set_status(&handle.perch_path, spt_store::liveness::STATUS_ONLINE)
            .map_err(|e| format!("mark psyche online: {e}"))?;
        Ok(handle)
    }

    /// Mark a daemon-hosted perch `offline` (teardown counterpart of the

        spt_store::info::set_status(&handle.perch_path, spt_store::liveness::STATUS_ONLINE)
            .map_err(|e| format!("mark psyche online: {e}"))?;
        Ok(handle)
    }

    /// Owned [`spawn_psyche`](Self::spawn_psyche) — RETAINS the Psyche's child
    /// handle so the daemon-hosted lifecycle can reap it BY HANDLE on un-host
    /// (`child.kill()` + `wait()`), recycle-proof vs a bare-pid kill that could hit a
    /// recycled sibling on a shared box (REQ-HAZARD-UNHOST-PSYCHE-REAP). Marks the
    /// perch `online` like `spawn_psyche`; returns the handle PLUS the owned child.
    // [impl->REQ-HAZARD-UNHOST-PSYCHE-REAP]
    pub fn spawn_psyche_owned(
        &self,
        session_id: Option<&str>,
        event: Option<&str>,
    ) -> Result<(PsycheHandle, std::process::Child), String> {
        let (handle, child) = spawn_psyche_owned(
            &self.runtime,
            &self.id,
            &self.base_keys(session_id),
            event,
            &now_stamp(),
        )
        .map_err(|e| e.to_string())?;
        spt_store::info::set_status(&handle.perch_path, spt_store::liveness::STATUS_ONLINE)
            .map_err(|e| format!("mark psyche online: {e}"))?;
        Ok((handle, child))
    }

    /// Mark a daemon-hosted perch `offline` (teardown counterpart of the


        spt_store::info::set_status(&handle.perch_path, spt_store::liveness::STATUS_ONLINE)
            .map_err(|e| format!("mark psyche online: {e}"))?;
        Ok(handle)
    }

    /// Mark a daemon-hosted perch `offline` (teardown counterpart of the

        spt_store::info::set_status(&handle.perch_path, spt_store::liveness::STATUS_ONLINE)
            .map_err(|e| format!("mark psyche online: {e}"))?;
        Ok(handle)
    }

    /// Owned [`spawn_psyche`](Self::spawn_psyche) — RETAINS the Psyche's child
    /// handle so the daemon-hosted lifecycle can reap it BY HANDLE on un-host
    /// (`child.kill()` + `wait()`), recycle-proof vs a bare-pid kill that could hit a
    /// recycled sibling on a shared box (REQ-HAZARD-UNHOST-PSYCHE-REAP). Marks the
    /// perch `online` like `spawn_psyche`; returns the handle PLUS the owned child.
    // [impl->REQ-HAZARD-UNHOST-PSYCHE-REAP]
    pub fn spawn_psyche_owned(
        &self,
        session_id: Option<&str>,
        event: Option<&str>,
    ) -> Result<(PsycheHandle, std::process::Child), String> {
        let (handle, child) = spawn_psyche_owned(
            &self.runtime,
            &self.id,
            &self.base_keys(session_id),
            event,
            &now_stamp(),
        )
        .map_err(|e| e.to_string())?;
        spt_store::info::set_status(&handle.perch_path, spt_store::liveness::STATUS_ONLINE)
            .map_err(|e| format!("mark psyche online: {e}"))?;
        Ok((handle, child))
    }

    /// Mark a daemon-hosted perch `offline` (teardown counterpart of the

+    /// Owned [`spawn_psyche`](Self::spawn_psyche) — RETAINS the Psyche's child

+    /// handle so the daemon-hosted lifecycle can reap it BY HANDLE on un-host

+    /// (`child.kill()` + `wait()`), recycle-proof vs a bare-pid kill that could hit a

+    /// recycled sibling on a shared box (REQ-HAZARD-UNHOST-PSYCHE-REAP). Marks the

+    /// perch `online` like `spawn_psyche`; returns the handle PLUS the owned child.

+    // [impl->REQ-HAZARD-UNHOST-PSYCHE-REAP]

+    ) -> Result<(PsycheHandle, std::process::Child), String> {

+        let (handle, child) = spawn_psyche_owned(

+            &self.base_keys(session_id),

+        spt_store::info::set_status(&handle.perch_path, spt_store::liveness::STATUS_ONLINE)

+            .map_err(|e| format!("mark psyche online: {e}"))?;

/// `online` stamp in [`spawn_psyche`](Self::spawn_psyche)). From this point

/// the single liveness resolver reports the perch dead regardless of any pid


Now wire it into livehost.rs — `HostedLife` owns the child, `host_one` stores it, `stop_host` reaps by handle. Struct first:


struct HostedLife {
    stop: Arc<AtomicBool>,
    thread: JoinHandle<()>,
    /// When [`host_one`] spawned this endpoint's Psyche — the residency-grace
    /// anchor (REQ-HAZARD-LIVEHOST-NONRESIDENT).
    hosted_at: Instant,
}

struct HostedLife {
    stop: Arc<AtomicBool>,
    thread: JoinHandle<()>,
    /// When [`host_one`] spawned this endpoint's Psyche — the residency-grace
    /// anchor (REQ-HAZARD-LIVEHOST-NONRESIDENT).
    hosted_at: Instant,
    /// The OWNED `{id}-psyche` child handle — reaped BY HANDLE on un-host
    /// (`stop_host`) so the detached Psyche process never orphans past its endpoint
    /// (REQ-HAZARD-UNHOST-PSYCHE-REAP). Owning the handle (vs a bare pid) is
    /// recycle-proof: a teardown can never hit a recycled sibling pid on a shared box.
    psyche_child: Option<std::process::Child>,
}


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

struct HostedLife {
    stop: Arc<AtomicBool>,
    thread: JoinHandle<()>,
    /// When [`host_one`] spawned this endpoint's Psyche — the residency-grace
    /// anchor (REQ-HAZARD-LIVEHOST-NONRESIDENT).
    hosted_at: Instant,
}

struct HostedLife {
    stop: Arc<AtomicBool>,
    thread: JoinHandle<()>,
    /// When [`host_one`] spawned this endpoint's Psyche — the residency-grace
    /// anchor (REQ-HAZARD-LIVEHOST-NONRESIDENT).
    hosted_at: Instant,
    /// The OWNED `{id}-psyche` child handle — reaped BY HANDLE on un-host
    /// (`stop_host`) so the detached Psyche process never orphans past its endpoint
    /// (REQ-HAZARD-UNHOST-PSYCHE-REAP). Owning the handle (vs a bare pid) is
    /// recycle-proof: a teardown can never hit a recycled sibling pid on a shared box.
    psyche_child: Option<std::process::Child>,
}

+    /// The OWNED `{id}-psyche` child handle — reaped BY HANDLE on un-host

+    /// (`stop_host`) so the detached Psyche process never orphans past its endpoint

+    /// (REQ-HAZARD-UNHOST-PSYCHE-REAP). Owning the handle (vs a bare pid) is

+    /// recycle-proof: a teardown can never hit a recycled sibling pid on a shared box.

+    psyche_child: Option<std::process::Child>,

/// After a residency failure, hold off re-hosting the doomed endpoint for this


`stop_host` — reap the child by handle:


    fn stop_host(&self, id: &str) {
        let handle = {
            let mut map = self.inner.lock().unwrap_or_else(|p| p.into_inner());
            map.remove(id)
        };
        if let Some(h) = handle {
            h.stop.store(true, Ordering::SeqCst);
            let _ = h.thread.join();
        }
    }

    fn stop_host(&self, id: &str) {
        let handle = {
            let mut map = self.inner.lock().unwrap_or_else(|p| p.into_inner());
            map.remove(id)
        };
        if let Some(mut h) = handle {
            h.stop.store(true, Ordering::SeqCst);
            let _ = h.thread.join();
            // Reap the detached `{id}-psyche` HARNESS PROCESS by handle
            // (REQ-HAZARD-UNHOST-PSYCHE-REAP): joining the driver thread above stops
            // the in-brain pulse loop, but the Psyche is a SEPARATE long-lived process
            // — without this it would orphan (alive until the next daemon-stop/Breap)
            // on every endpoint-stop / signoff / B2+B5 offline. kill() + wait() BY
            // HANDLE is recycle-proof (a bare-pid kill could hit a recycled sibling on
            // this shared box) and reaps cleanly (no zombie). The handle is the
            // owned-liveness probe — never is_process_alive(pid) here (a killed owned
            // process reads ALIVE via OpenProcess until wait()).
            if let Some(mut child) = h.psyche_child.take() {
                let _ = child.kill();
                let _ = child.wait();
            }
        }
    }


    fn stop_host(&self, id: &str) {
        let handle = {
            let mut map = self.inner.lock().unwrap_or_else(|p| p.into_inner());
            map.remove(id)
        };
        if let Some(h) = handle {
            h.stop.store(true, Ordering::SeqCst);
            let _ = h.thread.join();
        }
    }

    fn stop_host(&self, id: &str) {
        let handle = {
            let mut map = self.inner.lock().unwrap_or_else(|p| p.into_inner());
            map.remove(id)
        };
        if let Some(mut h) = handle {
            h.stop.store(true, Ordering::SeqCst);
            let _ = h.thread.join();
            // Reap the detached `{id}-psyche` HARNESS PROCESS by handle
            // (REQ-HAZARD-UNHOST-PSYCHE-REAP): joining the driver thread above stops
            // the in-brain pulse loop, but the Psyche is a SEPARATE long-lived process
            // — without this it would orphan (alive until the next daemon-stop/Breap)
            // on every endpoint-stop / signoff / B2+B5 offline. kill() + wait() BY
            // HANDLE is recycle-proof (a bare-pid kill could hit a recycled sibling on
            // this shared box) and reaps cleanly (no zombie). The handle is the
            // owned-liveness probe — never is_process_alive(pid) here (a killed owned
            // process reads ALIVE via OpenProcess until wait()).
            if let Some(mut child) = h.psyche_child.take() {
                let _ = child.kill();
                let _ = child.wait();
            }
        }
    }

+            // Reap the detached `{id}-psyche` HARNESS PROCESS by handle

+            // (REQ-HAZARD-UNHOST-PSYCHE-REAP): joining the driver thread above stops

+            // the in-brain pulse loop, but the Psyche is a SEPARATE long-lived process

+            // — without this it would orphan (alive until the next daemon-stop/Breap)

+            // on every endpoint-stop / signoff / B2+B5 offline. kill() + wait() BY

+            // HANDLE is recycle-proof (a bare-pid kill could hit a recycled sibling on

+            // this shared box) and reaps cleanly (no zombie). The handle is the

+            // owned-liveness probe — never is_process_alive(pid) here (a killed owned

+            // process reads ALIVE via OpenProcess until wait()).

+            if let Some(mut child) = h.psyche_child.take() {


`host_one` — use the owned spawn, store the child:


    let perch = perch::resolve_perch_path(id, ParentHint::Infer);
    match lifecycle.spawn_psyche(Some(&session_id), None) {
        Ok(h) => {
            eprintln!("LIVEHOST_PSYCHE:{id} pid={}", h.pid);
            // NOTE: the prior psyche-host-failure stamp is NOT cleared here. Post
            // F-010 a successful detached spawn() is NOT a successful host — the
            // child may die within moments (the bad-argv exit-2 case). The clear
            // moved to confirm_residency_or_unhost's Resident arm, where a LIVE
            // resident psyche past the grace window proves the host truly took.
            // [impl->REQ-HAZARD-LIVEHOST-NONRESIDENT]
        }
        Err(e) => {

    let perch = perch::resolve_perch_path(id, ParentHint::Infer);
    let psyche_child = match lifecycle.spawn_psyche_owned(Some(&session_id), None) {
        Ok((h, child)) => {
            eprintln!("LIVEHOST_PSYCHE:{id} pid={}", h.pid);
            // NOTE: the prior psyche-host-failure stamp is NOT cleared here. Post
            // F-010 a successful detached spawn() is NOT a successful host — the
            // child may die within moments (the bad-argv exit-2 case). The clear
            // moved to confirm_residency_or_unhost's Resident arm, where a LIVE
            // resident psyche past the grace window proves the host truly took.
            // [impl->REQ-HAZARD-LIVEHOST-NONRESIDENT]
            // Retain the OWNED child handle so stop_host reaps the Psyche process by
            // handle on un-host (REQ-HAZARD-UNHOST-PSYCHE-REAP) — no orphan, recycle-proof.
            child
        }
        Err(e) => {


    let perch = perch::resolve_perch_path(id, ParentHint::Infer);
    match lifecycle.spawn_psyche(Some(&session_id), None) {
        Ok(h) => {
            eprintln!("LIVEHOST_PSYCHE:{id} pid={}", h.pid);
            // NOTE: the prior psyche-host-failure stamp is NOT cleared here. Post
            // F-010 a successful detached spawn() is NOT a successful host — the
            // child may die within moments (the bad-argv exit-2 case). The clear
            // moved to confirm_residency_or_unhost's Resident arm, where a LIVE
            // resident psyche past the grace window proves the host truly took.
            // [impl->REQ-HAZARD-LIVEHOST-NONRESIDENT]
        }
        Err(e) => {

    let perch = perch::resolve_perch_path(id, ParentHint::Infer);
    let psyche_child = match lifecycle.spawn_psyche_owned(Some(&session_id), None) {
        Ok((h, child)) => {
            eprintln!("LIVEHOST_PSYCHE:{id} pid={}", h.pid);
            // NOTE: the prior psyche-host-failure stamp is NOT cleared here. Post
            // F-010 a successful detached spawn() is NOT a successful host — the
            // child may die within moments (the bad-argv exit-2 case). The clear
            // moved to confirm_residency_or_unhost's Resident arm, where a LIVE
            // resident psyche past the grace window proves the host truly took.
            // [impl->REQ-HAZARD-LIVEHOST-NONRESIDENT]
            // Retain the OWNED child handle so stop_host reaps the Psyche process by
            // handle on un-host (REQ-HAZARD-UNHOST-PSYCHE-REAP) — no orphan, recycle-proof.
            child
        }
        Err(e) => {

// (resolve_perch_path/Infer), so the harness-reachable stamp lands on the

// exact info.json `spt endpoint list` / `whoami` reads.

-    match lifecycle.spawn_psyche(Some(&session_id), None) {

+    let psyche_child = match lifecycle.spawn_psyche_owned(Some(&session_id), None) {

+            // Retain the OWNED child handle so stop_host reaps the Psyche process by

+            // handle on un-host (REQ-HAZARD-UNHOST-PSYCHE-REAP) — no orphan, recycle-proof.

// Keep the (invisible) brain-stderr line AND stamp the failure onto the


355	            // F-010 a successful detached spawn() is NOT a successful host — the
356	            // child may die within moments (the bad-argv exit-2 case). The clear
357	            // moved to confirm_residency_or_unhost's Resident arm, where a LIVE
358	            // resident psyche past the grace window proves the host truly took.
359	            // [impl->REQ-HAZARD-LIVEHOST-NONRESIDENT]
360	            // Retain the OWNED child handle so stop_host reaps the Psyche process by
361	            // handle on un-host (REQ-HAZARD-UNHOST-PSYCHE-REAP) — no orphan, recycle-proof.
362	            child
363	        }
364	        Err(e) => {
365	            // Keep the (invisible) brain-stderr line AND stamp the failure onto the
366	            // perch's info.json so it is HARNESS-REACHABLE — a harness/human reads
367	            // perch state, never the brain's stderr. Current-state stamp: overwrite
368	            // reason+ts, increment attempts (the helper reads the prior count inside
369	            // its locked RMW). `status` is NOT touched — liveness stays authoritative.
370	            // host_one returns without tabling, so the 5s reconcile retries and each
371	            // retry re-stamps; a later CONFIRMED-RESIDENT host clears it (in
372	            // confirm_residency_or_unhost, not on bare spawn-Ok — see F-010 note above).
373	            // [impl->REQ-HAZARD-LIVEHOST-BOOT-RACE]
374	            eprintln!("LIVEHOST_PSYCHE_FAIL:{id}: {e}");
375	            let _ = spt_store::info::set_psyche_host_error(&perch, Some(&e.to_string()));
376	            return; // no Psyche → no driver; retried next reconcile
377	        }
378	    }
379	    let stop = Arc::new(AtomicBool::new(false));
380	    let handle = {
381	        let stop = Arc::clone(&stop);
382	        std::thread::spawn(move || {
383	            lifecycle.run_pulse_loop(Some(&session_id), &stop, reason, |_report| {});
384	        })
385	    };
386	    // Stamp the host instant — the residency-grace anchor a later tick measures
387	    // the `{id}-psyche` perch's liveness against (REQ-HAZARD-LIVEHOST-NONRESIDENT).
388	    set.insert(
389	        id,

            // F-010 a successful detached spawn() is NOT a successful host — the
            // child may die within moments (the bad-argv exit-2 case). The clear
            // moved to confirm_residency_or_unhost's Resident arm, where a LIVE
            // resident psyche past the grace window proves the host truly took.
            // [impl->REQ-HAZARD-LIVEHOST-NONRESIDENT]
            // Retain the OWNED child handle so stop_host reaps the Psyche process by
            // handle on un-host (REQ-HAZARD-UNHOST-PSYCHE-REAP) — no orphan, recycle-proof.
            child
        }
        Err(e) => {
            // Keep the (invisible) brain-stderr line AND stamp the failure onto the
            // perch's info.json so it is HARNESS-REACHABLE — a harness/human reads
            // perch state, never the brain's stderr. Current-state stamp: overwrite
            // reason+ts, increment attempts (the helper reads the prior count inside
            // its locked RMW). `status` is NOT touched — liveness stays authoritative.
            // host_one returns without tabling, so the 5s reconcile retries and each
            // retry re-stamps; a later CONFIRMED-RESIDENT host clears it (in
            // confirm_residency_or_unhost, not on bare spawn-Ok — see F-010 note above).
            // [impl->REQ-HAZARD-LIVEHOST-BOOT-RACE]
            eprintln!("LIVEHOST_PSYCHE_FAIL:{id}: {e}");
            let _ = spt_store::info::set_psyche_host_error(&perch, Some(&e.to_string()));
            return; // no Psyche → no driver; retried next reconcile
        }
    }
    let stop = Arc::new(AtomicBool::new(false));
    let handle = {
        let stop = Arc::clone(&stop);
        std::thread::spawn(move || {
            lifecycle.run_pulse_loop(Some(&session_id), &stop, reason, |_report| {});
        })
    };
    // Stamp the host instant — the residency-grace anchor a later tick measures
    // the `{id}-psyche` perch's liveness against (REQ-HAZARD-LIVEHOST-NONRESIDENT).
    set.insert(
        id,


378	    }
379	    let stop = Arc::new(AtomicBool::new(false));
380	    let handle = {
381	        let stop = Arc::clone(&stop);
382	        std::thread::spawn(move || {
383	            lifecycle.run_pulse_loop(Some(&session_id), &stop, reason, |_report| {});
384	        })
385	    };
386	    // Stamp the host instant — the residency-grace anchor a later tick measures
387	    // the `{id}-psyche` perch's liveness against (REQ-HAZARD-LIVEHOST-NONRESIDENT).
388	    set.insert(
389	        id,
390	        HostedLife {
391	            stop,
392	            thread: handle,
393	            hosted_at: Instant::now(),
394	        },
395	    );
396	}
397

    }
    let stop = Arc::new(AtomicBool::new(false));
    let handle = {
        let stop = Arc::clone(&stop);
        std::thread::spawn(move || {
            lifecycle.run_pulse_loop(Some(&session_id), &stop, reason, |_report| {});
        })
    };
    // Stamp the host instant — the residency-grace anchor a later tick measures
    // the `{id}-psyche` perch's liveness against (REQ-HAZARD-LIVEHOST-NONRESIDENT).
    set.insert(
        id,
        HostedLife {
            stop,
            thread: handle,
            hosted_at: Instant::now(),
        },
    );
}


            eprintln!("LIVEHOST_PSYCHE_FAIL:{id}: {e}");
            let _ = spt_store::info::set_psyche_host_error(&perch, Some(&e.to_string()));
            return; // no Psyche → no driver; retried next reconcile
        }
    }
    let stop = Arc::new(AtomicBool::new(false));
    let handle = {
        let stop = Arc::clone(&stop);
        std::thread::spawn(move || {
            lifecycle.run_pulse_loop(Some(&session_id), &stop, reason, |_report| {});
        })
    };
    // Stamp the host instant — the residency-grace anchor a later tick measures
    // the `{id}-psyche` perch's liveness against (REQ-HAZARD-LIVEHOST-NONRESIDENT).
    set.insert(
        id,
        HostedLife {
            stop,
            thread: handle,
            hosted_at: Instant::now(),
        },
    );
}

            eprintln!("LIVEHOST_PSYCHE_FAIL:{id}: {e}");
            let _ = spt_store::info::set_psyche_host_error(&perch, Some(&e.to_string()));
            return; // no Psyche → no driver; retried next reconcile
        }
    };
    let stop = Arc::new(AtomicBool::new(false));
    let handle = {
        let stop = Arc::clone(&stop);
        std::thread::spawn(move || {
            lifecycle.run_pulse_loop(Some(&session_id), &stop, reason, |_report| {});
        })
    };
    // Stamp the host instant — the residency-grace anchor a later tick measures
    // the `{id}-psyche` perch's liveness against (REQ-HAZARD-LIVEHOST-NONRESIDENT).
    set.insert(
        id,
        HostedLife {
            stop,
            thread: handle,
            hosted_at: Instant::now(),
            psyche_child: Some(psyche_child),
        },
    );
}


            eprintln!("LIVEHOST_PSYCHE_FAIL:{id}: {e}");
            let _ = spt_store::info::set_psyche_host_error(&perch, Some(&e.to_string()));
            return; // no Psyche → no driver; retried next reconcile
        }
    }
    let stop = Arc::new(AtomicBool::new(false));
    let handle = {
        let stop = Arc::clone(&stop);
        std::thread::spawn(move || {
            lifecycle.run_pulse_loop(Some(&session_id), &stop, reason, |_report| {});
        })
    };
    // Stamp the host instant — the residency-grace anchor a later tick measures
    // the `{id}-psyche` perch's liveness against (REQ-HAZARD-LIVEHOST-NONRESIDENT).
    set.insert(
        id,
        HostedLife {
            stop,
            thread: handle,
            hosted_at: Instant::now(),
        },
    );
}

            eprintln!("LIVEHOST_PSYCHE_FAIL:{id}: {e}");
            let _ = spt_store::info::set_psyche_host_error(&perch, Some(&e.to_string()));
            return; // no Psyche → no driver; retried next reconcile
        }
    };
    let stop = Arc::new(AtomicBool::new(false));
    let handle = {
        let stop = Arc::clone(&stop);
        std::thread::spawn(move || {
            lifecycle.run_pulse_loop(Some(&session_id), &stop, reason, |_report| {});
        })
    };
    // Stamp the host instant — the residency-grace anchor a later tick measures
    // the `{id}-psyche` perch's liveness against (REQ-HAZARD-LIVEHOST-NONRESIDENT).
    set.insert(
        id,
        HostedLife {
            stop,
            thread: handle,
            hosted_at: Instant::now(),
            psyche_child: Some(psyche_child),
        },
    );
}

+            psyche_child: Some(psyche_child),


Now the Bunhostreap unit in livehost.rs — `stop_host` reaps a real stand-in process. Insert before the B2 int test:


    // [int->REQ-HAZARD-HOSTED-LIVENESS-RECONCILE] real broker session lifecycle:
    // an spt-hosted perch with a LIVE broker session stays online across a pull
    // reconcile; when its broker session is KILLED + reaped, the next pull
    // reconcile clears the status=online latch and marks the perch offline.
    #[test]
    fn pull_reconcile_offlines_perch_when_broker_session_dies() {

    // [unit->REQ-HAZARD-UNHOST-PSYCHE-REAP] stop_host reaps the detached Psyche
    // PROCESS by handle, not just its in-brain driver thread: a real long-lived
    // child stands in for the `{id}-psyche`; after stop_host it is terminated (no
    // orphan). Owning the Child (vs a bare pid) is recycle-proof — the reap binds the
    // exact process. Liveness here is is_process_alive(pid) on a process we do NOT own
    // (the Child was moved into HostedLife, killed + waited by stop_host) — after the
    // reap closes the handle the OS frees it, so the pid reads dead.
    #[test]
    fn stop_host_reaps_the_detached_psyche_process() {
        with_home(|_| {
            // A real long-lived stand-in for the detached Psyche process.
            #[cfg(windows)]
            let mut cmd = std::process::Command::new("cmd");
            #[cfg(windows)]
            cmd.args(["/C", "ping -n 60 127.0.0.1 >NUL"]);
            #[cfg(unix)]
            let mut cmd = std::process::Command::new("sh");
            #[cfg(unix)]
            cmd.args(["-c", "sleep 60"]);
            let child = cmd.spawn().expect("spawn the psyche stand-in");
            let pid = child.id();

            // A trivial driver-thread stand-in (the pulse-loop analog) that parks
            // until stopped — so stop_host's join completes promptly.
            let set = Arc::new(LiveSet::new());
            let stop = Arc::new(AtomicBool::new(false));
            let thread = {
                let stop = Arc::clone(&stop);
                std::thread::spawn(move || {
                    while !stop.load(Ordering::SeqCst) {
                        std::thread::sleep(Duration::from_millis(10));
                    }
                })
            };
            set.insert(
                "agent",
                HostedLife {
                    stop,
                    thread,
                    hosted_at: Instant::now(),
                    psyche_child: Some(child),
                },
            );

            assert!(
                spt_store::proc::is_process_alive(pid),
                "the psyche stand-in starts alive"
            );
            set.stop_host("agent"); // joins the driver thread AND reaps the psyche child
            let dead = {
                let deadline = Instant::now() + Duration::from_secs(5);
                loop {
                    if !spt_store::proc::is_process_alive(pid) {
                        break true;
                    }
                    if Instant::now() >= deadline {
                        break false;
                    }
                    std::thread::sleep(Duration::from_millis(20));
                }
            };
            assert!(dead, "stop_host must terminate the detached psyche process");
        });
    }

    // [int->REQ-HAZARD-HOSTED-LIVENESS-RECONCILE] real broker session lifecycle:
    // an spt-hosted perch with a LIVE broker session stays online across a pull
    // reconcile; when its broker session is KILLED + reaped, the next pull
    // reconcile clears the status=online latch and marks the perch offline.
    #[test]
    fn pull_reconcile_offlines_perch_when_broker_session_dies() {


    // [unit->REQ-HAZARD-UNHOST-PSYCHE-REAP] stop_host reaps the detached Psyche
    // PROCESS by handle, not just its in-brain driver thread: a real long-lived
    // child stands in for the `{id}-psyche`; after stop_host it is terminated (no
    // orphan). Owning the Child (vs a bare pid) is recycle-proof — the reap binds the
    // exact process. Liveness here is is_process_alive(pid) on a process we do NOT own
    // (the Child was moved into HostedLife, killed + waited by stop_host) — after the
    // reap closes the handle the OS frees it, so the pid reads dead.
    #[test]
    fn stop_host_reaps_the_detached_psyche_process() {
        with_home(|_| {
            // A real long-lived stand-in for the detached Psyche process.
            #[cfg(windows)]
            let mut cmd = std::process::Command::new("cmd");
            #[cfg(windows)]
            cmd.args(["/C", "ping -n 60 127.0.0.1 >NUL"]);
            #[cfg(unix)]
            let mut cmd = std::process::Command::new("sh");
            #[cfg(unix)]
            cmd.args(["-c", "sleep 60"]);
            let child = cmd.spawn().expect("spawn the psyche stand-in");
            let pid = child.id();

            // A trivial driver-thread stand-in (the pulse-loop analog) that parks
            // until stopped — so stop_host's join completes promptly.
            let set = Arc::new(LiveSet::new());
            let stop = Arc::new(AtomicBool::new(false));
            let thread = {
                let stop = Arc::clone(&stop);
                std::thread::spawn(move || {
                    while !stop.load(Ordering::SeqCst) {
                        std::thread::sleep(Duration::from_millis(10));
                    }
                })
            };
            set.insert(
                "agent",
                HostedLife {
                    stop,
                    thread,
                    hosted_at: Instant::now(),
                    psyche_child: Some(child),
                },
            );

            assert!(
                spt_store::proc::is_process_alive(pid),
                "the psyche stand-in starts alive"
            );
            set.stop_host("agent"); // joins the driver thread AND reaps the psyche child
            let dead = {
                let deadline = Instant::now() + Duration::from_secs(5);
                loop {
                    if !spt_store::proc::is_process_alive(pid) {
                        break true;
                    }
                    if Instant::now() >= deadline {
                        break false;
                    }
                    std::thread::sleep(Duration::from_millis(20));
                }
            };
            assert!(dead, "stop_host must terminate the detached psyche process");
        });
    }

    // [int->REQ-HAZARD-HOSTED-LIVENESS-RECONCILE] real broker session lifecycle:
    // an spt-hosted perch with a LIVE broker session stays online across a pull
    // reconcile; when its broker session is KILLED + reaped, the next pull
    // reconcile clears the status=online latch and marks the perch offline.
    #[test]
    fn pull_reconcile_offlines_perch_when_broker_session_dies() {

+    // [unit->REQ-HAZARD-UNHOST-PSYCHE-REAP] stop_host reaps the detached Psyche

+    // PROCESS by handle, not just its in-brain driver thread: a real long-lived

+    // child stands in for the `{id}-psyche`; after stop_host it is terminated (no

+    // orphan). Owning the Child (vs a bare pid) is recycle-proof — the reap binds the

+    // exact process. Liveness here is is_process_alive(pid) on a process we do NOT own

+    // (the Child was moved into HostedLife, killed + waited by stop_host) — after the

+    // reap closes the handle the OS frees it, so the pid reads dead.

+    fn stop_host_reaps_the_detached_psyche_process() {

+            // A real long-lived stand-in for the detached Psyche process.

+            let mut cmd = std::process::Command::new("cmd");

+            let mut cmd = std::process::Command::new("sh");

+            let child = cmd.spawn().expect("spawn the psyche stand-in");

+            // A trivial driver-thread stand-in (the pulse-loop analog) that parks

+            // until stopped — so stop_host's join completes promptly.

+            let set = Arc::new(LiveSet::new());

+            let stop = Arc::new(AtomicBool::new(false));

+                let stop = Arc::clone(&stop);

+                std::thread::spawn(move || {

+                    while !stop.load(Ordering::SeqCst) {

+                        std::thread::sleep(Duration::from_millis(10));

+                    hosted_at: Instant::now(),

+                    psyche_child: Some(child),

+                spt_store::proc::is_process_alive(pid),

+                "the psyche stand-in starts alive"

+            set.stop_host("agent"); // joins the driver thread AND reaps the psyche child

+                let deadline = Instant::now() + Duration::from_secs(5);

+                    if !spt_store::proc::is_process_alive(pid) {

+                    if Instant::now() >= deadline {

+                    std::thread::sleep(Duration::from_millis(20));

+            assert!(dead, "stop_host must terminate the detached psyche process");


Now a dead-code issue: with the owned-Child path, `kill_pid` has no caller → clippy `-D warnings` would flag it. doyle wanted it as an *unowned fallback*, but wiring that (cold-start-orphan psyche reap) is a recycle-prone, separate concern beyond this REQ's stated int. Cleanest: remove `kill_pid` this wave (owned-Child fully covers the REQ) and flag the residual to doyle. Revert the proc.rs addition:


/// Best-effort SCOPED termination of a SINGLE process by pid — never a tree, never
/// machine-wide (this box is shared with live broker + sibling agents, so a broad
/// reap is forbidden; KNOWN-HAZARDS). Used to reap a detached `{id}-psyche` harness
/// process when its endpoint un-hosts (REQ-HAZARD-UNHOST-PSYCHE-REAP): the in-brain
/// pulse-driver thread is joined separately, but the Psyche is a separate detached
/// process whose pid the caller tracked. `pid == 0` is never a real target (the
/// kernel/idle process; on Unix `kill(0, …)` would hit the caller's own group). The
/// caller guards pid-recycle with [`is_process_alive`] before calling.
// [impl->REQ-HAZARD-UNHOST-PSYCHE-REAP]
pub fn kill_pid(pid: u32) {
    if pid == 0 {
        return; // never a real per-process target (see is_process_alive)
    }
    #[cfg(unix)]
    unsafe {
        libc::kill(pid as i32, libc::SIGKILL);
        // Clear the zombie if it was our child (in-process hosting / tests); a no-op
        // (ECHILD) when somebody else owns the wait.
        libc::waitpid(pid as i32, std::ptr::null_mut(), libc::WNOHANG);
    }
    #[cfg(windows)]
    {
        extern "system" {
            fn OpenProcess(
                dw_desired_access: u32,
                b_inherit_handle: i32,
                dw_process_id: u32,
            ) -> isize;
            fn TerminateProcess(h_process: isize, u_exit_code: u32) -> i32;
            fn CloseHandle(h_object: isize) -> i32;
        }
        const PROCESS_TERMINATE: u32 = 0x0001;
        let handle = unsafe { OpenProcess(PROCESS_TERMINATE, 0, pid) };
        if handle != 0 {
            unsafe {
                TerminateProcess(handle, 1);
                CloseHandle(handle);
            }
        }
    }
}

/// The current process's **parent** pid — the stable session-binding anchor for

/// The current process's **parent** pid — the stable session-binding anchor for


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

/// Best-effort SCOPED termination of a SINGLE process by pid — never a tree, never
/// machine-wide (this box is shared with live broker + sibling agents, so a broad
/// reap is forbidden; KNOWN-HAZARDS). Used to reap a detached `{id}-psyche` harness
/// process when its endpoint un-hosts (REQ-HAZARD-UNHOST-PSYCHE-REAP): the in-brain
/// pulse-driver thread is joined separately, but the Psyche is a separate detached
/// process whose pid the caller tracked. `pid == 0` is never a real target (the
/// kernel/idle process; on Unix `kill(0, …)` would hit the caller's own group). The
/// caller guards pid-recycle with [`is_process_alive`] before calling.
// [impl->REQ-HAZARD-UNHOST-PSYCHE-REAP]
pub fn kill_pid(pid: u32) {
    if pid == 0 {
        return; // never a real per-process target (see is_process_alive)
    }
    #[cfg(unix)]
    unsafe {
        libc::kill(pid as i32, libc::SIGKILL);
        // Clear the zombie if it was our child (in-process hosting / tests); a no-op
        // (ECHILD) when somebody else owns the wait.
        libc::waitpid(pid as i32, std::ptr::null_mut(), libc::WNOHANG);
    }
    #[cfg(windows)]
    {
        extern "system" {
            fn OpenProcess(
                dw_desired_access: u32,
                b_inherit_handle: i32,
                dw_process_id: u32,
            ) -> isize;
            fn TerminateProcess(h_process: isize, u_exit_code: u32) -> i32;
            fn CloseHandle(h_object: isize) -> i32;
        }
        const PROCESS_TERMINATE: u32 = 0x0001;
        let handle = unsafe { OpenProcess(PROCESS_TERMINATE, 0, pid) };
        if handle != 0 {
            unsafe {
                TerminateProcess(handle, 1);
                CloseHandle(handle);
            }
        }
    }
}

/// The current process's **parent** pid — the stable session-binding anchor for

-/// Best-effort SCOPED termination of a SINGLE process by pid — never a tree, never

-/// machine-wide (this box is shared with live broker + sibling agents, so a broad

-/// reap is forbidden; KNOWN-HAZARDS). Used to reap a detached `{id}-psyche` harness

-/// process when its endpoint un-hosts (REQ-HAZARD-UNHOST-PSYCHE-REAP): the in-brain

-/// pulse-driver thread is joined separately, but the Psyche is a separate detached

-/// process whose pid the caller tracked. `pid == 0` is never a real target (the

-/// kernel/idle process; on Unix `kill(0, …)` would hit the caller's own group). The

-/// caller guards pid-recycle with [`is_process_alive`] before calling.

-        return; // never a real per-process target (see is_process_alive)

-        libc::kill(pid as i32, libc::SIGKILL);

-        // Clear the zombie if it was our child (in-process hosting / tests); a no-op

-        // (ECHILD) when somebody else owns the wait.

-        libc::waitpid(pid as i32, std::ptr::null_mut(), libc::WNOHANG);

-                dw_desired_access: u32,

-            fn TerminateProcess(h_process: isize, u_exit_code: u32) -> i32;

-            fn CloseHandle(h_object: isize) -> i32;

-        const PROCESS_TERMINATE: u32 = 0x0001;

-        let handle = unsafe { OpenProcess(PROCESS_TERMINATE, 0, pid) };

-                TerminateProcess(handle, 1);

/// harness-hosted startup (the harness process that spawned `spt`). The

/// pid-recycle defense (KNOWN-HAZARDS 5.1) anchors liveness on this, not on the


Activate the REQ + confirm the `endpoint stop` command form for the int:


required_stages = []  # registry-first (operator-greenlit fold-in, wave 5 w/ B4); activate [impl,unit,int] when built. impl = stop_host scoped-kills the {id}-psyche pid (tracked in HostedLife at host_one); unit = stop_host terminates a real enrolled psyche-stand-in process; int = endpoint-stop of a live spt-hosted agent leaves no {id}-psyche process

required_stages = ["impl", "unit", "int"]  # activated v0.12.0 wave 5 (operator-greenlit fold-in): impl = spawn_session_owned/spawn_psyche_owned RETAIN the Child up to HostedLife.psyche_child; stop_host reaps BY HANDLE (child.kill()+wait(), recycle-proof — NOT a bare pid that could hit a recycled sibling); unit = stop_host_reaps_the_detached_psyche_process (real stand-in terminated); int = endpoint-stop of a live spt-hosted agent leaves no {id}-psyche process


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

title = "On un-host, the detached `{id}-psyche` HARNESS PROCESS is reaped — not just its in-brain pulse-driver thread. Today stop_host (livehost.rs:203) trips the HostedLife stop flag + JOINS the driver thread, but the Psyche is a detached harness process (spawn_psyche → ManifestRuntime detached spawn, runtime.rs:341-356; its pid is untracked in HostedLife though stamped on the `{id}-psyche` perch, where residency-confirm already reads it). So endpoint-stop / mid-life agent-death / a B2/B5 offline-then-unhost leaves the psyche process ORPHANED, alive until the next daemon-stop (where Breap's job/group reaps the whole brain subtree). The Psyche STAYS a harness process by design (CONTEXT.md 97/203/251 — headless harness session, its own perch) — the fix does NOT move it in-brain; it SCOPED-kills the `{id}-psyche` pid on un-host (never machine-wide — shared box). Track the pid in HostedLife at host_one (cleanest) or read the `{id}-psyche` perch pid at stop_host. Composes with H3 (endpoint stop → offline → reconcile un-host → reap) and B2/B5 (the offline arms that trigger un-host). (v0.12.0)"

-required_stages = []  # registry-first (operator-greenlit fold-in, wave 5 w/ B4); activate [impl,unit,int] when built. impl = stop_host scoped-kills the {id}-psyche pid (tracked in HostedLife at host_one); unit = stop_host terminates a real enrolled psyche-stand-in process; int = endpoint-stop of a live spt-hosted agent leaves no {id}-psyche process

+required_stages = ["impl", "unit", "int"]  # activated v0.12.0 wave 5 (operator-greenlit fold-in): impl = spawn_session_owned/spawn_psyche_owned RETAIN the Child up to HostedLife.psyche_child; stop_host reaps BY HANDLE (child.kill()+wait(), recycle-proof — NOT a bare pid that could hit a recycled sibling); unit = stop_host_reaps_the_detached_psyche_process (real stand-in terminated); int = endpoint-stop of a live spt-hosted agent leaves no {id}-psyche process

title = "A slow / dead / hostile VIEWER must NEVER stall the controller, the PTY child, or the session drain thread. The broker drain fans output to the controller on the authoritative blocking bounded path (advances delivered_through) but to each viewer via a bounded per-viewer channel with a dedicated writer thread; the drain `try_send`s under the log lock and a viewer whose bounded queue OVERFLOWS (can't keep up) is EVICTED (queue dropped, writer thread ends, removed from the viewers map) — the drain thread NEVER touches a viewer socket, so no viewer write can backpressure or block it. A soft viewer cap bounds the thread count. Viewer eviction never perturbs the controller stream, the delivered_through cursor, or the child."


Bash

cd "C:/Users/decid/Documents/projects/spt-core"; grep -rn "endpoint\|Stop\|stop" crates/spt/src/cli.rs | grep -iE "EndpointCmd|Stop\b|\"stop\"|endpoint.*stop|cmd_stop" | head -15

Find the endpoint stop CLI form


209:    /// The per-machine daemon: run, stop, or read node status.
329:    /// Soft-stop a perch (spool preserved).
333:    Stop {
334:        /// Perch id to stop.
341:    /// Refuses while the perch is live (stop it first).
356:        /// Stream live changes instead of a one-shot snapshot (Ctrl-C to stop).
430:    /// Stop the daemon (service-aware: a managed service is stopped via its
431:    /// manager so it does not auto-restart-fight; else a graceful IPC stop).
432:    Stop,
868:    /// Stop serving a held subnet (the daemon keeps running).
875:        /// The held subnet to stop serving.
1081:            EndpointCmd::Stop { id } => cmd_stop(&id),
1146:            DaemonCmd::Stop => cmd_daemon_stop(),
1669:/// variable-width `endpoint_id` never snaps later fields to a different tab-stop
1786:/// shells offline"). Two legs through existing machinery: (1) soft-stop the


`spt endpoint stop <id>` → `cmd_stop` (H3, sets offline). Write the Bunhostreap int modeled on bootrace — host a resident psyche, `endpoint stop`, assert the psyche process dies via the owned-reap (test never kills it):


Write

//! REQ-HAZARD-UNHOST-PSYCHE-REAP int — un-hosting a live spt-hosted agent reaps its
//! detached `{id}-psyche` HARNESS PROCESS; it does not orphan until daemon-stop.
//!
//! Today the brain's livehost tabled only the in-brain pulse-driver THREAD; the
//! Psyche is a separate detached process, so endpoint-stop / signoff / a B2+B5
//! offline stopped the driver but left the Psyche running until the next
//! daemon-stop (Breap). The fix retains the OWNED child handle up to
//! `HostedLife.psyche_child` and reaps it BY HANDLE in `stop_host` (`kill()`+`wait()`,
//! recycle-proof — a bare-pid kill could hit a recycled sibling on this shared box).
//!
//! Full-stack proof: the REAL `spt daemon run` broker + supervised brain host a
//! LONG-LIVED Psyche for an online live agent (the bootrace resident fixture), then
//! `spt endpoint stop <id>` (H3) marks the perch offline → the brain's next reconcile
//! tick un-hosts it → `stop_host` reaps the Psyche. The test then asserts the
//! `{id}-psyche` PROCESS reads DEAD **without the test killing it** — the daemon's
//! owned-reap did. Bounded; all spawned pids reaped scoped (never machine-wide).
// [int->REQ-HAZARD-UNHOST-PSYCHE-REAP]

use std::path::{Path, PathBuf};
use std::process::{Child, Command, Output, Stdio};
use std::time::{Duration, Instant};

mod common;
use common::CommandNoWindowExt;

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

/// Scoped pid-tree kill — never machine-wide (a live broker shares this box).
fn kill_pid(pid: u32) {
    #[cfg(windows)]
    let _ = Command::new("taskkill")
        .no_window()
        .args(["/PID", &pid.to_string(), "/F", "/T"])
        .output();
    #[cfg(unix)]
    let _ = Command::new("kill").args(["-9", &pid.to_string()]).output();
}

/// `Command::output()` with a deadline, off-thread — bounds a hang into a failure.
fn output_bounded(mut cmd: Command, deadline: Duration) -> Output {
    let (tx, rx) = std::sync::mpsc::channel();
    std::thread::spawn(move || {
        let _ = tx.send(cmd.output());
    });
    rx.recv_timeout(deadline)
        .expect("captured spt call must complete within its deadline")
        .expect("run spt")
}

/// Poll `brain.ready` for the brain pid, up to `budget`.
fn wait_for_ready_pid(path: &Path, budget: Duration) -> Option<u32> {
    let deadline = Instant::now() + budget;
    while Instant::now() < deadline {
        if let Ok(s) = std::fs::read_to_string(path) {
            if let Some(pid) = serde_json::from_str::<serde_json::Value>(&s)
                .ok()
                .and_then(|v| v.get("pid").and_then(|p| p.as_u64()))
            {
                return Some(pid as u32);
            }
        }
        std::thread::sleep(Duration::from_millis(50));
    }
    None
}

#[test]
fn endpoint_stop_reaps_the_hosted_psyche_process() {
    // ── (1) Isolated, NET-LESS SPT_HOME (corrupt node.key → no net, brain still hosts). ──
    let home = tempfile::tempdir().unwrap();
    std::env::set_var("SPT_HOME", home.path());
    let identity_dir = home.path().join("identity");
    std::fs::create_dir_all(&identity_dir).unwrap();
    std::fs::write(identity_dir.join("node.key"), "not-a-valid-seed").unwrap();

    let spt_bin = PathBuf::from(env!("CARGO_BIN_EXE_spt"));
    let exe_suffix = spt_bin
        .extension()
        .map(|e| format!(".{}", e.to_string_lossy()))
        .unwrap_or_default();

    // ── (2) A live-capable adapter whose psyche is LONG-LIVED (a copy of `spt` run as
    //    the blocking `ready` listener — the bootrace resident fixture). The bare token
    //    resolves only via install-dir resolution. ──
    let src = perch::spt_home().join("srcs").join("mocklive");
    std::fs::create_dir_all(&src).unwrap();
    let psyche_bin = src.join(format!("psychebin{exe_suffix}"));
    std::fs::copy(&spt_bin, &psyche_bin).expect("stage the psyche binary fixture");
    let drops = home.path().join("drops");
    std::fs::create_dir_all(&drops).unwrap();
    let dir = drops.to_string_lossy().replace('\\', "/");
    let manifest_toml = format!(
        "[adapter]\nname=\"mocklive\"\nkind=\"harness\"\nversion=\"1\"\n\
         min_spt_core_version=\"0\"\n\n\
         [session]\ncommune_dir=\"{dir}\"\nsignoff_dir=\"{dir}\"\n\n\
         [session.psyche_init]\ncommand='psychebin ready unhost-reap-resident-psyche'\ncwd=\"{{psyche_dir}}\"\nkeys=[]\n"
    );
    let manifest_path = src.join("manifest.toml");
    std::fs::write(&manifest_path, &manifest_toml).unwrap();
    spt_runtime::registry::register(&perch::adapters_dir(), &src, 1000).unwrap();

    // ── (3) Launch the REAL daemon broker PROCESS (spawns the supervised brain). ──
    let brain_log = home.path().join("brain.stderr.log");
    let brain_log_file = std::fs::File::create(&brain_log).expect("create brain stderr log");
    let mut broker: Child = Command::new(&spt_bin)
        .no_window()
        .args(["daemon", "run"])
        .env("SPT_HOME", home.path())
        .stdout(Stdio::null())
        .stderr(Stdio::from(brain_log_file))
        .spawn()
        .expect("spawn spt daemon run (broker process)");

    let brain_pid =
        match wait_for_ready_pid(&home.path().join("brain.ready"), Duration::from_secs(30)) {
            Some(p) => p,
            None => {
                let _ = broker.kill();
                let _ = broker.wait();
                panic!(
                    "PRECONDITION FAILED: brain never came up.\n{}",
                    std::fs::read_to_string(&brain_log).unwrap_or_default()
                );
            }
        };

    // ── (4) The REAL establish→online path: `api seed` then a persistent `api listen`
    //    (marks the perch online; the brain reconcile then hosts the Psyche). ──
    let id = "unhost-reap-agent";
    let pid = std::process::id().to_string();
    let mp = manifest_path.to_string_lossy().to_string();

    let seed = {
        let mut cmd = Command::new(&spt_bin);
        cmd.no_window()
            .args([
                "api", "--adapter", "mocklive", "seed", "--pid", &pid, "--session-id", "sid-unhost",
            ])
            .env("SPT_HOME", home.path());
        output_bounded(cmd, Duration::from_secs(30))
    };
    assert!(
        seed.status.success(),
        "api seed failed: {}",
        String::from_utf8_lossy(&seed.stderr)
    );

    let listen_log = home.path().join("listen.stderr.log");
    let listen_log_file = std::fs::File::create(&listen_log).expect("create listen stderr log");
    let mut listen: Child = Command::new(&spt_bin)
        .no_window()
        .args([
            "api", "--adapter", "mocklive", "--manifest", &mp, "listen", id, "--parent-pid", &pid,
        ])
        .env("SPT_HOME", home.path())
        .stdout(Stdio::null())
        .stderr(Stdio::from(listen_log_file))
        .spawn()
        .expect("spawn the persistent api listen");

    // ── (5) Wait until the brain hosts the Psyche AND it is RESIDENT (alive past the
    //    grace) — read the `{id}-psyche` perch pid + probe it. ──
    let psyche_perch =
        perch::resolve_perch_path(&format!("{id}-psyche"), ParentHint::Explicit(id));
    let hosted_deadline = Instant::now() + Duration::from_secs(30);
    let mut psyche_pid: Option<u32> = None;
    while Instant::now() < hosted_deadline {
        if let Some(p) = spt_store::info::read_pid(&psyche_perch) {
            if spt_store::proc::is_process_alive(p) {
                psyche_pid = Some(p);
                break;
            }
        }
        std::thread::sleep(Duration::from_millis(200));
    }
    let psyche_pid = match psyche_pid {
        Some(p) => p,
        None => {
            kill_pid(listen.id());
            let _ = listen.wait();
            kill_pid(brain_pid);
            let _ = broker.kill();
            let _ = broker.wait();
            panic!(
                "PRECONDITION FAILED: the brain never hosted a resident Psyche.\n\
                 === brain stderr ===\n{}\n=== listen stderr ===\n{}",
                std::fs::read_to_string(&brain_log).unwrap_or_default(),
                std::fs::read_to_string(&listen_log).unwrap_or_default()
            );
        }
    };

    // ── (6) THE TRIGGER: `spt endpoint stop <id>` (H3) marks the perch offline → the
    //    brain's next reconcile tick un-hosts it → stop_host reaps the Psyche. ──
    let stop = {
        let mut cmd = Command::new(&spt_bin);
        cmd.no_window()
            .args(["endpoint", "stop", id])
            .env("SPT_HOME", home.path());
        output_bounded(cmd, Duration::from_secs(30))
    };
    assert!(
        stop.status.success(),
        "endpoint stop failed: {}",
        String::from_utf8_lossy(&stop.stderr)
    );

    // ── (7) THE ASSERTION: the Psyche PROCESS dies — reaped by the daemon's owned-reap,
    //    NOT by this test. Poll is_process_alive(psyche_pid) to dead (one reconcile tick
    //    ~5s + kill latency; bound generously). We never killed it ourselves. ──
    let reap_deadline = Instant::now() + Duration::from_secs(20);
    let mut reaped = false;
    while Instant::now() < reap_deadline {
        if !spt_store::proc::is_process_alive(psyche_pid) {
            reaped = true;
            break;
        }
        std::thread::sleep(Duration::from_millis(200));
    }

    let brain_stderr = std::fs::read_to_string(&brain_log).unwrap_or_default();
    eprintln!("=== DIAGNOSTIC: psyche_pid={psyche_pid} reaped={reaped} brain_pid={brain_pid} ===");
    eprintln!("=== brain stderr ===\n{brain_stderr}");

    // ── (8) Reap scoped. The psyche should already be dead; kill defensively only if
    //    the bug left it alive (so the test never leaks). ──
    if !reaped {
        kill_pid(psyche_pid);
    }
    kill_pid(listen.id());
    let _ = listen.wait();
    kill_pid(brain_pid);
    let _ = broker.kill();
    let _ = broker.wait();

    assert!(
        reaped,
        "REQ-HAZARD-UNHOST-PSYCHE-REAP: un-hosting (endpoint stop → reconcile un-host) must \
         reap the detached `{id}-psyche` process — pid {psyche_pid} was STILL ALIVE 20s after \
         the perch went offline (orphaned; only daemon-stop/Breap would have caught it).\n\
         === brain stderr ===\n{brain_stderr}",
    );
}