17:The reference binary built from the workspace. Replaces today's `owl.exe`. Most external integrations (plugins, hooks, scripts in other harnesses) interact with spt-core *only* through this binary — fire-and-forget subcommands, long-running listeners under a parent harness's process supervisor, etc. Unix builds use the same name without `.exe`.
23:A rebuilt version of today's Claude Code `spt` plugin. It is the **first consumer** built *atop* spt-core and the **acceptance proof** of spt-core v1 (it reaches feature parity with modern SPT while delegating all core functionality to spt-core, primarily via `spt.exe`, with deeper hooks where useful) — but it **lives and builds in its own repository, outside spt-core**. It is a Claude-Code-specific *adapter*: it holds the Claude Code conventions (hooks, slash-commands, skill/plugin layout, `claude` session-invocation). **spt-core itself contains zero Claude Code conventions** — only the harness-agnostic contract the plugin binds to. The only adapter-shaped artifact ever in this repo is a generic mock/test adapter exercising the manifest + `api` contract (PRD R-DOCS-2). The M2 milestone delivers that contract + the lifecycle primitives; building the plugin is downstream work, not M2.
26:The single always-on, one-per-machine logical supervisor. Owns the PTYs for all hosted sessions, the node's network identity + WAN endpoint, the subnet registry, all spools, **all poll-listener logic, and all Psyche/pulse loops** — everything is consolidated here (no separate poll-listener or Psyche-wrapper processes; listeners already touch sessions directly under capsule/idle, and Psyche wrappers already invoke harness binaries directly, so they belong in the one supervisor). Collapses what the sister project planned as a separate `spt-node` daemon into one process — see Networking. The `spt-node` separate-deliverable concept is retired.
29:- **broker** (stable "kernel") — holds *only* the un-transferable, must-not-die resources: PTY master fds, the spawned harness child processes, and listening network sockets. Minimal, dumb, versioned local IPC. Almost never updates.
35:A thin, stateless `spt.exe` task that exists only in **harness-hosted** sessions (where the agent harness is the parent process and spt cannot reach into its process tree — today's Monitor model). It streams the daemon brain's events into the session's stdout. All *stateful* listener logic lives in the daemon; the relay is a dumb pipe, freely killable and respawnable. **spt-hosted** sessions need no relay — the daemon injects events straight into the PTY.
42:- **`spt.exe` / `spt`** — the canonical binary, built from the workspace. The primary integration path for harness plugins and external tooling, which mostly fire it as a subprocess at various surfaces (one-shot commands, poll listeners under a Monitor-tool-equivalent, hook tap-ins).
54:The full surface a harness binds to in order to participate in the spt-core ecosystem. Has two equally-important halves: the **runtime manifest** (outbound — how spt-core drives the harness) and the **subcommand surface** (inbound — how the harness reports events back to spt-core). A harness implementation is one TOML/YAML manifest + a binding from the harness's own hook system into `spt.exe <subcommand>` calls.
62:The stable set of `spt.exe <subcommand>` entry points that harnesses bind their own hook systems to. When the harness's runtime emits an event (subagent started, tool just invoked, user typed `/clear`, session crashed), the harness's hook fires a short-lived `spt.exe <subcommand>` invocation that mutates on-disk SPT state (perch registry, spool, etc.). spt-core publishes this surface; harnesses author the bindings.
64:**Naming convention:** these inbound, machinery-facing commands are prefixed **`api `/`api-`** (e.g. `spt api bind`, `spt api state`) to distinguish them from the agent-facing verbs an agent invokes directly (`send`, `ring`, `ready`, …). The `api` namespace is the harness/adapter commands-API; the unprefixed namespace is the agent surface.
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).
84:**post-spawn seam** — the just-launched binary calls an spt-core command on boot (via the adapter's SessionStart-equivalent hook) to bind itself. Needed because the harness's own session id usually isn't known until after the binary runs. Payload: the harness `session_id` (when binary-generated rather than spt-core-injected); the `parent_pid` (the stable session-binding anchor — see KNOWN-HAZARDS 2.1); an endpoint identity/type confirmation; optionally a local HTTP port the binary listens on (for HTTP-mode input delivery, below); and a **boot nonce** (a generation/boot discriminator so a respawn-after-crash bind can't be confused with a stale duplicate — guards KNOWN-HAZARDS 2.4). The call flips the perch from skeleton → live.
86:**post-spawn is optional only under a strict commitment:** an adapter may forgo post-spawn *only* if it (a) injects the spt-core-generated session UUID at spawn AND (b) guarantees the launched-process pid IS the stable session-binding anchor (no wrapper-script / subprocess pid indirection). If either does not hold, post-spawn must fire to report `session_id` and/or `parent_pid`. UUID-injection alone suppresses only the `session_id` reporting, not the binding.
102:- adapter manifest requesting HTTP POST delivery to the endpoint binary on a local port (shared via the post-spawn seam).
117:- **Env injection is asymmetric** (file-bridge-only-when-not-launcher, applied to env): spt-hosted sessions inherit env from the broker that spawns them; harness-hosted sessions need the harness's declared env channel. With `spt` on PATH the env table is small.
126:- **`api bind`** — post-spawn boot bind (payload above). Skeleton→live.
127:- **`api listen`** — *long-running* relay/poll listener that an adapter-owned (harness-hosted) session owns as a child process; streams the daemon's events to the session's stdout. Distinct from the short-lived `api poll`. This is the heir to today's Monitor-bound `$LIVE start` poll loop.
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`).
136:- **`api emit --type <sensory_type> <payload>`** — a broker-launched **Shell** binary pushes a sensory payload to its owner agent (owner known from `api bind`; REST-only, never spooled). See the Shell model.
140:### Startup flows (the two topologies)
142:**Adapters never resolve `$SPT_HOME`.** spt-core install registers its binary directory on the system-wide PATH, so adapters call `spt api …` on any OS without path math. All harness↔daemon bridging goes through `spt api` commands (the daemon is always running, or auto-started — below), so there is **no adapter-written file** in the bind path.
144:**Harness-hosted (e.g. spt-plugin; the harness binary is user-launched, harness is the parent).** Key constraint: the SPT *live agent* does not exist until the agent invokes start — the `live_id` isn't chosen at session boot, and `$LIVE start` is itself invoked *behind the Monitor tool*, so it becomes the long-running relay. So binding cannot happen at SessionStart directly. A **seed record** (daemon-held, in-memory — not a file) bridges the gap:
145:1. The harness's SessionStart hook calls **`spt api seed --pid <parent_pid> [session_id, cwd, adapter_name, …]`**. The daemon records an ephemeral in-memory **seed entry** keyed by `parent_pid` — the session details the spt-hosted topology would share directly, minus the not-yet-chosen `live_id`. In-memory (not a file) avoids drive churn and the `$SPT_HOME` resolution nuisance; seeds are consumed within seconds, so persistence across a daemon restart is unnecessary (re-fired on the next SessionStart if needed).
146:2. The agent runs `/spt:live <id>` → the adapter's `$LIVE start <id>` alias = **`$SPT listen <id>`** (= `spt api listen <id>`), invoked via Monitor. It self-discovers its `parent_pid`, the daemon matches the seed entry by that pid (validated against `session_id` to defeat PID-recycling — KNOWN-HAZARDS 5.1), creates/revives the perch binding `live_id` ↔ session details, then enters the long-running relay loop streaming events to stdout.
148:   - Seed entry refreshed on each SessionStart (keeps `session_id` current across `/clear`, since `parent_pid` is stable while the harness process persists). If a harness has no SessionStart-equiv, `start` may carry the details directly as args — the seed is the preferred convenience, not the only path.
150:**Daemon auto-start:** the daemon is per-machine always-on (OS-service registered), but any `spt api` invocation that needs it will **start it if absent** (fresh boot, crash, never-installed-as-service). `$SPT listen` for the first SPT session on a machine thus transparently spins up the daemon. Ensure-running lives in the `api` layer generally; `listen` is the reliable anchor.
154:2. The binary boots and fires **`api bind`** (or skips it under the strict UUID-injection + stable-pid commitment). **No catalyst/seed file** — the daemon is the launcher, already holds a direct channel (it spawned the process and owns the PTY), so a file round-trip would only add drive churn for no benefit.
158:So the old `$LIVE start` splits by topology: harness-hosted = SessionStart writes seed → `$SPT listen <id>` consumes seed (by `parent_pid`) + binds + relays; spt-hosted = daemon spawn-session + `api bind` (direct, no file). The asymmetry is the file-bridge-only-when-no-direct-channel principle.
160:**Env-var aliases:** adapters inject clean env-var aliases for in-session invocation (heirs to today's `$OWL`/`$LIVE`), e.g. **`$SPT` = `spt api`** so a Monitor-bound call reads `$SPT listen <id>`. spt-core supplies the subcommands; the adapter supplies the env aliases (manifest philosophy).
167:Minimal SPT participant — a perch + a poll listener, no Psyche, no live-agent wrapper. Direct heir to the sister project's "ready agent".
186:- **bind** — sustained drive: "give me a Shell of capability X"; channel instantiates and the agent drives it directly until teardown. Supports operating a *specific* Shell regardless of where the user currently is (agent transience).
223:**Command-delivery method** is the shell adapter's choice (same modes as agent inject-input): HTTP REST, stdin-via-broker, or child-relay (`api poll`). The binary is always broker-launched, so binding is direct (`api bind`, no seed-file).
233:**Shell-relevant commands:** agent/user surface — `spt shell spawn <shell_adapter> [--id]`, `shell list`, `shell relink <id>`, `shell teardown <id>`, `shell cmd <id> <op…>` (or ordinary `send` of a typed command payload), and **`spt shutdown`** (an agent gracefully shuts down *its own* endpoint → suspended; fires the transition echo-commune; cascades its persistent shells offline). Machinery `api` surface — `api bind` (type=Shell), `api poll` (relay delivery), `api emit --type <sensory_type> <payload>` (sensory back to the owner; owner known from bind), and **`api owner-shutdown`** (a shell directly suspends its linked owner, bypassing agent comms — gated by `can_shutdown` in the shell manifest; the channel is this machinery command, not the content channels).
283:First network socket bind triggers an OS firewall prompt (Windows Defender, some Linux); unavoidable without a signed binary + pre-declared exception. Framed in CLI UX, not suppressible.
394:**TOTP-seeded SPAKE2 pairing**:
395:The day-one pairing model. A durable **TOTP seed** is the subnet secret: generated on the first node, shown as a QR → stored in the user's authenticator app *and* held by every already-trusted node. To pair a new node, the rotating 6-digit TOTP code is used as the **password for a SPAKE2 (PAKE) handshake** — *not* as a bearer token verified by a seed-holder. A trusted node (online, no human needed — it holds the seed) computes the current code as its PAKE password; the user reads the code off their phone and types it into the new node. Matching codes → PAKE succeeds → pubkeys exchanged and bound, MITM-resistant encrypted channel established.
398:- **Not TOTP-as-bearer-token** — a 6-digit code sent over the wire for verification is ~20 bits, replayable within its 30s window, and doesn't bind to the key being exchanged (real-time MITM/replay seam). PAKE makes the low-entropy code MITM-resistant and limits attackers to one online guess per attempt (no offline brute force).
399:- **Better UX than plain Magic Wormhole** — Wormhole needs a fresh code on one node typed into the other, requiring a human/relay at *both* ends each pairing. With the persistent TOTP seed replicated to online trusted nodes, the trusted side is automatic; **the user only ever interacts with the new node** (read phone, type into new machine).
405:Because every paired node holds the TOTP seed, the user can fetch the *current* pairing code from any already-paired machine — no phone required if a trusted node is handy. Via `spt pair --code` (prints the current code) or by asking a LiveAgent/ReadyAgent for it (the adapter supplies the agent instructions to surface it). Mild exposure note: anyone with CLI/agent access to a trusted node can read the current code — acceptable, since such access already implies a trusted position.
408:The first time two nodes pair, the user is prompted to **name the subnet**. The name is a human label on the subnet identity (which is cryptographically the shared seed). On every subsequent pairing the same name is shown ("adding node to subnet `<name>`"), giving the user confidence they're extending the right subnet as the fleet grows.
410:Design caveats (carried forward, none disqualifying): a seed-holder must be online + reachable by the untrusted new node at pairing time (relay allows pre-trust contact on a pairing ALPN); ±1 TOTP window tolerance; rate-limit handshake attempts; **recovery** = re-provision the QR from any still-trusted node (all hold the seed) — losing all nodes *and* the auth app makes the subnet unrecoverable (documented); **revocation** of a paired node is a separate trust-store delete (TOTP gates joining only).


Bash

cd "C:\Users\decid\Documents\projects\spt-core" && sed -n '1,30p' crates/spt-store/src/proc.rs; echo "===grep parent/ppid==="; grep -rni "ppid\|parent_pid\|getppid\|parent process\|NtQuery\|ParentProcess" crates/ | grep -v test | head

Check for parent-pid resolver


//! Process-liveness probe. Used by the registry to detect stale entries
//! (dead-PID rows) before resolution. Ported from sister `types.rs`
//! `is_process_alive` (ADR-0001): Windows `OpenProcess` (tasklist hangs on
//! Win11 26200+), Unix `kill(pid, 0)`.

/// Whether a process with `pid` is currently alive. Best-effort and
/// side-effect-free — sends no signal, opens then immediately closes a handle.
// [impl->REQ-HAZARD-REGISTRY-STALE-CLEAN]
pub fn is_process_alive(pid: u32) -> bool {
    #[cfg(unix)]
    {
        // kill(pid, 0) probes existence without delivering a signal.
        unsafe { libc::kill(pid as i32, 0) == 0 }
    }
    #[cfg(windows)]
    {
        // OpenProcess with the most limited query right; tasklist/WMIC hang on
        // recent Windows builds.
        extern "system" {
            fn OpenProcess(dw_desired_access: u32, b_inherit_handle: i32, dw_process_id: u32) -> isize;
            fn CloseHandle(h_object: isize) -> i32;
        }
        const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
        let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
        if handle == 0 {
            false
        } else {
            unsafe {
                CloseHandle(handle);
            }
===grep parent/ppid===
crates/spt-runtime/src/manifest.rs:481:fires = "api seed --pid {parent_pid} --session-id {session_id} --adapter {adapter_name}"
crates/spt-runtime/src/runtime.rs:40:    "parent_pid",
crates/spt-store/src/info.rs:50:    pub parent_pid: Option<u32>,
crates/spt-store/src/info.rs:65:            parent_pid: None,
crates/spt-store/src/info.rs:141:        // parent_pid omitted (None) → absent from JSON.
crates/spt-store/src/info.rs:143:        assert!(!raw.contains("parent_pid"), "None field must be omitted: {raw}");
crates/spt-store/src/info.rs:158:            parent_pid: None,


Need parent-pid resolution + the recycle hazard spec. Check both.


cd "C:\Users\decid\Documents\projects\spt-core" && grep -n "PID-RECYCLE\|pid.recycl\|5.1\|PID recycl" -i docs/KNOWN-HAZARDS.md traceable-reqs.toml | head; echo "===5.1==="; sed -n '/### 5.1/,/### 5.2/p' docs/KNOWN-HAZARDS.md

Read pid-recycle hazard


docs/KNOWN-HAZARDS.md:55:- **Sister cite:** CHANGELOG v1.35.1 "IN-05"; `hook_subagent_start.rs:40-51`.
docs/KNOWN-HAZARDS.md:70:- **Failure:** the sister evaluates Psyche/perch liveness via a dedicated process PID — the wrapper's own pid in `info.json`, checked with `is_process_alive`. Under ADR-0004 the Psyche (and any spt-hosted Self) is a **loop inside the daemon**, not a separate process: it holds no dedicated pid, and its `claude`/summarizer subprocess is ephemeral (spawned per pulse/commune, then exits). If a daemon-hosted perch's `info.json` carries the **daemon's** pid, then *every* hosted endpoint shares one pid, and `is_process_alive(pid)` reads "alive" for a torn-down endpoint as long as the daemon runs — while `clean_stale_entries` (dead-pid deletion) can no longer distinguish a dead endpoint from a live one. The 2.1/5.1 liveness models do **not** cover this third category: the Psyche is neither harness-hosted (no `parent_pid` anchor) nor a broker-held PTY child.
docs/KNOWN-HAZARDS.md:95:- **Sister cite:** `src/live/wrapper/orphan.rs:175-199`; tests A-H:333-565.
docs/KNOWN-HAZARDS.md:134:### 5.1 Windows PID recycling false positives
docs/KNOWN-HAZARDS.md:184:- **Sister cite:** CHANGELOG v1.11.20 Phase 35.1.
docs/KNOWN-HAZARDS.md:224:| 2.1/5.1 | Stable PID/broker-handle over ephemeral PID | liveness detection |
traceable-reqs.toml:211:title = "Ed25519 identity primitive: keypair, detached sign/verify, stable pubkey<->hex"
traceable-reqs.toml:404:id = "REQ-HAZARD-WINDOWS-PID-RECYCLE"
traceable-reqs.toml:405:title = "Windows PID-recycling false positives guarded (5.1)"
===5.1===
### 5.1 Windows PID recycling false positives
- **Failure:** recycled PID reads alive for the wrong process → orphan misclassification.
- **Invariant:** anchor liveness on the stable parent/harness PID; minimal info.json for supervisor-owned perches; mtime grace window (≥60s) masks transient mismatches.
- **spt-core mapping:** broker-held handles supersede PID polling for spt-hosted sessions; keep the grace window for harness-hosted.
- **Sister cite:** `src/live/wrapper/orphan.rs:141-161`; `src/common/list_filter.rs:168-175`.

### 5.2 Windows EBUSY on atomic rename


cd "C:\Users\decid\Documents\projects\spt-core" && grep -n "pub fn\|pub struct" crates/spt-msg/src/listener.rs; echo "===spool pub==="; grep -n "pub fn" crates/spt-store/src/spool.rs; echo "===ready rest==="; sed -n '80,200p' crates/spt-msg/src/ready.rs

Inspect listener + spool + ready APIs


32:pub struct PollListener {
82:    pub fn bind(id: &str) -> Result<Self, String> {
88:    pub fn bind_blocking(id: &str, timeout: Duration) -> Result<Self, String> {
123:    pub fn local_addr(&self) -> std::io::Result<SocketAddr> {
129:    pub fn check_message(&self) -> Option<String> {
139:    pub fn check_message_blocking(&self) -> Option<String> {
152:    pub fn close(&mut self) {
160:    pub fn close_busy(&mut self) {
===spool pub===
41:pub fn open_spool_at(perch_path: &Path) -> rusqlite::Result<Connection> {
88:pub fn spool_message_at(perch_path: &Path, from_id: &str, body: &str) -> rusqlite::Result<()> {
101:pub fn spool_message_deferred_at(perch_path: &Path, from_id: &str, body: &str) -> rusqlite::Result<()> {
126:pub fn drain_all_at(perch_path: &Path) -> rusqlite::Result<Vec<String>> {
133:pub fn drain_non_deferred_at(perch_path: &Path) -> rusqlite::Result<Vec<String>> {
163:pub fn drain_one_at(perch_path: &Path) -> rusqlite::Result<Option<String>> {
182:pub fn peek_all_at(perch_path: &Path) -> rusqlite::Result<Vec<(i64, String, String, i64)>> {
202:pub fn pending_count_at(perch_path: &Path) -> rusqlite::Result<usize> {
215:pub fn pending_count_non_deferred_at(perch_path: &Path) -> rusqlite::Result<usize> {
228:pub fn mark_delivered_at(perch_path: &Path, msg_ids: &[i64]) -> rusqlite::Result<()> {
===ready rest===
        ))
    }

    /// The agent's id.
    pub fn id(&self) -> &str {
        &self.id
    }

    /// Wait up to one poll window for a live TCP message. Returns `None` on
    /// timeout (call again to keep listening).
    pub fn recv(&self) -> Option<String> {
        self.listener.check_message_blocking()
    }

    /// Drain any non-deferred spool rows that accrued since startup (e.g. a
    /// sender that spooled because it raced the listener bind, or a hook-channel
    /// arrival). Oldest first.
    pub fn drain_spool(&self) -> Vec<String> {
        spool::drain_non_deferred_at(&self.perch_path).unwrap_or_default()
    }

    /// Soft teardown (6.2): remove the `ready` marker and unregister the
    /// address. The spool and `info.json` are **preserved** so a later restart
    /// resumes its backlog. Idempotent.
    // [impl->REQ-MSG-3]
    // [impl->REQ-HAZARD-SOFT-CLEANUP]
    pub fn soft_cleanup(&mut self) {
        let _ = fs::remove_file(perch::resolve_ready_file(&self.id, ParentHint::Infer));
        self.listener.close();
        self.closed = true;
    }
}

impl Drop for ReadyAgent {
    fn drop(&mut self) {
        if !self.closed {
            self.soft_cleanup();
        }
    }
}

/// Coarse creation timestamp (epoch seconds as a string). Opaque to readers —
/// `info.json` treats `started` as a producer-format string.
fn now_stamp() -> String {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs().to_string())
        .unwrap_or_else(|_| "0".to_string())
}

/// Harness session id: honor `OWL_SESSION_ID` if set, else synthesize a
/// process-unique fallback. (M2 wires the real harness session id.)
fn session_id() -> String {
    if let Ok(s) = std::env::var("OWL_SESSION_ID") {
        if !s.is_empty() {
            return s;
        }
    }
    let nanos = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_nanos())
        .unwrap_or(0);
    format!("sess-{}-{}", std::process::id(), nanos)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::deliver;

    fn unique_id(prefix: &str) -> String {
        let nanos = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_nanos())
            .unwrap_or(0);
        format!("{}-{}-{}", prefix, std::process::id(), nanos)
    }

    // [unit->REQ-MSG-3] start registers a reachable, resolvable perch.
    #[test]
    fn start_registers_resolvable_perch() {
        crate::test_home::isolate();
        let id = unique_id("test-ready-start");
        let (agent, backlog) = ReadyAgent::start(&id).unwrap();
        assert!(backlog.is_empty());

        // Resolvable + online from a sender's point of view.
        assert!(deliver::is_online(&id));
        assert!(deliver::perch_exists(&id));

        // info.json + ready marker on disk.
        let perch_path = perch::resolve_perch_path(&id, ParentHint::Infer);
        assert!(info::read_info(&perch_path).is_some());
        assert!(perch::resolve_ready_file(&id, ParentHint::Infer).exists());
        drop(agent);
    }

    // [unit->REQ-MSG-3] backlog spooled while offline drains on startup.
    #[test]
    fn backlog_drains_on_start() {
        crate::test_home::isolate();
        let owlery = perch::owlery_dir();
        let id = unique_id("test-ready-backlog");
        // Pre-create an offline perch and spool a message to it.
        let perch_path = perch::resolve_perch_path(&id, ParentHint::Infer);
        fs::create_dir_all(&perch_path).unwrap();
        let rec = InfoJson::new(&id, "now", std::process::id(), "sess", "ready_agent");
        info::write_info(&perch_path, &rec).unwrap();
        assert_eq!(deliver::deliver(&id, "sender", "while you slept", &owlery), deliver::DeliveryPath::Spool);

        // Starting drains it.
        let (_agent, backlog) = ReadyAgent::start(&id).unwrap();
        assert_eq!(backlog.len(), 1);
        assert!(backlog[0].contains("while you slept"));
    }

    // [unit->REQ-MSG-3] a live TCP message is received via recv().
    #[test]
    fn recv_gets_live_tcp_message() {
        crate::test_home::isolate();
        let owlery = perch::owlery_dir();


Good — `test_home`, spool, listener primitives exist. Add `parent_pid()` to spt-store::proc (Unix getppid / Windows NtQueryInformationProcess).


30	            }
31	            true
32	        }
33	    }
34	}
35
36	#[cfg(test)]
37	mod tests {
38	    use super::*;
39
40	    // [unit->REQ-HAZARD-REGISTRY-STALE-CLEAN] the current process is alive.
41	    #[test]

            }
            true
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    // [unit->REQ-HAZARD-REGISTRY-STALE-CLEAN] the current process is alive.
    #[test]


Edit

            }
            true
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    // [unit->REQ-HAZARD-REGISTRY-STALE-CLEAN] the current process is alive.
    #[test]

            }
            true
        }
    }
}

/// The current process's **parent** pid — the stable session-binding anchor for
/// harness-hosted startup (the harness process that spawned `spt`). The
/// pid-recycle defense (KNOWN-HAZARDS 5.1) anchors liveness on this, not on the
/// ephemeral `spt` invocation's own pid.
///
/// Unix: `getppid`. Windows: `NtQueryInformationProcess(ProcessBasicInformation)`
/// → `InheritedFromUniqueProcessId` (tasklist/WMIC hang on recent builds; the
/// Nt call is the non-hanging path, mirroring the OpenProcess choice above).
/// Returns `None` only if the query fails.
pub fn parent_pid() -> Option<u32> {
    #[cfg(unix)]
    {
        Some(unsafe { libc::getppid() } as u32)
    }
    #[cfg(windows)]
    {
        #[repr(C)]
        struct ProcessBasicInformation {
            _exit_status: usize,
            _peb_base: usize,
            _affinity_mask: usize,
            _base_priority: usize,
            _unique_process_id: usize,
            inherited_from_unique_process_id: usize,
        }
        extern "system" {
            fn NtQueryInformationProcess(
                process_handle: isize,
                process_information_class: u32,
                process_information: *mut core::ffi::c_void,
                process_information_length: u32,
                return_length: *mut u32,
            ) -> i32;
        }
        // GetCurrentProcess() is the constant pseudo-handle -1; no import needed.
        const CURRENT_PROCESS: isize = -1;
        const PROCESS_BASIC_INFORMATION: u32 = 0;
        let mut info = ProcessBasicInformation {
            _exit_status: 0,
            _peb_base: 0,
            _affinity_mask: 0,
            _base_priority: 0,
            _unique_process_id: 0,
            inherited_from_unique_process_id: 0,
        };
        let mut ret_len: u32 = 0;
        let status = unsafe {
            NtQueryInformationProcess(
                CURRENT_PROCESS,
                PROCESS_BASIC_INFORMATION,
                &mut info as *mut _ as *mut core::ffi::c_void,
                core::mem::size_of::<ProcessBasicInformation>() as u32,
                &mut ret_len,
            )
        };
        if status == 0 {
            Some(info.inherited_from_unique_process_id as u32)
        } else {
            None
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    // [unit->REQ-HAZARD-REGISTRY-STALE-CLEAN] the current process is alive.
    #[test]


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

            }
            true
        }
    }
}

/// The current process's **parent** pid — the stable session-binding anchor for
/// harness-hosted startup (the harness process that spawned `spt`). The
/// pid-recycle defense (KNOWN-HAZARDS 5.1) anchors liveness on this, not on the
/// ephemeral `spt` invocation's own pid.
///
/// Unix: `getppid`. Windows: `NtQueryInformationProcess(ProcessBasicInformation)`
/// → `InheritedFromUniqueProcessId` (tasklist/WMIC hang on recent builds; the
/// Nt call is the non-hanging path, mirroring the OpenProcess choice above).
/// Returns `None` only if the query fails.
pub fn parent_pid() -> Option<u32> {
    #[cfg(unix)]
    {
        Some(unsafe { libc::getppid() } as u32)
    }
    #[cfg(windows)]
    {
        #[repr(C)]
        struct ProcessBasicInformation {
            _exit_status: usize,
            _peb_base: usize,
            _affinity_mask: usize,
            _base_priority: usize,
            _unique_process_id: usize,
            inherited_from_unique_process_id: usize,
        }
        extern "system" {
            fn NtQueryInformationProcess(
                process_handle: isize,
                process_information_class: u32,
                process_information: *mut core::ffi::c_void,
                process_information_length: u32,
                return_length: *mut u32,
            ) -> i32;
        }
        // GetCurrentProcess() is the constant pseudo-handle -1; no import needed.
        const CURRENT_PROCESS: isize = -1;
        const PROCESS_BASIC_INFORMATION: u32 = 0;
        let mut info = ProcessBasicInformation {
            _exit_status: 0,
            _peb_base: 0,
            _affinity_mask: 0,
            _base_priority: 0,
            _unique_process_id: 0,
            inherited_from_unique_process_id: 0,
        };
        let mut ret_len: u32 = 0;
        let status = unsafe {
            NtQueryInformationProcess(
                CURRENT_PROCESS,
                PROCESS_BASIC_INFORMATION,
                &mut info as *mut _ as *mut core::ffi::c_void,
                core::mem::size_of::<ProcessBasicInformation>() as u32,
                &mut ret_len,
            )
        };
        if status == 0 {
            Some(info.inherited_from_unique_process_id as u32)
        } else {
            None
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    // [unit->REQ-HAZARD-REGISTRY-STALE-CLEAN] the current process is alive.
    #[test]

//! Process-liveness probe. Used by the registry to detect stale entries
//! (dead-PID rows) before resolution. Ported from sister `types.rs`
//! `is_process_alive` (ADR-0001): Windows `OpenProcess` (tasklist hangs on
//! Win11 26200+), Unix `kill(pid, 0)`.

/// Whether a process with `pid` is currently alive. Best-effort and
/// side-effect-free — sends no signal, opens then immediately closes a handle.
// [impl->REQ-HAZARD-REGISTRY-STALE-CLEAN]
pub fn is_process_alive(pid: u32) -> bool {
    #[cfg(unix)]
    {
        // kill(pid, 0) probes existence without delivering a signal.
        unsafe { libc::kill(pid as i32, 0) == 0 }
    }
    #[cfg(windows)]
    {
        // OpenProcess with the most limited query right; tasklist/WMIC hang on
        // recent Windows builds.
        extern "system" {
            fn OpenProcess(dw_desired_access: u32, b_inherit_handle: i32, dw_process_id: u32) -> isize;
            fn CloseHandle(h_object: isize) -> i32;
        }
        const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
        let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
        if handle == 0 {
            false
        } else {
            unsafe {
                CloseHandle(handle);
            }
            true
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    // [unit->REQ-HAZARD-REGISTRY-STALE-CLEAN] the current process is alive.
    #[test]
    fn current_process_is_alive() {
        assert!(is_process_alive(std::process::id()));
    }

    // [unit->REQ-HAZARD-REGISTRY-STALE-CLEAN] a pid far above any real
    // allocation reads as dead. Chosen deterministically (above Linux pid_max
    // ~4.2M and Windows ranges; positive as i32 so the Unix kill-group
    // semantics don't trigger) to avoid the pid-reuse flakiness of
    // spawn-then-reap under parallel test execution.
    #[test]
    fn unassigned_pid_is_dead() {
        const DEAD_PID: u32 = 2_000_000_000;
        assert!(!is_process_alive(DEAD_PID), "unassigned pid must read as dead");
    }
}

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

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

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

+/// ephemeral `spt` invocation's own pid.

+/// Unix: `getppid`. Windows: `NtQueryInformationProcess(ProcessBasicInformation)`

+/// → `InheritedFromUniqueProcessId` (tasklist/WMIC hang on recent builds; the

+/// Nt call is the non-hanging path, mirroring the OpenProcess choice above).

+/// Returns `None` only if the query fails.

+        Some(unsafe { libc::getppid() } as u32)

+        struct ProcessBasicInformation {

+            inherited_from_unique_process_id: usize,

+            fn NtQueryInformationProcess(

+                process_information_class: u32,

+                process_information: *mut core::ffi::c_void,

+                process_information_length: u32,

+                return_length: *mut u32,

+        // GetCurrentProcess() is the constant pseudo-handle -1; no import needed.

+        const CURRENT_PROCESS: isize = -1;

+        const PROCESS_BASIC_INFORMATION: u32 = 0;

+        let mut info = ProcessBasicInformation {

+            inherited_from_unique_process_id: 0,

+                PROCESS_BASIC_INFORMATION,

+                &mut info as *mut _ as *mut core::ffi::c_void,

+                core::mem::size_of::<ProcessBasicInformation>() as u32,

+            Some(info.inherited_from_unique_process_id as u32)


    #[test]
    fn unassigned_pid_is_dead() {
        const DEAD_PID: u32 = 2_000_000_000;
        assert!(!is_process_alive(DEAD_PID), "unassigned pid must read as dead");
    }
}

    #[test]
    fn unassigned_pid_is_dead() {
        const DEAD_PID: u32 = 2_000_000_000;
        assert!(!is_process_alive(DEAD_PID), "unassigned pid must read as dead");
    }

    // [unit->REQ-HAZARD-WINDOWS-PID-RECYCLE] the parent pid resolves to a real,
    // live process (the test runner that spawned this process).
    #[test]
    fn parent_pid_resolves_and_is_alive() {
        let ppid = parent_pid().expect("parent pid must resolve");
        assert_ne!(ppid, 0, "parent pid must be non-zero");
        assert_ne!(ppid, std::process::id(), "parent must differ from self");
        assert!(is_process_alive(ppid), "the live parent must read as alive");
    }
}


    #[test]
    fn unassigned_pid_is_dead() {
        const DEAD_PID: u32 = 2_000_000_000;
        assert!(!is_process_alive(DEAD_PID), "unassigned pid must read as dead");
    }

    // [unit->REQ-HAZARD-WINDOWS-PID-RECYCLE] the parent pid resolves to a real,
    // live process (the test runner that spawned this process).
    #[test]
    fn parent_pid_resolves_and_is_alive() {
        let ppid = parent_pid().expect("parent pid must resolve");
        assert_ne!(ppid, 0, "parent pid must be non-zero");
        assert_ne!(ppid, std::process::id(), "parent must differ from self");
        assert!(is_process_alive(ppid), "the live parent must read as alive");
    }
}

//! Process-liveness probe. Used by the registry to detect stale entries
//! (dead-PID rows) before resolution. Ported from sister `types.rs`
//! `is_process_alive` (ADR-0001): Windows `OpenProcess` (tasklist hangs on
//! Win11 26200+), Unix `kill(pid, 0)`.

/// Whether a process with `pid` is currently alive. Best-effort and
/// side-effect-free — sends no signal, opens then immediately closes a handle.
// [impl->REQ-HAZARD-REGISTRY-STALE-CLEAN]
pub fn is_process_alive(pid: u32) -> bool {
    #[cfg(unix)]
    {
        // kill(pid, 0) probes existence without delivering a signal.
        unsafe { libc::kill(pid as i32, 0) == 0 }
    }
    #[cfg(windows)]
    {
        // OpenProcess with the most limited query right; tasklist/WMIC hang on
        // recent Windows builds.
        extern "system" {
            fn OpenProcess(dw_desired_access: u32, b_inherit_handle: i32, dw_process_id: u32) -> isize;
            fn CloseHandle(h_object: isize) -> i32;
        }
        const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
        let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
        if handle == 0 {
            false
        } else {
            unsafe {
                CloseHandle(handle);
            }
            true
        }
    }
}

/// The current process's **parent** pid — the stable session-binding anchor for
/// harness-hosted startup (the harness process that spawned `spt`). The
/// pid-recycle defense (KNOWN-HAZARDS 5.1) anchors liveness on this, not on the
/// ephemeral `spt` invocation's own pid.
///
/// Unix: `getppid`. Windows: `NtQueryInformationProcess(ProcessBasicInformation)`
/// → `InheritedFromUniqueProcessId` (tasklist/WMIC hang on recent builds; the
/// Nt call is the non-hanging path, mirroring the OpenProcess choice above).
/// Returns `None` only if the query fails.
pub fn parent_pid() -> Option<u32> {
    #[cfg(unix)]
    {
        Some(unsafe { libc::getppid() } as u32)
    }
    #[cfg(windows)]
    {
        #[repr(C)]
        struct ProcessBasicInformation {
            _exit_status: usize,
            _peb_base: usize,
            _affinity_mask: usize,
            _base_priority: usize,
            _unique_process_id: usize,
            inherited_from_unique_process_id: usize,
        }
        extern "system" {
            fn NtQueryInformationProcess(
                process_handle: isize,
                process_information_class: u32,
                process_information: *mut core::ffi::c_void,
                process_information_length: u32,
                return_length: *mut u32,
            ) -> i32;
        }
        // GetCurrentProcess() is the constant pseudo-handle -1; no import needed.
        const CURRENT_PROCESS: isize = -1;
        const PROCESS_BASIC_INFORMATION: u32 = 0;
        let mut info = ProcessBasicInformation {
            _exit_status: 0,
            _peb_base: 0,
            _affinity_mask: 0,
            _base_priority: 0,
            _unique_process_id: 0,
            inherited_from_unique_process_id: 0,
        };
        let mut ret_len: u32 = 0;
        let status = unsafe {
            NtQueryInformationProcess(
                CURRENT_PROCESS,
                PROCESS_BASIC_INFORMATION,
                &mut info as *mut _ as *mut core::ffi::c_void,
                core::mem::size_of::<ProcessBasicInformation>() as u32,
                &mut ret_len,
            )
        };
        if status == 0 {
            Some(info.inherited_from_unique_process_id as u32)
        } else {
            None
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    // [unit->REQ-HAZARD-REGISTRY-STALE-CLEAN] the current process is alive.
    #[test]
    fn current_process_is_alive() {
        assert!(is_process_alive(std::process::id()));
    }

    // [unit->REQ-HAZARD-REGISTRY-STALE-CLEAN] a pid far above any real
    // allocation reads as dead. Chosen deterministically (above Linux pid_max
    // ~4.2M and Windows ranges; positive as i32 so the Unix kill-group
    // semantics don't trigger) to avoid the pid-reuse flakiness of
    // spawn-then-reap under parallel test execution.
    #[test]
    fn unassigned_pid_is_dead() {
        const DEAD_PID: u32 = 2_000_000_000;
        assert!(!is_process_alive(DEAD_PID), "unassigned pid must read as dead");
    }
}

+    // [unit->REQ-HAZARD-WINDOWS-PID-RECYCLE] the parent pid resolves to a real,

+    // live process (the test runner that spawned this process).

+    fn parent_pid_resolves_and_is_alive() {

+        let ppid = parent_pid().expect("parent pid must resolve");

+        assert_ne!(ppid, 0, "parent pid must be non-zero");

+        assert_ne!(ppid, std::process::id(), "parent must differ from self");

+        assert!(is_process_alive(ppid), "the live parent must read as alive");


T0: Scaffold spt-runtime crate

New crate (members + deps spt-proto/store/msg, serde, toml); activation-prep. Compiles; traceable-reqs check green.

T1: Manifest schema + parse + validate

Typed structs for every docs/MANIFEST.md section + parse + validate (helpful errors). REQ-MANIFEST-1.

T2: AgentRuntime trait + ManifestRuntime

Substitution-key catalog + opaque-template fill + spawn-session seam (timeout). REQ-SEAM-SPAWN, REQ-HAZARD-SUBPROCESS-TIMEOUT.

T3: spt api dispatch group + local-api-auth

--manifest/--adapter resolution, adapter_name on every call, exit codes, local-api-auth guard. REQ-API-1, REQ-HAZARD-LOCAL-API-AUTH.

T4: Harness-hosted startup (seed/listen/bind)

api seed (interim seed-file by parent_pid) + api listen (consume seed, validate session_id, bind skeleton->live, relay loop) + api bind. REQ-START-1/2, REQ-SEAM-POSTSPAWN, REQ-API-2, REQ-HAZARD-WINDOWS-PID-RECYCLE.

T5: Delivery + activity (state/poll/inject)

api state busy|idle (+echo-gate sentinel, --no-gate, echo-gate set/clear) + api poll [--include-deferred] + inject-method selection. REQ-SEAM-ACTIVITY, REQ-SEAM-INJECT, REQ-API-2.

T6: Worker perches + cascade-wipe guard

api worker-start/worker-stop/worker-poll + cascade-wipe guard. REQ-API-2, REQ-HAZARD-CASCADE-WIPE-GUARD.

T7: Reporting + boundary surface

api boundary clear|compact + session-end (--erase) + presence + history-log + emit stub + capability hostable_types + env-alias supply. REQ-API-2/3, REQ-START-4, REQ-SEAM-CAPABILITY, REQ-HAZARD-SOFT-CLEANUP.

T8: Generic mock adapter

adapters/mock/manifest.toml + cross-platform session scripts/helper bin; zero CC conventions. REQ-MANIFEST-1, REQ-API-3.

T9: Contract E2E test

load mock manifest -> ManifestRuntime spawns session -> api bind -> api state idle -> api poll delivers spooled message. REQ-API-1/2 int, REQ-START-2 int.

T10: Activation sweep

Activate M2a reqs + hazards + REQ-ARCH-2, green check, CI green, amend ROADMAP/CONTEXT for split.


Updated task #4 status


Updated task #5 status


Bash

cd "C:\Users\decid\Documents\projects\spt-core" && cargo test -p spt-store proc:: 2>&1 | tail -10

Test parent_pid resolver


    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.80s
     Running unittests src\lib.rs (target\debug\deps\spt_store-48f4f027db936eae.exe)

running 3 tests
test proc::tests::current_process_is_alive ... ok
test proc::tests::parent_pid_resolves_and_is_alive ... ok
test proc::tests::unassigned_pid_is_dead ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 28 filtered out; finished in 0.00s


`spt-store` has serde — add a localized `seed` module there (the M3 swap point).


Write

//! Ephemeral harness-hosted startup seed — the **interim no-daemon** bridge
//! (decision 2026-06-01). A `SessionStart`-equivalent hook fires
//! `api seed --pid <parent_pid> --session-id <sid>`, writing a seed file keyed
//! by the stable parent pid. `api listen <id>` later consumes it (matched by
//! the listener's *own* parent pid), then binds the perch.
//!
//! CONTEXT describes the M3 daemon holding this in-memory; M2a mirrors M1's
//! standalone model with a file instead. **This module is the localized swap
//! point** — M3 replaces the file read/write with the daemon's in-memory map
//! and nothing else in the `api listen` path changes.
//
// [impl->REQ-START-1]

use serde::{Deserialize, Serialize};
use std::io;
use std::path::PathBuf;

use crate::atomic::atomic_write_string;
use crate::perch::spt_home;

/// One ephemeral seed entry: the session details the spt-hosted topology would
/// share directly, minus the not-yet-chosen `live_id`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Seed {
    /// The stable session-binding anchor (the harness process pid).
    pub parent_pid: u32,
    /// The harness session id — the pid-recycle discriminator (5.1).
    pub session_id: String,
    /// adapter_name that wrote the seed.
    pub adapter: String,
    /// Optional working directory the session runs in.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub cwd: Option<String>,
    /// Creation stamp (opaque producer string).
    pub created: String,
}

/// `<spt_home>/seeds/`.
pub fn seeds_dir() -> PathBuf {
    spt_home().join("seeds")
}

/// `<spt_home>/seeds/<parent_pid>.json`.
pub fn seed_path(parent_pid: u32) -> PathBuf {
    seeds_dir().join(format!("{parent_pid}.json"))
}

/// Write (or refresh) the seed keyed by its `parent_pid`. Atomic (tmp+rename).
pub fn write_seed(seed: &Seed) -> io::Result<()> {
    std::fs::create_dir_all(seeds_dir())?;
    let json = serde_json::to_string(seed)
        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
    atomic_write_string(&seed_path(seed.parent_pid), &json)
}

/// Read the seed for `parent_pid`, if present and parseable.
pub fn read_seed(parent_pid: u32) -> Option<Seed> {
    let content = std::fs::read_to_string(seed_path(parent_pid)).ok()?;
    serde_json::from_str(&content).ok()
}

/// Remove a consumed seed (best-effort; seeds are ephemeral).
pub fn remove_seed(parent_pid: u32) {
    let _ = std::fs::remove_file(seed_path(parent_pid));
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::Mutex;

    static ENV_LOCK: Mutex<()> = Mutex::new(());

    struct Home {
        _lock: std::sync::MutexGuard<'static, ()>,
        _tmp: tempfile::TempDir,
        prev: Option<String>,
    }
    impl Home {
        fn set() -> Self {
            let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
            let tmp = tempfile::tempdir().unwrap();
            let prev = std::env::var("SPT_HOME").ok();
            std::env::set_var("SPT_HOME", tmp.path());
            Home { _lock: lock, _tmp: tmp, prev }
        }
    }
    impl Drop for Home {
        fn drop(&mut self) {
            match &self.prev {
                Some(v) => std::env::set_var("SPT_HOME", v),
                None => std::env::remove_var("SPT_HOME"),
            }
        }
    }

    // [unit->REQ-START-1] a written seed reads back identically, keyed by pid.
    #[test]
    fn seed_round_trips() {
        let _h = Home::set();
        let seed = Seed {
            parent_pid: 4321,
            session_id: "sid-xyz".into(),
            adapter: "mock".into(),
            cwd: Some("C:/repo".into()),
            created: "1700000000".into(),
        };
        write_seed(&seed).unwrap();
        assert_eq!(read_seed(4321), Some(seed));
    }

    // [unit->REQ-START-1] absent seed reads None; remove clears it.
    #[test]
    fn missing_seed_is_none_and_remove_clears() {
        let _h = Home::set();
        assert_eq!(read_seed(999), None);
        let seed = Seed {
            parent_pid: 999,
            session_id: "s".into(),
            adapter: "mock".into(),
            cwd: None,
            created: "0".into(),
        };
        write_seed(&seed).unwrap();
        assert!(read_seed(999).is_some());
        remove_seed(999);
        assert_eq!(read_seed(999), None);
    }
}


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

//! Ephemeral harness-hosted startup seed — the **interim no-daemon** bridge
//! (decision 2026-06-01). A `SessionStart`-equivalent hook fires
//! `api seed --pid <parent_pid> --session-id <sid>`, writing a seed file keyed
//! by the stable parent pid. `api listen <id>` later consumes it (matched by
//! the listener's *own* parent pid), then binds the perch.
//!
//! CONTEXT describes the M3 daemon holding this in-memory; M2a mirrors M1's
//! standalone model with a file instead. **This module is the localized swap
//! point** — M3 replaces the file read/write with the daemon's in-memory map
//! and nothing else in the `api listen` path changes.
//
// [impl->REQ-START-1]

use serde::{Deserialize, Serialize};
use std::io;
use std::path::PathBuf;

use crate::atomic::atomic_write_string;
use crate::perch::spt_home;

/// One ephemeral seed entry: the session details the spt-hosted topology would
/// share directly, minus the not-yet-chosen `live_id`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Seed {
    /// The stable session-binding anchor (the harness process pid).
    pub parent_pid: u32,
    /// The harness session id — the pid-recycle discriminator (5.1).
    pub session_id: String,
    /// adapter_name that wrote the seed.
    pub adapter: String,
    /// Optional working directory the session runs in.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub cwd: Option<String>,
    /// Creation stamp (opaque producer string).
    pub created: String,
}

/// `<spt_home>/seeds/`.
pub fn seeds_dir() -> PathBuf {
    spt_home().join("seeds")
}

/// `<spt_home>/seeds/<parent_pid>.json`.
pub fn seed_path(parent_pid: u32) -> PathBuf {
    seeds_dir().join(format!("{parent_pid}.json"))
}

/// Write (or refresh) the seed keyed by its `parent_pid`. Atomic (tmp+rename).
pub fn write_seed(seed: &Seed) -> io::Result<()> {
    std::fs::create_dir_all(seeds_dir())?;
    let json = serde_json::to_string(seed)
        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
    atomic_write_string(&seed_path(seed.parent_pid), &json)
}

/// Read the seed for `parent_pid`, if present and parseable.
pub fn read_seed(parent_pid: u32) -> Option<Seed> {
    let content = std::fs::read_to_string(seed_path(parent_pid)).ok()?;
    serde_json::from_str(&content).ok()
}

/// Remove a consumed seed (best-effort; seeds are ephemeral).
pub fn remove_seed(parent_pid: u32) {
    let _ = std::fs::remove_file(seed_path(parent_pid));
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::Mutex;

    static ENV_LOCK: Mutex<()> = Mutex::new(());

    struct Home {
        _lock: std::sync::MutexGuard<'static, ()>,
        _tmp: tempfile::TempDir,
        prev: Option<String>,
    }
    impl Home {
        fn set() -> Self {
            let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
            let tmp = tempfile::tempdir().unwrap();
            let prev = std::env::var("SPT_HOME").ok();
            std::env::set_var("SPT_HOME", tmp.path());
            Home { _lock: lock, _tmp: tmp, prev }
        }
    }
    impl Drop for Home {
        fn drop(&mut self) {
            match &self.prev {
                Some(v) => std::env::set_var("SPT_HOME", v),
                None => std::env::remove_var("SPT_HOME"),
            }
        }
    }

    // [unit->REQ-START-1] a written seed reads back identically, keyed by pid.
    #[test]
    fn seed_round_trips() {
        let _h = Home::set();
        let seed = Seed {
            parent_pid: 4321,
            session_id: "sid-xyz".into(),
            adapter: "mock".into(),
            cwd: Some("C:/repo".into()),
            created: "1700000000".into(),
        };
        write_seed(&seed).unwrap();
        assert_eq!(read_seed(4321), Some(seed));
    }

    // [unit->REQ-START-1] absent seed reads None; remove clears it.
    #[test]
    fn missing_seed_is_none_and_remove_clears() {
        let _h = Home::set();
        assert_eq!(read_seed(999), None);
        let seed = Seed {
            parent_pid: 999,
            session_id: "s".into(),
            adapter: "mock".into(),
            cwd: None,
            created: "0".into(),
        };
        write_seed(&seed).unwrap();
        assert!(read_seed(999).is_some());
        remove_seed(999);
        assert_eq!(read_seed(999), None);
    }
}


18	pub mod registry;
19	pub mod spool;
20

pub mod registry;
pub mod spool;


14	pub mod atomic;
15	pub mod info;
16	pub mod perch;
17	pub mod proc;
18	pub mod registry;
19	pub mod spool;

pub mod atomic;
pub mod info;
pub mod perch;
pub mod proc;
pub mod registry;
pub mod spool;


Edit

pub mod atomic;
pub mod info;
pub mod perch;
pub mod proc;
pub mod registry;
pub mod spool;

pub mod atomic;
pub mod info;
pub mod perch;
pub mod proc;
pub mod registry;
pub mod seed;
pub mod spool;


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

pub mod atomic;
pub mod info;
pub mod perch;
pub mod proc;
pub mod registry;
pub mod seed;
pub mod spool;

//! `spt-store` — the persistence commodity layer for spt-core.
//!
//! Sits directly above `spt-proto` (R-ARCH-1 layering): it serializes proto
//! types to disk (spool, registry, info.json) through a single atomic-write
//! primitive. Stable on-disk formats are copied verbatim from the sister
//! project (ADR-0001); the EBUSY-retry hardening on the write path is the
//! clean-room addition demanded by KNOWN-HAZARDS 5.2.
//!
// Second layer of the acyclically-layered crate stack (R-ARCH-1): spt-store
// depends only on spt-proto (one layer down), never the reverse — the
// dependency edge is one-way, so the workspace graph stays acyclic.
// [impl->REQ-ARCH-1]

pub mod atomic;
pub mod info;
pub mod perch;
pub mod proc;
pub mod registry;
pub mod spool;

pub mod perch;
pub mod proc;
pub mod registry;
+pub mod seed;
pub mod spool;


Now the startup handlers. Write `api/startup.rs`.


Write

//! Harness-hosted startup (interim no-daemon): `api seed` → `api listen` → bind,
//! plus the post-spawn `api bind`.
//!
//! Flow (CONTEXT §Startup flows, harness-hosted):
//! 1. The harness's SessionStart hook fires `api seed --pid <parent_pid>
//!    --session-id <sid>` → an ephemeral seed file keyed by the stable parent
//!    pid (REQ-START-1).
//! 2. The agent runs its `$SPT listen <id>` alias → `api listen` self-discovers
//!    its parent pid, matches the seed, validates it against pid-recycle
//!    (KNOWN-HAZARDS 5.1), binds the perch skeleton→live, and relays.
//! 3. Alternatively a just-spawned binary fires `api bind <id>` to associate
//!    itself post-spawn (REQ-SEAM-POSTSPAWN) — establishing the live perch on
//!    first contact, or flipping an existing skeleton.
//
// [impl->REQ-START-1]
// [impl->REQ-START-2]
// [impl->REQ-SEAM-POSTSPAWN]

use std::time::Duration;

use spt_msg::listener::PollListener;
use spt_store::info::{self, InfoJson};
use spt_store::perch::{self, ParentHint};
use spt_store::proc;
use spt_store::seed::{self, Seed};
use spt_store::spool;

use super::auth;
use super::{Ctx, EXIT_REFUSED};

/// Poll window for the relay loop — bounds each blocking accept so the loop
/// stays responsive (mirrors M1's listener cadence).
const RELAY_POLL: Duration = Duration::from_millis(200);

/// `api seed` — record the ephemeral startup seed keyed by parent pid. No prior
/// perch exists, so this call is not auth-gated (it *establishes* the binding
/// material the gated calls later authenticate against).
// [impl->REQ-START-1]
pub fn cmd_seed(ctx: &Ctx, parent_pid: u32, session_id: String) -> i32 {
    if session_id.is_empty() {
        eprintln!("USAGE: seed requires a non-empty --session-id");
        return EXIT_REFUSED;
    }
    let seed = Seed {
        parent_pid,
        session_id,
        adapter: ctx.adapter.clone(),
        cwd: std::env::current_dir()
            .ok()
            .map(|p| p.to_string_lossy().replace('\\', "/")),
        created: now_stamp(),
    };
    match seed::write_seed(&seed) {
        Ok(()) => {
            eprintln!("SEEDED:{parent_pid}");
            0
        }
        Err(e) => {
            eprintln!("SEED_FAIL:{parent_pid}: {e}");
            EXIT_REFUSED
        }
    }
}

/// Why a seed-driven bind was refused. Each variant is a distinct diagnostic so
/// a stale/recycled seed never silently binds the wrong session.
#[derive(Debug, PartialEq, Eq)]
pub enum BindError {
    NoParentPid,
    NoSeed(u32),
    /// The seed's anchor process is dead → recycled/abandoned (5.1).
    StaleSeed(u32),
    EmptySession,
    /// A *live* perch already holds this id under a different session (5.1).
    Conflict { id: String, held: String },
    Io(String),
}

/// Consume the seed for `parent_pid` and bind/revive the perch skeleton→live.
/// Pure of any listener I/O so it is unit-testable without spawning processes.
/// Returns the perch's capability token on success.
// [impl->REQ-START-2]
// [impl->REQ-HAZARD-WINDOWS-PID-RECYCLE]
pub fn bind_from_seed(id: &str, parent_pid: u32) -> Result<String, BindError> {
    let seed = seed::read_seed(parent_pid).ok_or(BindError::NoSeed(parent_pid))?;
    if seed.session_id.is_empty() {
        return Err(BindError::EmptySession);
    }
    // Pid-recycle defense (5.1): anchor liveness on the stable parent pid. A
    // dead anchor means the harness that wrote the seed is gone — a recycled or
    // abandoned seed must never bind.
    if !proc::is_process_alive(seed.parent_pid) {
        return Err(BindError::StaleSeed(seed.parent_pid));
    }
    let token = establish_perch(id, &seed.session_id, seed.parent_pid)?;
    seed::remove_seed(parent_pid); // consumed; refreshed on the next SessionStart
    Ok(token)
}

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

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

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

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

/// `api listen <id>` — consume the seed, bind the perch, then run the relay
/// loop (heir to `$LIVE start`'s Monitor-bound poll loop). `--once` drains and
/// exits (testability); otherwise blocks streaming messages to stdout.
// [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
        .or(proof_session)
        .or_else(|| std::env::var("OWL_SESSION_ID").ok())
        .filter(|s| !s.is_empty());
    let Some(session_id) = session_id else {
        eprintln!("USAGE: bind needs --set-session-id (or $OWL_SESSION_ID)");
        return EXIT_REFUSED;
    };
    // The session's own anchor: the process that fired this hook (the session).
    let parent_pid = proc::parent_pid().unwrap_or_else(std::process::id);
    match establish_perch(id, &session_id, parent_pid) {
        Ok(token) => {
            eprintln!("BOUND:{id} token={token}");
            0
        }
        Err(e) => {
            report_bind_error(&e);
            EXIT_REFUSED
        }
    }
}

fn report_bind_error(e: &BindError) {
    match e {
        BindError::NoParentPid => eprintln!("NO_PARENT_PID: could not resolve the parent pid"),
        BindError::NoSeed(p) => eprintln!("NO_SEED:{p} (no seed for this parent pid)"),
        BindError::StaleSeed(p) => eprintln!("STALE_SEED:{p} (anchor process is dead)"),
        BindError::EmptySession => eprintln!("EMPTY_SESSION: seed carried no session id"),
        BindError::Conflict { id, held } => {
            eprintln!("CONFLICT:{id} is live under session {held}")
        }
        BindError::Io(m) => eprintln!("BIND_IO: {m}"),
    }
}

/// Print a framed message to stdout and flush (one delivery per emit).
fn emit(msg: &str) {
    use std::io::Write;
    print!("{msg}");
    let _ = std::io::stdout().flush();
}

/// Coarse creation timestamp (epoch seconds as a string), matching the
/// `info.json` `started` producer-format convention.
fn now_stamp() -> String {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs().to_string())
        .unwrap_or_else(|_| "0".to_string())
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::Mutex;

    static ENV_LOCK: Mutex<()> = Mutex::new(());

    struct Home {
        _lock: std::sync::MutexGuard<'static, ()>,
        _tmp: tempfile::TempDir,
        prev: Option<String>,
    }
    impl Home {
        fn set() -> Self {
            let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
            let tmp = tempfile::tempdir().unwrap();
            let prev = std::env::var("SPT_HOME").ok();
            std::env::set_var("SPT_HOME", tmp.path());
            Home { _lock: lock, _tmp: tmp, prev }
        }
    }
    impl Drop for Home {
        fn drop(&mut self) {
            match &self.prev {
                Some(v) => std::env::set_var("SPT_HOME", v),
                None => std::env::remove_var("SPT_HOME"),
            }
        }
    }

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

    #[test] // [unit->REQ-START-2] a valid seed (live anchor) binds a live perch.
    fn valid_seed_binds_live_perch() {
        let _h = Home::set();
        // Use this very process as the (alive) anchor.
        let anchor = std::process::id();
        write_seed(anchor, "sid-1");
        let token = bind_from_seed("alice", anchor).expect("bind");
        assert_eq!(token.len(), 32);

        let perch_path = perch::resolve_perch_path("alice", ParentHint::Infer);
        let rec = info::read_info(&perch_path).expect("info.json");
        assert_eq!(rec.session_id, "sid-1");
        assert_eq!(rec.state, "live_agent");
        assert_eq!(rec.parent_pid, Some(anchor));
        assert!(perch::resolve_ready_file("alice", ParentHint::Infer).exists());
        // Seed is consumed.
        assert!(seed::read_seed(anchor).is_none());
    }

    #[test] // [unit->REQ-HAZARD-WINDOWS-PID-RECYCLE] a dead-anchor seed never binds.
    fn stale_seed_with_dead_anchor_refused() {
        let _h = Home::set();
        const DEAD: u32 = 2_000_000_000;
        write_seed(DEAD, "sid-1");
        let err = bind_from_seed("alice", DEAD).unwrap_err();
        assert_eq!(err, BindError::StaleSeed(DEAD));
        // No perch was created.
        assert!(info::read_info(&perch::resolve_perch_path("alice", ParentHint::Infer)).is_none());
    }

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

    #[test] // [unit->REQ-START-2] empty session id in the seed is refused.
    fn empty_session_seed_refused() {
        let _h = Home::set();
        let anchor = std::process::id();
        write_seed(anchor, "");
        assert_eq!(bind_from_seed("alice", anchor).unwrap_err(), BindError::EmptySession);
    }

    #[test] // [unit->REQ-HAZARD-WINDOWS-PID-RECYCLE] a live perch under another
    // session is not silently rebound (id-collision guard).
    fn live_perch_under_other_session_conflicts() {
        let _h = Home::set();
        let anchor = std::process::id();
        // First bind establishes alice@sid-1 owned by this (live) process.
        write_seed(anchor, "sid-1");
        bind_from_seed("alice", anchor).unwrap();
        // A second seed tries to claim alice for a different session.
        write_seed(anchor, "sid-2");
        let err = bind_from_seed("alice", anchor).unwrap_err();
        assert_eq!(err, BindError::Conflict { id: "alice".into(), held: "sid-1".into() });
    }

    #[test] // [unit->REQ-SEAM-POSTSPAWN] first-contact bind establishes a live perch.
    fn post_spawn_bind_establishes_perch() {
        let _h = Home::set();
        let code = cmd_bind("bob", Some("sid-boot".into()), None);
        assert_eq!(code, 0);
        let rec = info::read_info(&perch::resolve_perch_path("bob", ParentHint::Infer)).unwrap();
        assert_eq!(rec.session_id, "sid-boot");
        assert_eq!(rec.state, "live_agent");
    }

    #[test] // [unit->REQ-SEAM-POSTSPAWN] re-bind with the same session is idempotent.
    fn rebind_same_session_ok() {
        let _h = Home::set();
        assert_eq!(cmd_bind("bob", Some("sid-boot".into()), None), 0);
        assert_eq!(cmd_bind("bob", Some("sid-boot".into()), None), 0);
    }
}


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

//! Harness-hosted startup (interim no-daemon): `api seed` → `api listen` → bind,
//! plus the post-spawn `api bind`.
//!
//! Flow (CONTEXT §Startup flows, harness-hosted):
//! 1. The harness's SessionStart hook fires `api seed --pid <parent_pid>
//!    --session-id <sid>` → an ephemeral seed file keyed by the stable parent
//!    pid (REQ-START-1).
//! 2. The agent runs its `$SPT listen <id>` alias → `api listen` self-discovers
//!    its parent pid, matches the seed, validates it against pid-recycle
//!    (KNOWN-HAZARDS 5.1), binds the perch skeleton→live, and relays.
//! 3. Alternatively a just-spawned binary fires `api bind <id>` to associate
//!    itself post-spawn (REQ-SEAM-POSTSPAWN) — establishing the live perch on
//!    first contact, or flipping an existing skeleton.
//
// [impl->REQ-START-1]
// [impl->REQ-START-2]
// [impl->REQ-SEAM-POSTSPAWN]

use std::time::Duration;

use spt_msg::listener::PollListener;
use spt_store::info::{self, InfoJson};
use spt_store::perch::{self, ParentHint};
use spt_store::proc;
use spt_store::seed::{self, Seed};
use spt_store::spool;

use super::auth;
use super::{Ctx, EXIT_REFUSED};

/// Poll window for the relay loop — bounds each blocking accept so the loop
/// stays responsive (mirrors M1's listener cadence).
const RELAY_POLL: Duration = Duration::from_millis(200);

/// `api seed` — record the ephemeral startup seed keyed by parent pid. No prior
/// perch exists, so this call is not auth-gated (it *establishes* the binding
/// material the gated calls later authenticate against).
// [impl->REQ-START-1]
pub fn cmd_seed(ctx: &Ctx, parent_pid: u32, session_id: String) -> i32 {
    if session_id.is_empty() {
        eprintln!("USAGE: seed requires a non-empty --session-id");
        return EXIT_REFUSED;
    }
    let seed = Seed {
        parent_pid,
        session_id,
        adapter: ctx.adapter.clone(),
        cwd: std::env::current_dir()
            .ok()
            .map(|p| p.to_string_lossy().replace('\\', "/")),
        created: now_stamp(),
    };
    match seed::write_seed(&seed) {
        Ok(()) => {
            eprintln!("SEEDED:{parent_pid}");
            0
        }
        Err(e) => {
            eprintln!("SEED_FAIL:{parent_pid}: {e}");
            EXIT_REFUSED
        }
    }
}

/// Why a seed-driven bind was refused. Each variant is a distinct diagnostic so
/// a stale/recycled seed never silently binds the wrong session.
#[derive(Debug, PartialEq, Eq)]
pub enum BindError {
    NoParentPid,
    NoSeed(u32),
    /// The seed's anchor process is dead → recycled/abandoned (5.1).
    StaleSeed(u32),
    EmptySession,
    /// A *live* perch already holds this id under a different session (5.1).
    Conflict { id: String, held: String },
    Io(String),
}

/// Consume the seed for `parent_pid` and bind/revive the perch skeleton→live.
/// Pure of any listener I/O so it is unit-testable without spawning processes.
/// Returns the perch's capability token on success.
// [impl->REQ-START-2]
// [impl->REQ-HAZARD-WINDOWS-PID-RECYCLE]
pub fn bind_from_seed(id: &str, parent_pid: u32) -> Result<String, BindError> {
    let seed = seed::read_seed(parent_pid).ok_or(BindError::NoSeed(parent_pid))?;
    if seed.session_id.is_empty() {
        return Err(BindError::EmptySession);
    }
    // Pid-recycle defense (5.1): anchor liveness on the stable parent pid. A
    // dead anchor means the harness that wrote the seed is gone — a recycled or
    // abandoned seed must never bind.
    if !proc::is_process_alive(seed.parent_pid) {
        return Err(BindError::StaleSeed(seed.parent_pid));
    }
    let token = establish_perch(id, &seed.session_id, seed.parent_pid)?;
    seed::remove_seed(parent_pid); // consumed; refreshed on the next SessionStart
    Ok(token)
}

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

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

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

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

/// `api listen <id>` — consume the seed, bind the perch, then run the relay
/// loop (heir to `$LIVE start`'s Monitor-bound poll loop). `--once` drains and
/// exits (testability); otherwise blocks streaming messages to stdout.
// [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
        .or(proof_session)
        .or_else(|| std::env::var("OWL_SESSION_ID").ok())
        .filter(|s| !s.is_empty());
    let Some(session_id) = session_id else {
        eprintln!("USAGE: bind needs --set-session-id (or $OWL_SESSION_ID)");
        return EXIT_REFUSED;
    };
    // The session's own anchor: the process that fired this hook (the session).
    let parent_pid = proc::parent_pid().unwrap_or_else(std::process::id);
    match establish_perch(id, &session_id, parent_pid) {
        Ok(token) => {
            eprintln!("BOUND:{id} token={token}");
            0
        }
        Err(e) => {
            report_bind_error(&e);
            EXIT_REFUSED
        }
    }
}

fn report_bind_error(e: &BindError) {
    match e {
        BindError::NoParentPid => eprintln!("NO_PARENT_PID: could not resolve the parent pid"),
        BindError::NoSeed(p) => eprintln!("NO_SEED:{p} (no seed for this parent pid)"),
        BindError::StaleSeed(p) => eprintln!("STALE_SEED:{p} (anchor process is dead)"),
        BindError::EmptySession => eprintln!("EMPTY_SESSION: seed carried no session id"),
        BindError::Conflict { id, held } => {
            eprintln!("CONFLICT:{id} is live under session {held}")
        }
        BindError::Io(m) => eprintln!("BIND_IO: {m}"),
    }
}

/// Print a framed message to stdout and flush (one delivery per emit).
fn emit(msg: &str) {
    use std::io::Write;
    print!("{msg}");
    let _ = std::io::stdout().flush();
}

/// Coarse creation timestamp (epoch seconds as a string), matching the
/// `info.json` `started` producer-format convention.
fn now_stamp() -> String {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs().to_string())
        .unwrap_or_else(|_| "0".to_string())
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::Mutex;

    static ENV_LOCK: Mutex<()> = Mutex::new(());

    struct Home {
        _lock: std::sync::MutexGuard<'static, ()>,
        _tmp: tempfile::TempDir,
        prev: Option<String>,
    }
    impl Home {
        fn set() -> Self {
            let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
            let tmp = tempfile::tempdir().unwrap();
            let prev = std::env::var("SPT_HOME").ok();
            std::env::set_var("SPT_HOME", tmp.path());
            Home { _lock: lock, _tmp: tmp, prev }
        }
    }
    impl Drop for Home {
        fn drop(&mut self) {
            match &self.prev {
                Some(v) => std::env::set_var("SPT_HOME", v),
                None => std::env::remove_var("SPT_HOME"),
            }
        }
    }

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

    #[test] // [unit->REQ-START-2] a valid seed (live anchor) binds a live perch.
    fn valid_seed_binds_live_perch() {
        let _h = Home::set();
        // Use this very process as the (alive) anchor.
        let anchor = std::process::id();
        write_seed(anchor, "sid-1");
        let token = bind_from_seed("alice", anchor).expect("bind");
        assert_eq!(token.len(), 32);

        let perch_path = perch::resolve_perch_path("alice", ParentHint::Infer);
        let rec = info::read_info(&perch_path).expect("info.json");
        assert_eq!(rec.session_id, "sid-1");
        assert_eq!(rec.state, "live_agent");
        assert_eq!(rec.parent_pid, Some(anchor));
        assert!(perch::resolve_ready_file("alice", ParentHint::Infer).exists());
        // Seed is consumed.
        assert!(seed::read_seed(anchor).is_none());
    }

    #[test] // [unit->REQ-HAZARD-WINDOWS-PID-RECYCLE] a dead-anchor seed never binds.
    fn stale_seed_with_dead_anchor_refused() {
        let _h = Home::set();
        const DEAD: u32 = 2_000_000_000;
        write_seed(DEAD, "sid-1");
        let err = bind_from_seed("alice", DEAD).unwrap_err();
        assert_eq!(err, BindError::StaleSeed(DEAD));
        // No perch was created.
        assert!(info::read_info(&perch::resolve_perch_path("alice", ParentHint::Infer)).is_none());
    }

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

    #[test] // [unit->REQ-START-2] empty session id in the seed is refused.
    fn empty_session_seed_refused() {
        let _h = Home::set();
        let anchor = std::process::id();
        write_seed(anchor, "");
        assert_eq!(bind_from_seed("alice", anchor).unwrap_err(), BindError::EmptySession);
    }

    #[test] // [unit->REQ-HAZARD-WINDOWS-PID-RECYCLE] a live perch under another
    // session is not silently rebound (id-collision guard).
    fn live_perch_under_other_session_conflicts() {
        let _h = Home::set();
        let anchor = std::process::id();
        // First bind establishes alice@sid-1 owned by this (live) process.
        write_seed(anchor, "sid-1");
        bind_from_seed("alice", anchor).unwrap();
        // A second seed tries to claim alice for a different session.
        write_seed(anchor, "sid-2");
        let err = bind_from_seed("alice", anchor).unwrap_err();
        assert_eq!(err, BindError::Conflict { id: "alice".into(), held: "sid-1".into() });
    }

    #[test] // [unit->REQ-SEAM-POSTSPAWN] first-contact bind establishes a live perch.
    fn post_spawn_bind_establishes_perch() {
        let _h = Home::set();
        let code = cmd_bind("bob", Some("sid-boot".into()), None);
        assert_eq!(code, 0);
        let rec = info::read_info(&perch::resolve_perch_path("bob", ParentHint::Infer)).unwrap();
        assert_eq!(rec.session_id, "sid-boot");
        assert_eq!(rec.state, "live_agent");
    }

    #[test] // [unit->REQ-SEAM-POSTSPAWN] re-bind with the same session is idempotent.
    fn rebind_same_session_ok() {
        let _h = Home::set();
        assert_eq!(cmd_bind("bob", Some("sid-boot".into()), None), 0);
        assert_eq!(cmd_bind("bob", Some("sid-boot".into()), None), 0);
    }
}


Now wire startup handlers into dispatch.


Edit

pub mod auth;

use clap::{Args, Subcommand};

pub mod auth;
pub mod startup;

use clap::{Args, Subcommand};


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;

use clap::{Args, Subcommand};

pub mod auth;
pub mod startup;

use clap::{Args, Subcommand};

// [impl->REQ-API-1]
pub mod auth;
+pub mod startup;
use clap::{Args, Subcommand};
use std::path::PathBuf;


T0: Scaffold spt-runtime crate

T1: Manifest schema + parse + validate

T6: Worker perches + cascade-wipe guard

T7: Reporting + boundary surface

T8: Generic mock adapter

T9: Contract E2E test

T10: Activation sweep


    /// Consume a seed and hold the perch + relay loop (interim long-runner, T4).
    Listen {
        id: String,
    },

    /// Consume a seed and hold the perch + relay loop (interim long-runner, T4).
    Listen {
        id: String,
        /// Override the parent-pid anchor (defaults to the self-discovered PPID).
        #[arg(long)]
        parent_pid: Option<u32>,
        /// Drain backlog + one receive cycle, then exit (testability).
        #[arg(long)]
        once: bool,
    },


    /// Consume a seed and hold the perch + relay loop (interim long-runner, T4).
    Listen {
        id: String,
    },

    /// Consume a seed and hold the perch + relay loop (interim long-runner, T4).
    Listen {
        id: String,
        /// Override the parent-pid anchor (defaults to the self-discovered PPID).
        #[arg(long)]
        parent_pid: Option<u32>,
        /// Drain backlog + one receive cycle, then exit (testability).
        #[arg(long)]
        once: bool,
    },

+        /// Override the parent-pid anchor (defaults to the self-discovered PPID).

+        /// Drain backlog + one receive cycle, then exit (testability).

/// Post-spawn bind of a session to its perch (T4).


    match args.cmd {
        ApiCmd::Capability => cmd_capability(&ctx),
        // Mutations gated by auth — handlers land in T4–T7.
        ApiCmd::Bind { id, auth, .. } => gated(&id, &auth, |_| not_implemented("bind")),
        ApiCmd::State { id, auth, .. } => gated(&id, &auth, |_| not_implemented("state")),

    match args.cmd {
        ApiCmd::Capability => cmd_capability(&ctx),
        // Harness-hosted startup (T4). Seed establishes; listen consumes+binds;
        // bind is the post-spawn establishing call (intrinsic auth).
        ApiCmd::Seed { pid, session_id } => startup::cmd_seed(&ctx, pid, session_id),
        ApiCmd::Listen { id, parent_pid, once } => startup::cmd_listen(&id, parent_pid, once),
        ApiCmd::Bind { id, bind_session, auth } => {
            startup::cmd_bind(&id, bind_session, auth.session_id)
        }
        // Mutations gated by auth — handlers land in T5–T7.
        ApiCmd::State { id, auth, .. } => gated(&id, &auth, |_| not_implemented("state")),


    match args.cmd {
        ApiCmd::Capability => cmd_capability(&ctx),
        // Mutations gated by auth — handlers land in T4–T7.
        ApiCmd::Bind { id, auth, .. } => gated(&id, &auth, |_| not_implemented("bind")),
        ApiCmd::State { id, auth, .. } => gated(&id, &auth, |_| not_implemented("state")),

    match args.cmd {
        ApiCmd::Capability => cmd_capability(&ctx),
        // Harness-hosted startup (T4). Seed establishes; listen consumes+binds;
        // bind is the post-spawn establishing call (intrinsic auth).
        ApiCmd::Seed { pid, session_id } => startup::cmd_seed(&ctx, pid, session_id),
        ApiCmd::Listen { id, parent_pid, once } => startup::cmd_listen(&id, parent_pid, once),
        ApiCmd::Bind { id, bind_session, auth } => {
            startup::cmd_bind(&id, bind_session, auth.session_id)
        }
        // Mutations gated by auth — handlers land in T5–T7.
        ApiCmd::State { id, auth, .. } => gated(&id, &auth, |_| not_implemented("state")),

-        // Mutations gated by auth — handlers land in T4–T7.

-        ApiCmd::Bind { id, auth, .. } => gated(&id, &auth, |_| not_implemented("bind")),

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

+            startup::cmd_bind(&id, bind_session, auth.session_id)

+        // Mutations gated by auth — handlers land in T5–T7.

ApiCmd::EchoGate { id, auth, .. } => gated(&id, &auth, |_| not_implemented("echo-gate")),

ApiCmd::Poll { id, auth, .. } => gated(&id, &auth, |_| not_implemented("poll")),


        ApiCmd::Emit { id, auth, .. } => gated(&id, &auth, |_| not_implemented("emit")),
        // Startup commands establish their own auth (seed has no prior perch).
        ApiCmd::Seed { .. } => not_implemented("seed"),
        ApiCmd::Listen { .. } => not_implemented("listen"),
    }
}

        ApiCmd::Emit { id, auth, .. } => gated(&id, &auth, |_| not_implemented("emit")),
    }
}


        ApiCmd::Emit { id, auth, .. } => gated(&id, &auth, |_| not_implemented("emit")),
        // Startup commands establish their own auth (seed has no prior perch).
        ApiCmd::Seed { .. } => not_implemented("seed"),
        ApiCmd::Listen { .. } => not_implemented("listen"),
    }
}

        ApiCmd::Emit { id, auth, .. } => gated(&id, &auth, |_| not_implemented("emit")),
    }
}

ApiCmd::Presence { id, auth, .. } => gated(&id, &auth, |_| not_implemented("presence")),

ApiCmd::HistoryLog { id, auth, .. } => gated(&id, &auth, |_| not_implemented("history-log")),

-        // Startup commands establish their own auth (seed has no prior perch).

-        ApiCmd::Seed { .. } => not_implemented("seed"),

-        ApiCmd::Listen { .. } => not_implemented("listen"),


Bash

cd "C:\Users\decid\Documents\projects\spt-core" && cargo test -p spt -p spt-store 2>&1 | tail -30

Build + test spt + spt-store


 right: Refused
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

---- api::startup::tests::valid_seed_binds_live_perch stdout ----

thread 'api::startup::tests::valid_seed_binds_live_perch' (57936) panicked at crates\spt\src\api\startup.rs:297:48:
info.json

---- api::auth::tests::token_proof_authenticates stdout ----

thread 'api::auth::tests::token_proof_authenticates' (58656) panicked at crates\spt\src\api\auth.rs:173:9:
assertion `left == right` failed
  left: NoEndpoint
 right: Ok

---- api::startup::tests::live_perch_under_other_session_conflicts stdout ----

thread 'api::startup::tests::live_perch_under_other_session_conflicts' (47220) panicked at crates\spt\src\api\startup.rs:342:51:
called `Result::unwrap_err()` on an `Ok` value: "cc285ef7249331723b3a5c164cb49995"

failures:
    api::auth::tests::no_proof_is_refused
    api::auth::tests::token_proof_authenticates
    api::startup::tests::live_perch_under_other_session_conflicts
    api::startup::tests::valid_seed_binds_live_perch

test result: FAILED. 16 passed; 4 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s

error: test failed, to rerun pass `-p spt --bin spt`


Test-home env race — auth & startup modules use separate locks. Check the shared `test_home` helper.


cd "C:\Users\decid\Documents\projects\spt-core" && grep -rn "test_home\|pub fn isolate\|mod test_home" crates/spt-msg/src/lib.rs crates/spt-msg/src/*.rs | head; echo "===file==="; find crates/spt-msg -name "test_home*"; cat crates/spt-msg/src/test_home.rs 2>/dev/null | head -50

Inspect test_home helper


crates/spt-msg/src/lib.rs:30:/// [`test_home::isolate`] first so it operates in a throwaway temp dir, never
crates/spt-msg/src/lib.rs:33:pub(crate) mod test_home {
crates/spt-msg/src/deliver.rs:191:        crate::test_home::isolate();
crates/spt-msg/src/deliver.rs:218:        crate::test_home::isolate();
crates/spt-msg/src/deliver.rs:238:        crate::test_home::isolate();
crates/spt-msg/src/deliver.rs:263:        crate::test_home::isolate();
crates/spt-msg/src/deliver.rs:287:        crate::test_home::isolate();
crates/spt-msg/src/deliver.rs:308:        crate::test_home::isolate();
crates/spt-msg/src/lib.rs:30:/// [`test_home::isolate`] first so it operates in a throwaway temp dir, never
crates/spt-msg/src/lib.rs:33:pub(crate) mod test_home {
===file===


20	pub mod listener;
21	pub mod ready;
22	pub mod ring;
23	pub mod wire;
24
25	/// Test-only home isolation. The listener/ready/ring surfaces resolve the
26	/// owlery through `spt_store::perch::owlery_dir()`, which honors `$SPT_HOME`.
27	/// Without an override the default home is `%LOCALAPPDATA%\spt-core` — distinct
28	/// from the modern spt runtime's `spt/` home, but still a real shared dir that
29	/// must not collect test debris. Every unit test that touches a perch MUST call
30	/// [`test_home::isolate`] first so it operates in a throwaway temp dir, never
31	/// the real one.
32	#[cfg(test)]
33	pub(crate) mod test_home {
34	    use std::sync::OnceLock;
35	    use tempfile::TempDir;
36
37	    static HOME: OnceLock<TempDir> = OnceLock::new();
38
39	    /// Point `$SPT_HOME` at an isolated temp dir for the whole test process.
40	    /// Idempotent: the first caller wins, every later caller blocks until the
41	    /// env is set, so no test performs a perch op against the default home.
42	    pub(crate) fn isolate() {
43	        HOME.get_or_init(|| {
44	            let dir = TempDir::new().expect("temp SPT_HOME");
45	            std::env::set_var("SPT_HOME", dir.path());
46	            dir
47	        });
48	    }
49	}
50

pub mod listener;
pub mod ready;
pub mod ring;
pub mod wire;

/// Test-only home isolation. The listener/ready/ring surfaces resolve the
/// owlery through `spt_store::perch::owlery_dir()`, which honors `$SPT_HOME`.
/// Without an override the default home is `%LOCALAPPDATA%\spt-core` — distinct
/// from the modern spt runtime's `spt/` home, but still a real shared dir that
/// must not collect test debris. Every unit test that touches a perch MUST call
/// [`test_home::isolate`] first so it operates in a throwaway temp dir, never
/// the real one.
#[cfg(test)]
pub(crate) mod test_home {
    use std::sync::OnceLock;
    use tempfile::TempDir;

    static HOME: OnceLock<TempDir> = OnceLock::new();

    /// Point `$SPT_HOME` at an isolated temp dir for the whole test process.
    /// Idempotent: the first caller wins, every later caller blocks until the
    /// env is set, so no test performs a perch op against the default home.
    pub(crate) fn isolate() {
        HOME.get_or_init(|| {
            let dir = TempDir::new().expect("temp SPT_HOME");
            std::env::set_var("SPT_HOME", dir.path());
            dir
        });
    }
}


`test_home` is `pub(crate)` to spt-msg only. The races are within the spt test binary (separate module locks). Add one shared lock for the spt crate's tests.


Write

//! Shared test-only home isolation for the `spt` binary's unit tests.
//!
//! Several test modules (`api::auth`, `api::startup`, …) mutate process-wide env
//! (`SPT_HOME` + the auth-proof vars) and touch perches under the owlery. A
//! per-module lock is not enough — tests in *different* modules run on different
//! threads of the same test process and would clobber each other's `SPT_HOME`.
//! [`isolated_home`] serializes them on one process-wide lock and hands each a
//! fresh throwaway home, restoring the previous value on drop.

use std::sync::{Mutex, MutexGuard};
use tempfile::TempDir;

static LOCK: Mutex<()> = Mutex::new(());

/// RAII guard: holds the global test lock and points `SPT_HOME` at a fresh temp
/// dir for the test's duration. Also clears the auth-proof env so a prior
/// test's `$OWL_SESSION_ID` / `$SPT_API_TOKEN` can't leak into a fallback.
pub struct HomeGuard {
    _lock: MutexGuard<'static, ()>,
    _tmp: TempDir,
    prev_home: Option<String>,
}

impl Drop for HomeGuard {
    fn drop(&mut self) {
        match &self.prev_home {
            Some(v) => std::env::set_var("SPT_HOME", v),
            None => std::env::remove_var("SPT_HOME"),
        }
    }
}

/// Acquire an isolated home for a test. Blocks until any other env-touching
/// test releases its guard.
pub fn isolated_home() -> HomeGuard {
    let lock = LOCK.lock().unwrap_or_else(|e| e.into_inner());
    let tmp = TempDir::new().expect("temp SPT_HOME");
    let prev_home = std::env::var("SPT_HOME").ok();
    std::env::set_var("SPT_HOME", tmp.path());
    std::env::remove_var("SPT_API_TOKEN");
    std::env::remove_var("OWL_SESSION_ID");
    HomeGuard { _lock: lock, _tmp: tmp, prev_home }
}


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

//! Shared test-only home isolation for the `spt` binary's unit tests.
//!
//! Several test modules (`api::auth`, `api::startup`, …) mutate process-wide env
//! (`SPT_HOME` + the auth-proof vars) and touch perches under the owlery. A
//! per-module lock is not enough — tests in *different* modules run on different
//! threads of the same test process and would clobber each other's `SPT_HOME`.
//! [`isolated_home`] serializes them on one process-wide lock and hands each a
//! fresh throwaway home, restoring the previous value on drop.

use std::sync::{Mutex, MutexGuard};
use tempfile::TempDir;

static LOCK: Mutex<()> = Mutex::new(());

/// RAII guard: holds the global test lock and points `SPT_HOME` at a fresh temp
/// dir for the test's duration. Also clears the auth-proof env so a prior
/// test's `$OWL_SESSION_ID` / `$SPT_API_TOKEN` can't leak into a fallback.
pub struct HomeGuard {
    _lock: MutexGuard<'static, ()>,
    _tmp: TempDir,
    prev_home: Option<String>,
}

impl Drop for HomeGuard {
    fn drop(&mut self) {
        match &self.prev_home {
            Some(v) => std::env::set_var("SPT_HOME", v),
            None => std::env::remove_var("SPT_HOME"),
        }
    }
}

/// Acquire an isolated home for a test. Blocks until any other env-touching
/// test releases its guard.
pub fn isolated_home() -> HomeGuard {
    let lock = LOCK.lock().unwrap_or_else(|e| e.into_inner());
    let tmp = TempDir::new().expect("temp SPT_HOME");
    let prev_home = std::env::var("SPT_HOME").ok();
    std::env::set_var("SPT_HOME", tmp.path());
    std::env::remove_var("SPT_API_TOKEN");
    std::env::remove_var("OWL_SESSION_ID");
    HomeGuard { _lock: lock, _tmp: tmp, prev_home }
}


Edit

mod api;
mod cli;
mod roster;

#[cfg(test)]
mod testutil;


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

mod api;
mod cli;
mod roster;

#[cfg(test)]
mod testutil;

//! `spt` — the single spt-core binary. A **thin** command layer over `spt-msg`:
//! it owns I/O (stdin/stdout/stderr) and process exit codes; all messaging
//! logic lives in the crate (clean-room separation per the M1 plan).
//!
//! Subcommands (REQ-MSG-2 stable surface): `send` · `ring` · `ready` · `poll` ·
//! `list` · `stop` · `whoami`. The harness-contract inbound surface lives under
//! the `api` group (REQ-API-1), dispatched from [`api`].
// [impl->REQ-MSG-2]

mod api;
mod cli;
mod roster;

fn main() {
    let code = cli::run();
    std::process::exit(code);
}

mod cli;
mod roster;
+#[cfg(test)]
+mod testutil;
+
fn main() {
let code = cli::run();
std::process::exit(code);


Now refactor auth.rs and startup.rs tests to use the shared guard.


#[cfg(test)]
mod tests {
    use super::*;
    use spt_store::info::InfoJson;
    use std::sync::Mutex;

    static ENV_LOCK: Mutex<()> = Mutex::new(());

    /// Set SPT_HOME to an isolated temp dir for the duration of a test (the
    /// owlery resolver reads it). Also clears the auth env so fallbacks don't
    /// leak between tests.
    struct Home {
        _lock: std::sync::MutexGuard<'static, ()>,
        _tmp: tempfile::TempDir,
        prev_home: Option<String>,
    }
    impl Home {
        fn set() -> Self {
            let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
            let tmp = tempfile::tempdir().unwrap();
            let prev_home = std::env::var("SPT_HOME").ok();
            std::env::set_var("SPT_HOME", tmp.path());
            std::env::remove_var("SPT_API_TOKEN");
            std::env::remove_var("OWL_SESSION_ID");
            Home { _lock: lock, _tmp: tmp, prev_home }
        }
    }
    impl Drop for Home {
        fn drop(&mut self) {
            match &self.prev_home {
                Some(v) => std::env::set_var("SPT_HOME", v),
                None => std::env::remove_var("SPT_HOME"),
            }
        }
    }

    fn establish(id: &str, session_id: &str) -> String {

#[cfg(test)]
mod tests {
    use super::*;
    use crate::testutil::isolated_home;
    use spt_store::info::InfoJson;

    fn establish(id: &str, session_id: &str) -> String {