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 mod psyche;
pub mod pulse;
pub mod signoff;

#[cfg(test)]
mod test_support;

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

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

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

#[cfg(test)]
mod test_support;

pub use context::{write_context, WriteOutcome, WriteSource};
pub use echo::{run_echo_commune, stamp_provenance, EchoError, EchoResult};
pub use history::{fetch_history, HistoryError, HistoryRecord};
pub use ingest::{ingest_drops, DropKind, Ingested};
pub use psyche::{compose_psyche_prompt, spawn_psyche, PsycheError, PsycheHandle};
pub use pulse::{tick, PulseConfig, TickOutcome};

pub mod ingest;
pub mod psyche;
pub mod pulse;
+pub mod signoff;
#[cfg(test)]
mod test_support;

+    graceful_signoff, sweep_stale_signoff, write_resume_commune, SignoffOutcome,


Bash

cargo test -p spt-live 2>&1 | grep -E "test result|error\[|FAILED|warning:" | head; echo "=== sweep dead_code check ==="; cargo clippy -p spt-live --all-targets -- -D warnings 2>&1 | tail -4

Test + clippy spt-live with signoff


test result: ok. 31 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.31s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
=== sweep dead_code check ===
    Checking spt-live v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-live)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.34s


31 green, clippy clean. Activate the three T6 hazards.


grep -n -A2 'id = "REQ-HAZARD-ECHO-BEFORE-SIGNOFF"\|id = "REQ-HAZARD-GRACE-BEFORE-SIGNOFF"\|id = "REQ-HAZARD-STALE-SIGNOFF-SENTINEL"' traceable-reqs.toml

Check T6 hazard stages


332:id = "REQ-HAZARD-GRACE-BEFORE-SIGNOFF"
333-title = "Grace-period wait completes before composing INIT_SIGNOFF (1.1)"
334-required_stages = []
--
372:id = "REQ-HAZARD-STALE-SIGNOFF-SENTINEL"
373-title = "Stale signoff sentinel does not kill a fresh start (3.2)"
374-required_stages = []
--
376:id = "REQ-HAZARD-ECHO-BEFORE-SIGNOFF"
377-title = "Echo-commune fires before INIT_SIGNOFF on orphan teardown (3.3)"
378-required_stages = []


cd "C:\Users\decid\Documents\projects\spt-core" && \
sed -i 's|title = "Grace-period wait completes before composing INIT_SIGNOFF (1.1)"\nrequired_stages = \[\]||' traceable-reqs.toml; echo "use edits instead"

noop


use edits instead


Edit

title = "Grace-period wait completes before composing INIT_SIGNOFF (1.1)"
required_stages = []

title = "Grace-period wait completes before composing INIT_SIGNOFF (1.1)"
required_stages = ["impl", "unit"]


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 = "Grace-period wait completes before composing INIT_SIGNOFF (1.1)"
required_stages = []

title = "Grace-period wait completes before composing INIT_SIGNOFF (1.1)"
required_stages = ["impl", "unit"]

title = "State-file reads tolerate concurrent writes (1.2)"


title = "Stale signoff sentinel does not kill a fresh start (3.2)"
required_stages = []

title = "Stale signoff sentinel does not kill a fresh start (3.2)"
required_stages = ["impl", "unit"]


title = "Stale signoff sentinel does not kill a fresh start (3.2)"
required_stages = []

title = "Stale signoff sentinel does not kill a fresh start (3.2)"
required_stages = ["impl", "unit"]

title = "Echo-commune fires before INIT_SIGNOFF on orphan teardown (3.3)"


title = "Echo-commune fires before INIT_SIGNOFF on orphan teardown (3.3)"
required_stages = []

title = "Echo-commune fires before INIT_SIGNOFF on orphan teardown (3.3)"
required_stages = ["impl", "unit"]


title = "Echo-commune fires before INIT_SIGNOFF on orphan teardown (3.3)"
required_stages = []

title = "Echo-commune fires before INIT_SIGNOFF on orphan teardown (3.3)"
required_stages = ["impl", "unit"]

title = "Envelope decode order, ampersand decoded last (4.1)"


Bash

traceable-reqs check > /dev/null 2>&1; echo "check_exit=$?"

traceable check after T6 activation


T0 — Scaffold spt-live crate

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

T1 — History subsystem

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

T2 — spawn-psyche seam

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

T3 — echo-commune seam

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

T4 — drop-file ingest

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

T5 — pulse heartbeat

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

T6 — signoff + boundary lifecycle

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

T7 — resume-session seam

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

T8 — api listen lifecycle wiring

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

T9 — LiveAgent E2E

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

T10 — Activation sweep

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


git add -A && git commit -q -F - <<'EOF'
feat(spt-live): M2b T6 — signoff + boundary ordering invariants

The sister's most expensive bugs — copy the ordering, clean-room the wiring.
graceful_signoff bakes in the load-bearing sequence with injected effects:
- 3.3 echo-before-signoff: the final-delta echo runs BEFORE composing
  INIT_SIGNOFF (skipped only when session_id is missing);
- 1.1 grace-before-signoff: the grace wait completes and the recheck binds
  still_gone BEFORE any signoff write — a transiently-recovered Self is never
  torn down.
sweep_stale_signoff (3.2) removes a leftover <id>-signoff.md on listener start
so a prior generation's sentinel can't read as a live signoff and kill a fresh
start. write_resume_commune authors the boundary Self-resume commune (Source:
self-commune, precedence-guarded) the re-entry preloads — M2a rebinds, M2b
writes.

M2b owns the GRACEFUL paths; orphan crash-watch is M3 and reuses this exact
ordering. 5 unit tests assert the ORDER (echo+recheck strictly precede compose),
the recovered-not-signed-off path, echo-skip on no session, the sweep, and the
provenance-tagged resume commune. REQ-HAZARD-ECHO-BEFORE-SIGNOFF /
GRACE-BEFORE-SIGNOFF / STALE-SIGNOFF-SENTINEL activated [impl,unit].

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

Commit T6


warning: in the working copy of 'crates/spt-live/src/lib.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'traceable-reqs.toml', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-live/src/signoff.rs', LF will be replaced by CRLF the next time Git touches it
86305ca feat(spt-live): M2b T6 — signoff + boundary ordering invariants


Updated task #7 status


7 shipped. T7 — resume seam. Read CONTEXT §resume seam to get the two modes right.


grep -n "resume\|psyche-context\|psyche_context\|fresh-with-preload\|continue-existing\|preload\|download" CONTEXT.md docs/MANIFEST.md | head -30

Find resume seam spec in CONTEXT


CONTEXT.md:57:A declarative configuration file (TOML/YAML — schema TBD) that tells spt-core how to drive a specific harness. Declares: how to invoke an agent session, how to look up conversation history for an echo commune, how to spawn/resume a Psyche-equivalent, which binary or command implements each harness-side operation, and which endpoint types this harness supports. spt-core is the actor for each of these; the manifest tells it what to do.
CONTEXT.md:75:How the harness spawns agent sessions, including Psyche and echo-commune sessions. For the rebuilt spt-plugin, Psyche and echo communes must migrate **off `claude -p`** (imminent Claude Code billing changes) to headless `claude` sessions (`--resume` for the Psyche). This is an adapter/manifest concern, not a core concern, but the parity milestone must carry it.
CONTEXT.md:81:**spawn-session seam** — launch a new agent session on this node. Manifest provides: a command template; `cwd`/project; a `headless` flag (optional, default false — for the GUI's resume-of-compatible-adapters); a `resume` flag (optional); and the `commune` + `signoff` file directories relative to `cwd` (so the daemon knows where to watch). Substitution keys spt-core can supply: `{id}` and, optionally, a spt-core-generated valid session UUID (e.g. injected as `--session-id {uuid}`) so an adapter can skip the post-spawn seam. spt-core does **not** inject: harness-internal env (broker handles binary env; adapter handles in-session env), and **no initial-context handoff** (not needed at start — the agent is prompted for context once its session is up; the first commune populates it).
CONTEXT.md:88:**spawn-psyche seam** — two command templates: fresh-start and resume (the resume template includes `$session_id`). Both include `$psyche_prompt` — the revival essentials spt-core feeds the Psyche (timestamp, incoming event envelope). Everything else is the adapter's: model selection (in its template), and any harness-specific instructions the Psyche needs (Write-tool usage, commune dir) supplied as a static preamble before `$psyche_prompt` or as adapter SessionStart additionalContext. spt-core owns `$psyche_prompt` content; the adapter owns the rest.
CONTEXT.md:90:**history subsystem** (covers echo-commune source logs, resume briefs, and Shell logs) — two supported paths:
CONTEXT.md:105:**resume-session seam** — two distinct forms:
CONTEXT.md:106:- **fresh-with-preload:** resume with *cleared* context (a fresh session) + psyche-download. Accepts a `$psyche-context` key to launch the fresh session with the psyche-download preloaded — or the adapter instead pulls it via an spt-core command in its SessionStart hook.
CONTEXT.md:107:- **continue-existing:** resume an existing harness session under the adapter (its native resume).
CONTEXT.md:132:- **`api boundary <clear|compact>`** — context-boundary report; **carries the new `session_id`** (it rotates on `/clear` or `/compact`), so the daemon rebinds the perch to the new session id while keeping the stable identity + `parent_pid` anchor. Authors a **Self-resume commune** (resume the Self session → commune file-drop) rather than a background echo — strong live-context signal at the boundary (see `docs/CONTEXT-MEMORY.md`).
CONTEXT.md:199:**Shell** (first-class concept — model locked, concrete types deferred past v1): a *driven surface*, not an agent. Nothing intelligent runs at a Shell; a remote agent (on another node) drives it. A Shell is a "self-documenting" endpoint that advertises a **typed capability toolset** and a 2-way interaction relationship with the user. It may live behind a node on a platform with zero conventional-harness compatibility (e.g. a gaming handheld). Examples (all deferred): `GameRobot` (a 2D-world avatar — move, gesture, alert-symbol, request-screenshot, preload-message), an OS-notification target, an in-session-inject target. The earlier "presence blueprint" idea collapses into this: a blueprint *is* a Shell type + its capabilities.
CONTEXT.md:310:The logical identity (`ling`). Shared subnet-wide. The thing you address. **Adapter-agnostic**: the same ID can run under different adapters over its life (start on `claude-spt`, later revive on `spt-codex` or `spt-pi`) — adapters expose the same SPT-relevant capability subset, so the ID is not bound to an adapter. The currently-running `adapter_name` is a property of the live instance, not part of the identity. **At most one instance of an ID per node** is allowed; a second attempt on the same node (any adapter) is rejected. An endpoint keeps an **ordered adapter history** (in `tracked/agents/<id>/meta.json`, synced) driving the resume UX: latest adapter is the default resume offer, then next-latest, … oldest, then "choose a different adapter". (Adapter-agnosticism is an *agent-endpoint* property; Shells are adapter/platform-bound — see Shells.)
CONTEXT.md:322:- **suspended** (opt-in, *cold*): the harness session is closed and resumed-on-wake → frees RAM/compute (and, for billable harnesses, footprint), at the cost of a resume on wake. Reached via per-endpoint opt-in *auto-suspend*, or on demand: a subcommand can **suspend an endpoint from anywhere** (any node).
CONTEXT.md:334:**Commands** (user/agent-initiated lifecycle, unprefixed): `spt suspend <id[@node]>` (suspend any of your instances from any node), `spt wake <id[@node]>` (explicitly wake a dormant/suspended instance), `spt shutdown` (an agent suspends its *own* endpoint — graceful, fires the suspend boundary signoff, cascades shells offline), `spt refresh` (spt-hosted only — an agent clears + resumes itself without stalling: `/clear` → commune capture → guaranteed post-clear resume signal so its turn restarts from immediate next-steps; see `docs/CONTEXT-MEMORY.md`).
CONTEXT.md:343:- **Smooth handoff (remote-control → local):** no teardown required. `git pull` brings the files; the **fresh-with-preload resume seam** preloads the other instance's latest synced context (psyche-download); the remote instance simply goes dormant. The mind follows; the files are local.
CONTEXT.md:469:This is the realization of the "guided resume" (resume by project or agent, XMB-style filtered selection) and "management GUI" (per-endpoint panes: latest session output, live/project context, psyche log) sketches. `attach` is one operation under this frontend, not the whole frontend.
docs/MANIFEST.md:49:One opaque command template per role: `self`, `psyche_init`, `psyche_resume`, `echo_commune`, `signoff`. Plus `cwd`, env inject/remove, detachment, and the recursion-guard env var. **Model, tools, flags, dir-grants all live inside `command`** — not as fields.
docs/MANIFEST.md:60:(`psyche_resume` adds `{session_id}`; both carry the spt-core-owned `{psyche_prompt}`. `signoff` is a `psyche_resume` variant firing a final context-save. `echo_commune` is a cheap-model template consuming history.)
docs/MANIFEST.md:159:| Psyche resume | `claude -p --resume <uuid> --model sonnet …` | `[session.psyche_resume]` + `{session_id}` |


Spec clear (CONTEXT:105-107). Refactor psyche.rs to share spawn+bind, add `psyche_context` substitution key, build resume module.


Edit

    "psyche_dir",
    "psyche_prompt",
    "agents_json",

    "psyche_dir",
    "psyche_prompt",
    "psyche_context",
    "agents_json",


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

    "psyche_dir",
    "psyche_prompt",
    "agents_json",

    "psyche_dir",
    "psyche_prompt",
    "psyche_context",
    "agents_json",


Now refactor psyche.rs to share the spawn+bind core:


/// 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> {
    let psyche_id = format!("{parent_id}-psyche");

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


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)

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

//! 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> {
    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(PSYCHE_INIT_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:?}");
        });
    }
}

+    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

+) -> Result<PsycheHandle, PsycheError> {


    k.insert(
        "psyche_prompt".to_string(),
        compose_psyche_prompt(now, event),
    );

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

    k.insert(
        "psyche_prompt".to_string(),
        compose_psyche_prompt(now, event),
    );

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


    k.insert(
        "psyche_prompt".to_string(),
        compose_psyche_prompt(now, event),
    );

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

//! 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(PSYCHE_INIT_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:?}");
        });
    }
}

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

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


Now the resume module.


Updated task #8 status


Write

//! resume-session seam (M2b T7) — re-entry into a Psyche's mind.
//!
//! Two distinct forms (CONTEXT §resume-session seam), both spawned from
//! `[session.psyche_resume]`:
//! - **continue-existing** — resume an existing harness session under the
//!   adapter (its native `--resume {session_id}`). Requires `session_id`.
//! - **fresh-with-preload** — resume with *cleared* context (a fresh session)
//!   plus the **psyche-download**: the prior context, preloaded via the
//!   `{psyche_context}` key (or the adapter pulls it in its SessionStart hook).
//!   Requires `psyche_context`.
//!
//! The required key for the chosen mode is validated **before** spawn — a
//! wrong/missing key errors cleanly, never a half-launched session
//! (REQ-SEAM-RESUME). Both wire into the spawn path and the boundary re-entry
//! (T6 authors the resume commune; this preloads it).
//
// Clean-room over the M2a spawn seam; the native-resume mechanics live in the
// adapter's opaque psyche_resume template (model + --resume flag), never here.
// [impl->REQ-SEAM-RESUME]

use crate::ingest::context_path;
use crate::psyche::{spawn_and_bind, PsycheError, PsycheHandle};
use spt_store::perch::ParentHint;
use std::collections::BTreeMap;

/// The role spawned for a Psyche re-entry.
pub const PSYCHE_RESUME_ROLE: &str = "psyche_resume";

/// Which resume form to perform.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResumeMode {
    /// Native resume of an existing harness session (`{session_id}`).
    ContinueExisting,
    /// Fresh cleared session preloaded with the psyche-download (`{psyche_context}`).
    FreshWithPreload,
}

impl ResumeMode {
    /// The substitution key this mode *requires* the caller to have supplied.
    fn required_key(self) -> &'static str {
        match self {
            ResumeMode::ContinueExisting => "session_id",
            ResumeMode::FreshWithPreload => "psyche_context",
        }
    }
}

/// A resume failure.
#[derive(Debug)]
pub enum ResumeError {
    /// The required key for the chosen mode is missing/empty — caught *before*
    /// any spawn.
    MissingResumeKey {
        mode: ResumeMode,
        key: &'static str,
    },
    /// Spawn / bind failure from the Psyche core.
    Psyche(PsycheError),
}

impl std::fmt::Display for ResumeError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            ResumeError::MissingResumeKey { mode, key } => {
                write!(f, "resume mode {mode:?} requires key `{key}` (missing before spawn)")
            }
            ResumeError::Psyche(e) => write!(f, "resume spawn failed: {e}"),
        }
    }
}

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

/// Read the perch's stored context — the **psyche-download** a fresh-with-preload
/// resume feeds back in via `{psyche_context}`. `None` if nothing is stored yet.
pub fn download_psyche_context(id: &str, hint: ParentHint<'_>) -> Option<String> {
    std::fs::read_to_string(context_path(id, hint)).ok()
}

/// Resume a Psyche for `parent_id` in the chosen `mode`. The mode's required key
/// is validated against `keys` **before** spawn; on success the `psyche_resume`
/// role is spawned and the nested perch rebound.
pub fn resume_psyche(
    runtime: &dyn spt_runtime::AgentRuntime,
    parent_id: &str,
    mode: ResumeMode,
    keys: &BTreeMap<String, String>,
    event: Option<&str>,
    now: &str,
) -> Result<PsycheHandle, ResumeError> {
    // Validate the mode's required key BEFORE any spawn (REQ-SEAM-RESUME).
    let required = mode.required_key();
    if keys.get(required).map(String::as_str).unwrap_or("").is_empty() {
        return Err(ResumeError::MissingResumeKey {
            mode,
            key: required,
        });
    }
    spawn_and_bind(runtime, PSYCHE_RESUME_ROLE, parent_id, keys, event, now)
        .map_err(ResumeError::Psyche)
}

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

    // psyche_resume templates that record their resume input into a marker file
    // in the cwd (the nested perch dir).
    #[cfg(windows)]
    const RESUME_SID: &str = "cmd /C echo resumed={session_id} > resumed.txt";
    #[cfg(unix)]
    const RESUME_SID: &str = "sh -c \"echo resumed={session_id} > resumed.txt\"";
    #[cfg(windows)]
    const PRELOAD_CTX: &str = "cmd /C echo preload={psyche_context} > preload.txt";
    #[cfg(unix)]
    const PRELOAD_CTX: &str = "sh -c \"echo preload={psyche_context} > preload.txt\"";

    fn resume_manifest(cmd: &str) -> Manifest {
        let toml = format!(
            "[adapter]\nname=\"mock\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
             [session.psyche_resume]\ncommand=\"{cmd}\"\ncwd=\"{{psyche_dir}}\"\n"
        );
        Manifest::from_toml_str(&toml).unwrap()
    }

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

    fn wait_marker(path: &std::path::Path) -> String {
        let start = std::time::Instant::now();
        loop {
            if let Ok(s) = std::fs::read_to_string(path) {
                if !s.trim().is_empty() {
                    return s;
                }
            }
            assert!(start.elapsed() < std::time::Duration::from_secs(5), "marker never written");
            std::thread::sleep(std::time::Duration::from_millis(20));
        }
    }

    #[test] // [unit->REQ-SEAM-RESUME] continue-existing re-enters an existing session.
    fn continue_existing_resumes_session() {
        with_home(|_| {
            let rt = ManifestRuntime::new(resume_manifest(RESUME_SID));
            let h = resume_psyche(
                &rt,
                "doyle",
                ResumeMode::ContinueExisting,
                &keys(&[("session_id", "sid-42")]),
                None,
                "t0",
            )
            .expect("resume");
            assert_eq!(h.id, "doyle-psyche");
            let marker = wait_marker(&h.perch_path.join("resumed.txt"));
            assert!(marker.contains("sid-42"), "session id not resumed: {marker:?}");
        });
    }

    #[test] // [unit->REQ-SEAM-RESUME] fresh-with-preload launches cleared + preloaded.
    fn fresh_with_preload_launches_preloaded() {
        with_home(|_| {
            let rt = ManifestRuntime::new(resume_manifest(PRELOAD_CTX));
            let h = resume_psyche(
                &rt,
                "doyle",
                ResumeMode::FreshWithPreload,
                &keys(&[("psyche_context", "PRIORCTX")]),
                None,
                "t0",
            )
            .expect("resume");
            let marker = wait_marker(&h.perch_path.join("preload.txt"));
            assert!(marker.contains("PRIORCTX"), "context not preloaded: {marker:?}");
        });
    }

    #[test] // [unit->REQ-SEAM-RESUME] a missing required key errors BEFORE spawn.
    fn missing_key_errors_before_spawn() {
        with_home(|_| {
            let rt = ManifestRuntime::new(resume_manifest(RESUME_SID));
            // ContinueExisting with no session_id.
            let e = resume_psyche(
                &rt,
                "doyle",
                ResumeMode::ContinueExisting,
                &keys(&[]),
                None,
                "t0",
            )
            .unwrap_err();
            assert!(
                matches!(e, ResumeError::MissingResumeKey { key: "session_id", .. }),
                "got {e}"
            );
            // No perch was bound — the error preceded any spawn.
            let perch = spt_store::perch::resolve_perch_path("doyle-psyche", ParentHint::Explicit("doyle"));
            assert!(!perch.join("info.json").exists(), "must not spawn/bind on missing key");
        });
    }

    #[test] // [unit->REQ-SEAM-RESUME] the psyche-download reads the stored context.
    fn download_reads_stored_context() {
        with_home(|_| {
            assert!(download_psyche_context("doyle", ParentHint::Infer).is_none());
            let ctx = context_path("doyle", ParentHint::Infer);
            std::fs::create_dir_all(ctx.parent().unwrap()).unwrap();
            std::fs::write(&ctx, "stored mind").unwrap();
            assert_eq!(
                download_psyche_context("doyle", ParentHint::Infer).as_deref(),
                Some("stored mind")
            );
        });
    }
}


File created successfully at: C:\Users\decid\Documents\projects\spt-core\crates\spt-live\src\resume.rs (file state is current in your context — no need to Read it back)

//! resume-session seam (M2b T7) — re-entry into a Psyche's mind.
//!
//! Two distinct forms (CONTEXT §resume-session seam), both spawned from
//! `[session.psyche_resume]`:
//! - **continue-existing** — resume an existing harness session under the
//!   adapter (its native `--resume {session_id}`). Requires `session_id`.
//! - **fresh-with-preload** — resume with *cleared* context (a fresh session)
//!   plus the **psyche-download**: the prior context, preloaded via the
//!   `{psyche_context}` key (or the adapter pulls it in its SessionStart hook).
//!   Requires `psyche_context`.
//!
//! The required key for the chosen mode is validated **before** spawn — a
//! wrong/missing key errors cleanly, never a half-launched session
//! (REQ-SEAM-RESUME). Both wire into the spawn path and the boundary re-entry
//! (T6 authors the resume commune; this preloads it).
//
// Clean-room over the M2a spawn seam; the native-resume mechanics live in the
// adapter's opaque psyche_resume template (model + --resume flag), never here.
// [impl->REQ-SEAM-RESUME]

use crate::ingest::context_path;
use crate::psyche::{spawn_and_bind, PsycheError, PsycheHandle};
use spt_store::perch::ParentHint;
use std::collections::BTreeMap;

/// The role spawned for a Psyche re-entry.
pub const PSYCHE_RESUME_ROLE: &str = "psyche_resume";

/// Which resume form to perform.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResumeMode {
    /// Native resume of an existing harness session (`{session_id}`).
    ContinueExisting,
    /// Fresh cleared session preloaded with the psyche-download (`{psyche_context}`).
    FreshWithPreload,
}

impl ResumeMode {
    /// The substitution key this mode *requires* the caller to have supplied.
    fn required_key(self) -> &'static str {
        match self {
            ResumeMode::ContinueExisting => "session_id",
            ResumeMode::FreshWithPreload => "psyche_context",
        }
    }
}

/// A resume failure.
#[derive(Debug)]
pub enum ResumeError {
    /// The required key for the chosen mode is missing/empty — caught *before*
    /// any spawn.
    MissingResumeKey {
        mode: ResumeMode,
        key: &'static str,
    },
    /// Spawn / bind failure from the Psyche core.
    Psyche(PsycheError),
}

impl std::fmt::Display for ResumeError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            ResumeError::MissingResumeKey { mode, key } => {
                write!(f, "resume mode {mode:?} requires key `{key}` (missing before spawn)")
            }
            ResumeError::Psyche(e) => write!(f, "resume spawn failed: {e}"),
        }
    }
}

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

/// Read the perch's stored context — the **psyche-download** a fresh-with-preload
/// resume feeds back in via `{psyche_context}`. `None` if nothing is stored yet.
pub fn download_psyche_context(id: &str, hint: ParentHint<'_>) -> Option<String> {
    std::fs::read_to_string(context_path(id, hint)).ok()
}

/// Resume a Psyche for `parent_id` in the chosen `mode`. The mode's required key
/// is validated against `keys` **before** spawn; on success the `psyche_resume`
/// role is spawned and the nested perch rebound.
pub fn resume_psyche(
    runtime: &dyn spt_runtime::AgentRuntime,
    parent_id: &str,
    mode: ResumeMode,
    keys: &BTreeMap<String, String>,
    event: Option<&str>,
    now: &str,
) -> Result<PsycheHandle, ResumeError> {
    // Validate the mode's required key BEFORE any spawn (REQ-SEAM-RESUME).
    let required = mode.required_key();
    if keys.get(required).map(String::as_str).unwrap_or("").is_empty() {
        return Err(ResumeError::MissingResumeKey {
            mode,
            key: required,
        });
    }
    spawn_and_bind(runtime, PSYCHE_RESUME_ROLE, parent_id, keys, event, now)
        .map_err(ResumeError::Psyche)
}

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

    // psyche_resume templates that record their resume input into a marker file
    // in the cwd (the nested perch dir).
    #[cfg(windows)]
    const RESUME_SID: &str = "cmd /C echo resumed={session_id} > resumed.txt";
    #[cfg(unix)]
    const RESUME_SID: &str = "sh -c \"echo resumed={session_id} > resumed.txt\"";
    #[cfg(windows)]
    const PRELOAD_CTX: &str = "cmd /C echo preload={psyche_context} > preload.txt";
    #[cfg(unix)]
    const PRELOAD_CTX: &str = "sh -c \"echo preload={psyche_context} > preload.txt\"";

    fn resume_manifest(cmd: &str) -> Manifest {
        let toml = format!(
            "[adapter]\nname=\"mock\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
             [session.psyche_resume]\ncommand=\"{cmd}\"\ncwd=\"{{psyche_dir}}\"\n"
        );
        Manifest::from_toml_str(&toml).unwrap()
    }

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

    fn wait_marker(path: &std::path::Path) -> String {
        let start = std::time::Instant::now();
        loop {
            if let Ok(s) = std::fs::read_to_string(path) {
                if !s.trim().is_empty() {
                    return s;
                }
            }
            assert!(start.elapsed() < std::time::Duration::from_secs(5), "marker never written");
            std::thread::sleep(std::time::Duration::from_millis(20));
        }
    }

    #[test] // [unit->REQ-SEAM-RESUME] continue-existing re-enters an existing session.
    fn continue_existing_resumes_session() {
        with_home(|_| {
            let rt = ManifestRuntime::new(resume_manifest(RESUME_SID));
            let h = resume_psyche(
                &rt,
                "doyle",
                ResumeMode::ContinueExisting,
                &keys(&[("session_id", "sid-42")]),
                None,
                "t0",
            )
            .expect("resume");
            assert_eq!(h.id, "doyle-psyche");
            let marker = wait_marker(&h.perch_path.join("resumed.txt"));
            assert!(marker.contains("sid-42"), "session id not resumed: {marker:?}");
        });
    }

    #[test] // [unit->REQ-SEAM-RESUME] fresh-with-preload launches cleared + preloaded.
    fn fresh_with_preload_launches_preloaded() {
        with_home(|_| {
            let rt = ManifestRuntime::new(resume_manifest(PRELOAD_CTX));
            let h = resume_psyche(
                &rt,
                "doyle",
                ResumeMode::FreshWithPreload,
                &keys(&[("psyche_context", "PRIORCTX")]),
                None,
                "t0",
            )
            .expect("resume");
            let marker = wait_marker(&h.perch_path.join("preload.txt"));
            assert!(marker.contains("PRIORCTX"), "context not preloaded: {marker:?}");
        });
    }

    #[test] // [unit->REQ-SEAM-RESUME] a missing required key errors BEFORE spawn.
    fn missing_key_errors_before_spawn() {
        with_home(|_| {
            let rt = ManifestRuntime::new(resume_manifest(RESUME_SID));
            // ContinueExisting with no session_id.
            let e = resume_psyche(
                &rt,
                "doyle",
                ResumeMode::ContinueExisting,
                &keys(&[]),
                None,
                "t0",
            )
            .unwrap_err();
            assert!(
                matches!(e, ResumeError::MissingResumeKey { key: "session_id", .. }),
                "got {e}"
            );
            // No perch was bound — the error preceded any spawn.
            let perch = spt_store::perch::resolve_perch_path("doyle-psyche", ParentHint::Explicit("doyle"));
            assert!(!perch.join("info.json").exists(), "must not spawn/bind on missing key");
        });
    }

    #[test] // [unit->REQ-SEAM-RESUME] the psyche-download reads the stored context.
    fn download_reads_stored_context() {
        with_home(|_| {
            assert!(download_psyche_context("doyle", ParentHint::Infer).is_none());
            let ctx = context_path("doyle", ParentHint::Infer);
            std::fs::create_dir_all(ctx.parent().unwrap()).unwrap();
            std::fs::write(&ctx, "stored mind").unwrap();
            assert_eq!(
                download_psyche_context("doyle", ParentHint::Infer).as_deref(),
                Some("stored mind")
            );
        });
    }
}


Edit

pub mod psyche;
pub mod pulse;
pub mod signoff;

#[cfg(test)]
mod test_support;

pub use context::{write_context, WriteOutcome, WriteSource};
pub use echo::{run_echo_commune, stamp_provenance, EchoError, EchoResult};
pub use history::{fetch_history, HistoryError, HistoryRecord};
pub use ingest::{ingest_drops, DropKind, Ingested};
pub use psyche::{compose_psyche_prompt, spawn_psyche, PsycheError, PsycheHandle};
pub use pulse::{tick, PulseConfig, TickOutcome};

pub mod psyche;
pub mod pulse;
pub mod resume;
pub mod signoff;

#[cfg(test)]
mod test_support;

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


pub mod psyche;
pub mod pulse;
pub mod resume;
pub mod signoff;

#[cfg(test)]
mod test_support;

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

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

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

#[cfg(test)]
mod test_support;

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

pub mod ingest;
pub mod psyche;
pub mod pulse;
+pub mod resume;
pub mod signoff;
#[cfg(test)]

+pub use resume::{download_psyche_context, resume_psyche, ResumeError, ResumeMode};


Bash

cargo test -p spt-live -p spt-runtime 2>&1 | grep -E "test result|error\[|FAILED|warning:" | grep -v "0 passed; 0 failed" | head

Test spt-live + runtime with resume


test result: ok. 35 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.31s
test result: ok. 17 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.32s


35 green. Activate REQ-SEAM-RESUME, clippy + check, commit T7.


Edit

id = "REQ-SEAM-RESUME"
title = "resume-session seam (fresh-with-preload / continue-existing)"
required_stages = []

id = "REQ-SEAM-RESUME"
title = "resume-session seam (fresh-with-preload / continue-existing)"
required_stages = ["impl", "unit"]


id = "REQ-SEAM-RESUME"
title = "resume-session seam (fresh-with-preload / continue-existing)"
required_stages = []

id = "REQ-SEAM-RESUME"
title = "resume-session seam (fresh-with-preload / continue-existing)"
required_stages = ["impl", "unit"]

title = "Hostable endpoint-types capability declaration"


Bash

cargo clippy --workspace --all-targets -- -D warnings 2>&1 | tail -3; echo "=== TRACE ==="; traceable-reqs check > /dev/null 2>&1; echo "check_exit=$?"

Workspace clippy + check


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


git add -A && git commit -q -F - <<'EOF'
feat(spt-live): M2b T7 — resume-session seam (continue-existing / fresh-preload)

resume_psyche re-enters a Psyche's mind from [session.psyche_resume] in two
forms (CONTEXT §resume-session seam):
- continue-existing: native --resume {session_id} of an existing session;
- fresh-with-preload: a cleared session preloaded with the psyche-download via
  {psyche_context} (download_psyche_context reads the stored context store —
  the same context.md the boundary resume commune (T6) and ingest (T4) write).

The mode's required key (session_id / psyche_context) is validated BEFORE any
spawn, so a wrong/missing key errors cleanly with no half-launched session.
Adds psyche_context to the runtime substitution-key catalog. psyche.rs factors
its spawn+bind into a shared spawn_and_bind core reused by init and resume; the
native-resume mechanics stay in the adapter's opaque template.

4 unit tests: continue-existing resumes the session id, fresh-with-preload
launches preloaded, a missing key errors before spawn (asserts no perch bound),
and the psyche-download reads stored context. REQ-SEAM-RESUME activated
[impl,unit].

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

Commit T7


warning: in the working copy of 'crates/spt-live/src/lib.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-live/src/psyche.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-runtime/src/runtime.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'traceable-reqs.toml', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-live/src/resume.rs', LF will be replaced by CRLF the next time Git touches it
5f1a344 feat(spt-live): M2b T7 — resume-session seam (continue-existing / fresh-preload)


8 shipped — all seams built. T8 wires spt-live into the `api listen` runner. Read cmd_listen.


95	    seed::remove_seed(parent_pid); // consumed; refreshed on the next SessionStart
96	    Ok(token)
97	}
98
99	/// Write the perch record (skeleton→live) + ready marker + capability token.
100	/// Reviving an existing perch keeps its token; a live perch held under a
101	/// different session id is a conflict (pid-recycle / id collision guard).
102	fn establish_perch(id: &str, session_id: &str, parent_pid: u32) -> Result<String, BindError> {
103	    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
104
105	    if let Some(existing) = info::read_info(&perch_path) {
106	        let owner_alive = info::read_pid(&perch_path)
107	            .map(proc::is_process_alive)
108	            .unwrap_or(false);
109	        if owner_alive
110	            && !existing.session_id.is_empty()
111	            && existing.session_id != session_id
112	        {
113	            return Err(BindError::Conflict {
114	                id: id.to_string(),
115	                held: existing.session_id,
116	            });
117	        }
118	    }
119
120	    std::fs::create_dir_all(&perch_path).map_err(|e| BindError::Io(e.to_string()))?;
121	    let mut rec = InfoJson::new(id, &now_stamp(), std::process::id(), session_id, "live_agent");
122	    rec.parent_pid = Some(parent_pid);
123	    info::write_info(&perch_path, &rec).map_err(|e| BindError::Io(e.to_string()))?;
124	    std::fs::write(perch::resolve_ready_file(id, ParentHint::Infer), "")
125	        .map_err(|e| BindError::Io(e.to_string()))?;
126
127	    // Keep an existing token across a revive; mint one for a fresh perch.
128	    let token = auth::read_token(&perch_path).unwrap_or_else(auth::mint_token);
129	    auth::write_token(&perch_path, &token).map_err(|e| BindError::Io(e.to_string()))?;
130	    Ok(token)
131	}
132
133	/// `api listen <id>` — consume the seed, bind the perch, then run the relay
134	/// loop (heir to `$LIVE start`'s Monitor-bound poll loop). `--once` drains and
135	/// exits (testability); otherwise blocks streaming messages to stdout.
136	// [impl->REQ-START-2]
137	pub fn cmd_listen(id: &str, parent_pid_override: Option<u32>, once: bool) -> i32 {
138	    let parent_pid = match parent_pid_override.or_else(proc::parent_pid) {
139	        Some(p) => p,
140	        None => {
141	            eprintln!("NO_PARENT_PID: could not resolve the parent pid anchor");
142	            return EXIT_REFUSED;
143	        }
144	    };
145	    let token = match bind_from_seed(id, parent_pid) {
146	        Ok(t) => t,
147	        Err(e) => {
148	            report_bind_error(&e);
149	            return EXIT_REFUSED;
150	        }
151	    };
152	    eprintln!("BOUND:{id} token={token}");
153
154	    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
155	    let listener = match PollListener::bind_blocking(id, RELAY_POLL) {
156	        Ok(l) => l,
157	        Err(e) => {
158	            eprintln!("LISTEN_FAIL:{id}: {e}");
159	            return EXIT_REFUSED;
160	        }
161	    };
162	    eprintln!("READY:{id}");
163
164	    // Drain the offline backlog first (non-deferred only; deferred rows wait
165	    // for the hook channel).
166	    for msg in spool::drain_non_deferred_at(&perch_path).unwrap_or_default() {
167	        emit(&msg);
168	    }
169	    if once {
170	        if let Some(msg) = listener.check_message_blocking() {
171	            emit(&msg);
172	        }
173	        for msg in spool::drain_non_deferred_at(&perch_path).unwrap_or_default() {
174	            emit(&msg);
175	        }
176	        return 0;
177	    }
178	    loop {
179	        if let Some(msg) = listener.check_message_blocking() {
180	            emit(&msg);
181	        }
182	        for msg in spool::drain_non_deferred_at(&perch_path).unwrap_or_default() {
183	            emit(&msg);
184	        }
185	    }
186	}
187
188	/// `api bind <id>` — post-spawn boot bind (REQ-SEAM-POSTSPAWN). Establishes the
189	/// live perch on first contact, or flips an existing skeleton, keyed to the
190	/// session id the binary reports. Auth is intrinsic: an existing *live* perch
191	/// under a different session id is refused (you can only bind your own).
192	// [impl->REQ-SEAM-POSTSPAWN]
193	pub fn cmd_bind(id: &str, set_session_id: Option<String>, proof_session: Option<String>) -> i32 {
194	    let session_id = set_session_id

    seed::remove_seed(parent_pid); // consumed; refreshed on the next SessionStart
    Ok(token)
}

/// Write the perch record (skeleton→live) + ready marker + capability token.
/// Reviving an existing perch keeps its token; a live perch held under a
/// different session id is a conflict (pid-recycle / id collision guard).
fn establish_perch(id: &str, session_id: &str, parent_pid: u32) -> Result<String, BindError> {
    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);

    if let Some(existing) = info::read_info(&perch_path) {
        let owner_alive = info::read_pid(&perch_path)
            .map(proc::is_process_alive)
            .unwrap_or(false);
        if owner_alive
            && !existing.session_id.is_empty()
            && existing.session_id != session_id
        {
            return Err(BindError::Conflict {
                id: id.to_string(),
                held: existing.session_id,
            });
        }
    }

    std::fs::create_dir_all(&perch_path).map_err(|e| BindError::Io(e.to_string()))?;
    let mut rec = InfoJson::new(id, &now_stamp(), std::process::id(), session_id, "live_agent");
    rec.parent_pid = Some(parent_pid);
    info::write_info(&perch_path, &rec).map_err(|e| BindError::Io(e.to_string()))?;
    std::fs::write(perch::resolve_ready_file(id, ParentHint::Infer), "")
        .map_err(|e| BindError::Io(e.to_string()))?;

    // Keep an existing token across a revive; mint one for a fresh perch.
    let token = auth::read_token(&perch_path).unwrap_or_else(auth::mint_token);
    auth::write_token(&perch_path, &token).map_err(|e| BindError::Io(e.to_string()))?;
    Ok(token)
}

/// `api listen <id>` — consume the seed, bind the perch, then run the relay
/// loop (heir to `$LIVE start`'s Monitor-bound poll loop). `--once` drains and
/// exits (testability); otherwise blocks streaming messages to stdout.
// [impl->REQ-START-2]
pub fn cmd_listen(id: &str, parent_pid_override: Option<u32>, once: bool) -> i32 {
    let parent_pid = match parent_pid_override.or_else(proc::parent_pid) {
        Some(p) => p,
        None => {
            eprintln!("NO_PARENT_PID: could not resolve the parent pid anchor");
            return EXIT_REFUSED;
        }
    };
    let token = match bind_from_seed(id, parent_pid) {
        Ok(t) => t,
        Err(e) => {
            report_bind_error(&e);
            return EXIT_REFUSED;
        }
    };
    eprintln!("BOUND:{id} token={token}");

    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
    let listener = match PollListener::bind_blocking(id, RELAY_POLL) {
        Ok(l) => l,
        Err(e) => {
            eprintln!("LISTEN_FAIL:{id}: {e}");
            return EXIT_REFUSED;
        }
    };
    eprintln!("READY:{id}");

    // Drain the offline backlog first (non-deferred only; deferred rows wait
    // for the hook channel).
    for msg in spool::drain_non_deferred_at(&perch_path).unwrap_or_default() {
        emit(&msg);
    }
    if once {
        if let Some(msg) = listener.check_message_blocking() {
            emit(&msg);
        }
        for msg in spool::drain_non_deferred_at(&perch_path).unwrap_or_default() {
            emit(&msg);
        }
        return 0;
    }
    loop {
        if let Some(msg) = listener.check_message_blocking() {
            emit(&msg);
        }
        for msg in spool::drain_non_deferred_at(&perch_path).unwrap_or_default() {
            emit(&msg);
        }
    }
}

/// `api bind <id>` — post-spawn boot bind (REQ-SEAM-POSTSPAWN). Establishes the
/// live perch on first contact, or flips an existing skeleton, keyed to the
/// session id the binary reports. Auth is intrinsic: an existing *live* perch
/// under a different session id is refused (you can only bind your own).
// [impl->REQ-SEAM-POSTSPAWN]
pub fn cmd_bind(id: &str, set_session_id: Option<String>, proof_session: Option<String>) -> i32 {
    let session_id = set_session_id


How does the manifest reach the runner? Check Ctx + listen dispatch.


grep -n "struct Ctx\|manifest\|Manifest\|fn cmd_listen\|Listen\|adapter" crates/spt/src/api/mod.rs | head -30

Find Ctx + manifest loading + listen dispatch


3://! `--adapter <name>` (the `adapter_name`), and every *mutation* is gated by
13://! (auth refused, no endpoint, bad manifest) · `2` usage (clap).
26:use spt_runtime::Manifest;
33:/// The `api` command group. `--adapter` is required on every invocation; an
34:/// optional `--manifest` is parsed for commands that need the adapter contract
38:    /// adapter_name — the calling harness adapter. Required on every `api` call.
40:    adapter: String,
41:    /// Path to the adapter's runtime manifest (when the command needs it).
43:    manifest: Option<PathBuf>,
60:    Listen {
162:    /// Print the adapter's declared capability (`hostable_types`) (T7).
184:pub struct Ctx {
185:    /// The `adapter_name` carried on this call.
186:    pub adapter: String,
187:    /// Parsed manifest, when `--manifest` was supplied (and valid).
188:    pub manifest: Option<Manifest>,
193:    // Resolve the manifest up front so a bad path/content fails fast and
195:    let manifest = match &args.manifest {
196:        Some(path) => match load_manifest(path, &args.adapter) {
202:    let ctx = Ctx { adapter: args.adapter, manifest };
209:        ApiCmd::Listen { id, parent_pid, once } => startup::cmd_listen(&id, parent_pid, once),
221:            delivery::cmd_poll(id, include_deferred, ctx.manifest.as_ref())
246:/// Load + validate a manifest, checking it matches the declared adapter_name.
248:fn load_manifest(path: &PathBuf, adapter: &str) -> Result<Manifest, i32> {
253:    let manifest = Manifest::from_toml_str(&text).map_err(|e| {
257:    if manifest.adapter.name != adapter {
259:            "ADAPTER_MISMATCH: --adapter {adapter} but manifest declares {}",
260:            manifest.adapter.name
264:    Ok(manifest)
300:    #[test] // [unit->REQ-API-1] adapter_name is mandatory on every api call.


T8 wires the lifecycle into the spt binary. Add dep, study contract_e2e style + manifest drop-dir resolution.


1	[package]
2	name = "spt"
3	version = "0.0.0"
4	edition = "2021"
5	publish = false
6	description = "spt-core CLI: the single `spt` binary. Thin command layer over spt-msg (send/ring/ready/poll/list/stop/whoami) + the harness-contract `api` surface (spt-runtime)."
7
8	[[bin]]
9	name = "spt"
10	path = "src/main.rs"
11
12	[dependencies]
13	spt-proto = { path = "../spt-proto" }
14	spt-store = { path = "../spt-store" }
15	spt-msg = { path = "../spt-msg" }
16	spt-runtime = { path = "../spt-runtime" }
17	clap = { version = "4", features = ["derive"] }
18
19	[dev-dependencies]
20	tempfile = "3"
21

[package]
name = "spt"
version = "0.0.0"
edition = "2021"
publish = false
description = "spt-core CLI: the single `spt` binary. Thin command layer over spt-msg (send/ring/ready/poll/list/stop/whoami) + the harness-contract `api` surface (spt-runtime)."

[[bin]]
name = "spt"
path = "src/main.rs"

[dependencies]
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
spt-runtime = { path = "../spt-runtime" }
clap = { version = "4", features = ["derive"] }

[dev-dependencies]
tempfile = "3"


160	    0
161	}
162
163	/// The commune/signoff **file-drop** paths — proof these are file-drops, not
164	/// commands (REQ-API-3). Dir comes from the manifest's `[session]` watched
165	/// dirs; the filename is spt-core-fixed (`<id>-commune.md` / `<id>-signoff.md`).
166	/// M2a resolves them; M2b ingests them.
167	// [impl->REQ-API-3]
168	#[allow(dead_code)] // M2a declares the file-drop contract; M2b's ingest consumes it
169	pub fn resolve_filedrops(manifest: Option<&Manifest>, id: &str) -> (Option<PathBuf>, Option<PathBuf>) {
170	    let session = manifest.map(|m| &m.session);
171	    let commune = session
172	        .and_then(|s| s.commune_dir.as_ref())
173	        .map(|dir| PathBuf::from(dir).join(format!("{id}-commune.md")));
174	    let signoff = session
175	        .and_then(|s| s.signoff_dir.as_ref())
176	        .map(|dir| PathBuf::from(dir).join(format!("{id}-signoff.md")));
177	    (commune, signoff)
178	}
179

    0
}

/// The commune/signoff **file-drop** paths — proof these are file-drops, not
/// commands (REQ-API-3). Dir comes from the manifest's `[session]` watched
/// dirs; the filename is spt-core-fixed (`<id>-commune.md` / `<id>-signoff.md`).
/// M2a resolves them; M2b ingests them.
// [impl->REQ-API-3]
#[allow(dead_code)] // M2a declares the file-drop contract; M2b's ingest consumes it
pub fn resolve_filedrops(manifest: Option<&Manifest>, id: &str) -> (Option<PathBuf>, Option<PathBuf>) {
    let session = manifest.map(|m| &m.session);
    let commune = session
        .and_then(|s| s.commune_dir.as_ref())
        .map(|dir| PathBuf::from(dir).join(format!("{id}-commune.md")));
    let signoff = session
        .and_then(|s| s.signoff_dir.as_ref())
        .map(|dir| PathBuf::from(dir).join(format!("{id}-signoff.md")));
    (commune, signoff)
}


Add spt-live dep, then build `api/live.rs` as the lifecycle host.


Edit

spt-runtime = { path = "../spt-runtime" }
clap = { version = "4", features = ["derive"] }

spt-runtime = { path = "../spt-runtime" }
spt-live = { path = "../spt-live" }
clap = { version = "4", features = ["derive"] }


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

spt-runtime = { path = "../spt-runtime" }
spt-live = { path = "../spt-live" }
clap = { version = "4", features = ["derive"] }

[package]
name = "spt"
version = "0.0.0"
edition = "2021"
publish = false
description = "spt-core CLI: the single `spt` binary. Thin command layer over spt-msg (send/ring/ready/poll/list/stop/whoami) + the harness-contract `api` surface (spt-runtime)."

[[bin]]
name = "spt"
path = "src/main.rs"

[dependencies]
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
spt-runtime = { path = "../spt-runtime" }
clap = { version = "4", features = ["derive"] }

[dev-dependencies]
tempfile = "3"


Bash

grep -n "^mod \|^pub mod \|mod auth\|mod delivery\|mod startup\|mod worker\|mod reporting" crates/spt/src/api/mod.rs | head

Find submodule declarations in api/mod.rs


17:pub mod auth;
18:pub mod delivery;
19:pub mod reporting;
20:pub mod startup;
21:pub mod worker;
285:mod tests {


T0 — Scaffold spt-live crate

T1 — History subsystem

T2 — spawn-psyche seam

T3 — echo-commune seam

T4 — drop-file ingest

T5 — pulse heartbeat

T6 — signoff + boundary lifecycle

T7 — resume-session seam

T8 — api listen lifecycle wiring

T9 — LiveAgent E2E

T10 — Activation sweep


Updated task #9 status


Write

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


Wire the module + integrate into `cmd_listen`. First declare module + update dispatch.


Edit

pub mod auth;
pub mod delivery;
pub mod reporting;

pub mod auth;
pub mod delivery;
pub mod live;
pub mod reporting;


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


17	pub mod auth;
18	pub mod delivery;
19	pub mod reporting;
20	pub mod startup;
21	pub mod worker;

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


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

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


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

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

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

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


207	        // Harness-hosted startup (T4). Seed establishes; listen consumes+binds;
208	        // bind is the post-spawn establishing call (intrinsic auth).
209	        ApiCmd::Seed { pid, session_id } => startup::cmd_seed(&ctx, pid, session_id),
210	        ApiCmd::Listen { id, parent_pid, once } => startup::cmd_listen(&id, parent_pid, once),
211	        ApiCmd::Bind { id, bind_session, auth } => {

        // Harness-hosted startup (T4). Seed establishes; listen consumes+binds;
        // bind is the post-spawn establishing call (intrinsic auth).
        ApiCmd::Seed { pid, session_id } => startup::cmd_seed(&ctx, pid, session_id),
        ApiCmd::Listen { id, parent_pid, once } => startup::cmd_listen(&id, parent_pid, once),
        ApiCmd::Bind { id, bind_session, auth } => {


ApiCmd::Listen { id, parent_pid, once } => startup::cmd_listen(&id, parent_pid, once),

        ApiCmd::Listen { id, parent_pid, once } => {
            startup::cmd_listen(&id, parent_pid, once, ctx.manifest.as_ref())
        }


        ApiCmd::Listen { id, parent_pid, once } => {
            startup::cmd_listen(&id, parent_pid, once, ctx.manifest.as_ref())
        }

// Harness-hosted startup (T4). Seed establishes; listen consumes+binds;

// bind is the post-spawn establishing call (intrinsic auth).

ApiCmd::Seed { pid, session_id } => startup::cmd_seed(&ctx, pid, session_id),

-        ApiCmd::Listen { id, parent_pid, once } => startup::cmd_listen(&id, parent_pid, once),

+        ApiCmd::Listen { id, parent_pid, once } => {

+            startup::cmd_listen(&id, parent_pid, once, ctx.manifest.as_ref())

ApiCmd::Bind { id, bind_session, auth } => {


Now update `cmd_listen` to host the lifecycle: