370
371	This is the core meaning of "the same agent on multiple machines": same identity, native local materialization on each, with the mind kept in sync. The ID is the shared identity for addressing; files are local; the mind follows.
372
373	**dormant / suspended instance:**
374	At most one instance of an endpoint is typically the *actively-driven* one; the others rest. Two resting states:
375	- **dormant** (default, *warm*): a live, state-preserved seat whose harness session stays running → instant re-activation. The default resting state.
376	- **suspended** (opt-in, *cold*): the harness session is closed and resumed-on-wake → frees RAM/compute (and, for billable harnesses, footprint), at the cost of a resume on wake. Reached via per-endpoint opt-in *auto-suspend*, or on demand: a subcommand can **suspend an endpoint from anywhere** (any node).
377
378	**State transitions (no idle timer):**
379	- **active** = the instance is the **most-recently-interacted instance for that ID** *and* **has a driver attached**. Both conditions. There is **no idle-grace timer** — a session stays active as long as it holds those two properties. A **linked Shell counts as a driver** (an agent with a live shell stays active even when the user isn't typing).
380	- **active → dormant** — the driver detaches, *or* another instance of the same ID becomes the most-recently-interacted one (attention shifts). No explicit stop needed to switch; driving `ling@laptop` makes `ling@desktop` dormant.
381	- **dormant → suspended** — manual (`spt suspend` / `spt shutdown` / shell `api owner-shutdown`), *or* opt-in auto-suspend after an `auto-suspend-after` threshold **counted from the moment the instance went dormant**.
382	- **wake (→ active)** — `spt wake`, a driver attaches, or a shell wake-watcher fires.
383
384	A resting instance retains its files + last context and is re-activatable in place — a lightweight **wake** (state's already there), distinct from instantiate-anywhere's fresh spawn. Resting instances remain addressable by node (`ling@desktop`). Registry status: **active / dormant / suspended / offline** (offline = node unreachable).
385
386	**Default policy:** warm (dormant) is the default when undriven. **Auto-suspend is opt-in, default OFF globally**, but **node-overridable** (a handheld / Pi / resource-constrained node may default it ON) and **per-endpoint overridable** (e.g. a billable harness whose warm session holds a cost footprint). Thresholds (`auto-suspend-after`) are config knobs: global default → node override → endpoint override.
387
388	**Commands** (user/agent-initiated lifecycle, unprefixed): `spt suspend <id[@node]>` (suspend any of your instances from any node), `spt wake <id[@node]>` (explicitly wake a dormant/suspended instance), `spt shutdown` (an agent suspends its *own* endpoint — graceful, fires the suspend boundary signoff, cascades shells offline), `spt refresh` (spt-hosted only — an agent clears + resumes itself without stalling: `/clear` → commune capture → guaranteed post-clear resume signal so its turn restarts from immediate next-steps; see `docs/CONTEXT-MEMORY.md`).
389
390	**`spt rename <id> <new_id>`** (endpoint rename, **rippled**): change an endpoint's logical ID and propagate the change to **all its instances** subnet-wide — the registry entries, every node's perch, and the synced context branches (`a-<id>` → `a-<new_id>`, and project-view references). The rename is one logical operation against the eventually-consistent registry; it must collision-check `new_id` against every subnet the endpoint is advertised into (the join-time check above), and reconcile concurrent renames by the same precedence marker the context merge uses (node + newest-wins, KNOWN-HAZARDS 6.5). The adapter-agnostic, node-anchored identity (ADR-0003) is what makes a clean rename possible; without it the ID would be entangled with per-adapter or per-session state.
391
392	**`spt fork <id> [--into <subnet>] [--as <new_id>] [--delete-source]`** (cross-subnet clone):
393	Clone an endpoint into a **new, identity-distinct endpoint** on another subnet — the sanctioned way to place an agent's mind in a different subnet (home subnet being immutable). The fork's **home = the target subnet**; it is **seeded with a one-time copy** of the source's mind (the live + project *Cross-node Psyche sync* tiers), after which the two **diverge** — there is **no ongoing sync**. A fork is therefore **not an instance**: an *instance* is the **same** identity on another node with a **synced** mind; a *fork* is a **new** identity with a **copied-then-independent** mind. The new id defaults to the source's bare name if free in the target subnet (the join-time collision check; else prompt/rename); the adapter follows the normal creation rule (*adapter selection*). The **source is untouched**, optionally deleted afterward (`--delete-source`, or a later delete). Distinct from **rename** (same identity, new label, rippled) and **instantiate-anywhere** (same identity, new *node*, consent-gated).
394
395	**transition echo commune:** any active → (dormant | suspended) transition fires an **echo commune** capturing the outgoing instance's final context delta; that commune syncs to whichever instance becomes active next. This is the finer mechanism behind catch-up-on-activation (and mirrors the sister project's "echo commune before signoff" pattern — KNOWN-HAZARDS 3.3).
396
397	**deferred-message gate:** deferred (spool-only, hook-consumer) messages are **not** delivered to a dormant or suspended instance — they hold until it is active again. (Extends the deferred-row semantics, KNOWN-HAZARDS 1.4/4.4, with an instance-state gate.)
398
399	**Remote-control vs local operation (two distinct modes — not the same as instances):**
400	- **Operate locally:** drive the native instance on *your* machine (its local files, its synced mind). The normal case.
401	- **Remote-control (Shell-like):** attach a control/view surface to an instance *running on another node* — compute + files stay remote; you are a viewport (the byte-stream terminal attach, daemon-to-daemon over Iroh). Used when you specifically want *that machine's* environment. This is effectively a Shell (a driven surface, user→agent direction), separate from the instance concept itself.
402	- **Smooth handoff (remote-control → local):** no teardown required. `git pull` brings the files; the **fresh-with-preload resume seam** preloads the other instance's latest synced context (psyche-download); the remote instance simply goes dormant. The mind follows; the files are local.
403
404	### Pieces the Instances model requires

This is the core meaning of "the same agent on multiple machines": same identity, native local materialization on each, with the mind kept in sync. The ID is the shared identity for addressing; files are local; the mind follows.

**dormant / suspended instance:**
At most one instance of an endpoint is typically the *actively-driven* one; the others rest. Two resting states:
- **dormant** (default, *warm*): a live, state-preserved seat whose harness session stays running → instant re-activation. The default resting state.
- **suspended** (opt-in, *cold*): the harness session is closed and resumed-on-wake → frees RAM/compute (and, for billable harnesses, footprint), at the cost of a resume on wake. Reached via per-endpoint opt-in *auto-suspend*, or on demand: a subcommand can **suspend an endpoint from anywhere** (any node).

**State transitions (no idle timer):**
- **active** = the instance is the **most-recently-interacted instance for that ID** *and* **has a driver attached**. Both conditions. There is **no idle-grace timer** — a session stays active as long as it holds those two properties. A **linked Shell counts as a driver** (an agent with a live shell stays active even when the user isn't typing).
- **active → dormant** — the driver detaches, *or* another instance of the same ID becomes the most-recently-interacted one (attention shifts). No explicit stop needed to switch; driving `ling@laptop` makes `ling@desktop` dormant.
- **dormant → suspended** — manual (`spt suspend` / `spt shutdown` / shell `api owner-shutdown`), *or* opt-in auto-suspend after an `auto-suspend-after` threshold **counted from the moment the instance went dormant**.
- **wake (→ active)** — `spt wake`, a driver attaches, or a shell wake-watcher fires.

A resting instance retains its files + last context and is re-activatable in place — a lightweight **wake** (state's already there), distinct from instantiate-anywhere's fresh spawn. Resting instances remain addressable by node (`ling@desktop`). Registry status: **active / dormant / suspended / offline** (offline = node unreachable).

**Default policy:** warm (dormant) is the default when undriven. **Auto-suspend is opt-in, default OFF globally**, but **node-overridable** (a handheld / Pi / resource-constrained node may default it ON) and **per-endpoint overridable** (e.g. a billable harness whose warm session holds a cost footprint). Thresholds (`auto-suspend-after`) are config knobs: global default → node override → endpoint override.

**Commands** (user/agent-initiated lifecycle, unprefixed): `spt suspend <id[@node]>` (suspend any of your instances from any node), `spt wake <id[@node]>` (explicitly wake a dormant/suspended instance), `spt shutdown` (an agent suspends its *own* endpoint — graceful, fires the suspend boundary signoff, cascades shells offline), `spt refresh` (spt-hosted only — an agent clears + resumes itself without stalling: `/clear` → commune capture → guaranteed post-clear resume signal so its turn restarts from immediate next-steps; see `docs/CONTEXT-MEMORY.md`).

**`spt rename <id> <new_id>`** (endpoint rename, **rippled**): change an endpoint's logical ID and propagate the change to **all its instances** subnet-wide — the registry entries, every node's perch, and the synced context branches (`a-<id>` → `a-<new_id>`, and project-view references). The rename is one logical operation against the eventually-consistent registry; it must collision-check `new_id` against every subnet the endpoint is advertised into (the join-time check above), and reconcile concurrent renames by the same precedence marker the context merge uses (node + newest-wins, KNOWN-HAZARDS 6.5). The adapter-agnostic, node-anchored identity (ADR-0003) is what makes a clean rename possible; without it the ID would be entangled with per-adapter or per-session state.

**`spt fork <id> [--into <subnet>] [--as <new_id>] [--delete-source]`** (cross-subnet clone):
Clone an endpoint into a **new, identity-distinct endpoint** on another subnet — the sanctioned way to place an agent's mind in a different subnet (home subnet being immutable). The fork's **home = the target subnet**; it is **seeded with a one-time copy** of the source's mind (the live + project *Cross-node Psyche sync* tiers), after which the two **diverge** — there is **no ongoing sync**. A fork is therefore **not an instance**: an *instance* is the **same** identity on another node with a **synced** mind; a *fork* is a **new** identity with a **copied-then-independent** mind. The new id defaults to the source's bare name if free in the target subnet (the join-time collision check; else prompt/rename); the adapter follows the normal creation rule (*adapter selection*). The **source is untouched**, optionally deleted afterward (`--delete-source`, or a later delete). Distinct from **rename** (same identity, new label, rippled) and **instantiate-anywhere** (same identity, new *node*, consent-gated).

**transition echo commune:** any active → (dormant | suspended) transition fires an **echo commune** capturing the outgoing instance's final context delta; that commune syncs to whichever instance becomes active next. This is the finer mechanism behind catch-up-on-activation (and mirrors the sister project's "echo commune before signoff" pattern — KNOWN-HAZARDS 3.3).

**deferred-message gate:** deferred (spool-only, hook-consumer) messages are **not** delivered to a dormant or suspended instance — they hold until it is active again. (Extends the deferred-row semantics, KNOWN-HAZARDS 1.4/4.4, with an instance-state gate.)

**Remote-control vs local operation (two distinct modes — not the same as instances):**
- **Operate locally:** drive the native instance on *your* machine (its local files, its synced mind). The normal case.
- **Remote-control (Shell-like):** attach a control/view surface to an instance *running on another node* — compute + files stay remote; you are a viewport (the byte-stream terminal attach, daemon-to-daemon over Iroh). Used when you specifically want *that machine's* environment. This is effectively a Shell (a driven surface, user→agent direction), separate from the instance concept itself.
- **Smooth handoff (remote-control → local):** no teardown required. `git pull` brings the files; the **fresh-with-preload resume seam** preloads the other instance's latest synced context (psyche-download); the remote instance simply goes dormant. The mind follows; the files are local.

### Pieces the Instances model requires


1	//! The daemon process entrypoint + auto-start (B6).
2	//!
3	//! [`Daemon::run`] is the body of the hidden `spt daemon` subcommand: it binds
4	//! the broker (B1/B2 PTY hosting) and the seed-control channel (B6) under the
5	//! canonical per-home names ([`crate::endpoint`]) and serves both for the life
6	//! of the process.
7	//!
8	//! [`ensure_running`] is the auto-start seam (REQ-DAEMON-3): any `api`
9	//! invocation calls it; if no daemon answers the seed-control ping it spawns
10	//! `spt daemon` detached and waits (bounded) for it to come up. The reliable
11	//! anchor is the seed channel — bound for the whole daemon lifetime, so a ping
12	//! against it is a true liveness test (`listen` is the interim model's anchor;
13	//! here the seed channel plays that role for cold-start detection).
14	// [impl->REQ-DAEMON-3]
15
16	use std::io;
17	use std::process::{Command, Stdio};
18	use std::sync::Arc;
19	use std::thread;
20	use std::time::Duration;
21
22	use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
23
24	use crate::broker::{default_journal_path, Broker};
25	use crate::config::{DaemonConfig, RelayChoice};
26	use crate::digesthub::{self, DigestHub};
27	use crate::endpoint::{broker_socket_name, daemon_pid_path, digest_socket_name, seed_socket_name};
28	use crate::nethost::{NetConfig, NetHost};
29	use crate::seedmap::{self, SeedRegistry};
30
31	/// How long auto-start waits for a freshly-spawned daemon to bind its channel.
32	const START_TIMEOUT: Duration = Duration::from_secs(5);
33	/// Poll cadence while waiting for the spawned daemon to come up.
34	const START_POLL: Duration = Duration::from_millis(25);
35
36	/// The per-machine broker/brain daemon process.
37	pub struct Daemon;
38
39	impl Daemon {
40	    /// Run the daemon: bind the broker (served on a background thread) + the
41	    /// seed-control channel (the foreground loop + liveness anchor) and serve
42	    /// until the process is killed. The body of `spt daemon`.
43	    pub fn run() -> io::Result<()> {
44	        let reg = Arc::new(SeedRegistry::new());
45
46	        // The broker's network host (D4a, ADR-0004 §B): the node's iroh endpoint
47	        // + live QUIC connections live in the broker so they survive a brain
48	        // restart. Identity is the durable node.key (stable name across daemon
49	        // restarts); relay stance comes from daemon.json; mDNS rides the
50	        // endpoint (closing the §B mDNS/relay-session row by construction). A
51	        // net failure degrades to a net-less broker — it must not cost PTY
52	        // hosting (mirrors the broker-bind soft-fail below).
53	        // [impl->REQ-DAEMON-2]
54	        let mut node_hex: Option<String> = None;
55	        let net = {
56	            let cfg = DaemonConfig::load();
57	            let relay = match cfg.relay {
58	                RelayChoice::N0 => RelayPolicy::N0Default,
59	                RelayChoice::Disabled => RelayPolicy::Disabled,
60	            };
61	            match spt_store::nodeid::load_or_create() {
62	                Ok(identity) => {
63	                    node_hex = Some(identity.public_key().to_hex());
64	                    let net_cfg = NetConfig {
65	                        identity,
66	                        relay,
67	                        discovery: LocalDiscovery::Mdns,
68	                        scope: BindScope::All, // production: real interfaces
69	                    };
70	                    match NetHost::start(net_cfg) {
71	                        Ok(host) => Some(host),
72	                        Err(e) => {
73	                            eprintln!("NET_BIND_FAIL: {e} — broker runs net-less");
74	                            None
75	                        }
76	                    }
77	                }
78	                Err(e) => {
79	                    eprintln!("NODE_KEY_FAIL: {e} — broker runs net-less");
80	                    None
81	                }
82	            }
83	        };
84
85	        // The broker hosts PTYs (B1/B2) + the net endpoint (D4a). Serve it on a
86	        // background thread; a bind failure there must not stop the seed channel
87	        // (the B6 surface) — the PTY-hosting consumers arrive in B7/B8.
88	        let net_up = net.is_some();
89	        match Broker::bind_in_with_net(&broker_socket_name(), default_journal_path(), net) {
90	            Ok(broker) => {
91	                thread::spawn(move || {
92	                    let _ = broker.serve();
93	                });
94	                // Inbound net dispatch + outbound peer pump (D9-1): the
95	                // production trigger loops. Only meaningful with a net host —
96	                // a net-less broker accepts nothing and dials nobody.
97	                if net_up {
98	                    let hex = node_hex.clone().unwrap_or_default();
99	                    let registry = Arc::new(crate::registryhost::RegistryHost::new(&hex));
100	                    let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
101	                    let _ = crate::dispatch::spawn_dispatcher(
102	                        broker_socket_name(),
103	                        Arc::clone(&registry),
104	                        Arc::clone(&stop),
105	                    );
106	                    let cfg = DaemonConfig::load();
107	                    if cfg.peer_pump {
108	                        let _ = crate::peerloop::spawn_peer_pump(
109	                            broker_socket_name(),
110	                            registry,
111	                            cfg,
112	                            stop,
113	                        );
114	                    }
115	                }
116	            }
117	            Err(e) => eprintln!("BROKER_BIND_FAIL: {e}"),
118	        }
119
120	        // The digest hub (B8) holds the live per-endpoint PTY-digest projections;
121	        // the digest-control channel serves `spt digest <id>` snapshot pulls +
122	        // delta-stream subscribes against it. Served on a background thread like
123	        // the broker. The per-session feed that *populates* the hub is driven by
124	        // the brain (the parse is logic — ADR-0004 §B), wired as daemon-hosted
125	        // sessions are spawned through the broker; a bare daemon serves the
126	        // channel and answers "no live digest" until a session is hosted.
127	        let digest_hub = Arc::new(DigestHub::new());
128	        {
129	            let hub = Arc::clone(&digest_hub);
130	            thread::spawn(move || {
131	                let _ = digesthub::serve_digest_control(&digest_socket_name(), hub);
132	            });
133	        }
134
135	        // Best-effort breadcrumb: record our pid for management/cleanup. Liveness
136	        // is the socket ping, never this file, so a stale value is harmless.
137	        let _ = std::fs::write(daemon_pid_path(), std::process::id().to_string());
138
139	        // The seed-control channel is the foreground loop. Binding it is what
140	        // arbitrates the single-daemon-per-home invariant: a second daemon racing
141	        // to start fails this bind and exits, leaving the winner serving.
142	        seedmap::serve_seed_control(&seed_socket_name(), reg)
143	    }
144	}
145
146	/// Whether a daemon is already serving the seed-control channel for this home.
147	pub fn is_running() -> bool {
148	    seedmap::ping(&seed_socket_name()).is_ok()
149	}
150
151	/// Auto-start (REQ-DAEMON-3): ensure a daemon is up for this home. A no-op if one
152	/// already answers; otherwise spawn `spt daemon` detached and wait (bounded) for
153	/// it to bind. Idempotent + race-tolerant: if two callers spawn at once, one
154	/// wins the bind and the other exits — and this check passes either way.
155	pub fn ensure_running() -> io::Result<()> {
156	    if is_running() {
157	        return Ok(());
158	    }
159	    spawn_detached()?;
160	    let mut waited = Duration::ZERO;
161	    while waited < START_TIMEOUT {
162	        if is_running() {
163	            return Ok(());
164	        }
165	        thread::sleep(START_POLL);
166	        waited += START_POLL;
167	    }
168	    Err(io::Error::new(
169	        io::ErrorKind::TimedOut,
170	        "daemon did not come up within the start window",
171	    ))
172	}
173
174	/// Spawn `spt daemon` as a detached background process. Inherits the environment
175	/// — so the child resolves the **same** `SPT_HOME`, hence the same canonical
176	/// socket names — but drops the std handles so it outlives the launching CLI
177	/// call. The child is independent (not killed when the parent exits): on unix it
178	/// is reparented to init when the launcher exits; on Windows a child is not in
179	/// the parent's job by default.
180	///
181	/// **Windows handle-inheritance guard.** A caller may capture this process's
182	/// stdout/stderr through a pipe (`Command::output()`, a harness hook reading the
183	/// command's output). On Windows `CreateProcess` is invoked with
184	/// `bInheritHandles = TRUE`, so the long-lived daemon would inherit that pipe's
185	/// write end and hold it open forever — the capturing reader never sees EOF and
186	/// hangs. Unix is immune (Rust marks pipe fds `CLOEXEC`). So before spawning we
187	/// strip `HANDLE_FLAG_INHERIT` from this process's std handles; the daemon then
188	/// cannot inherit them, the pipe closes when *this* process exits, and the
189	/// caller's read completes. Stripping the flag does not affect this process's own
190	/// use of its std handles — only what a child inherits.
191	fn spawn_detached() -> io::Result<()> {
192	    #[cfg(windows)]
193	    block_std_handle_inheritance();
194	    let exe = std::env::current_exe()?;
195	    let mut cmd = Command::new(exe);
196	    cmd.arg("daemon")
197	        .stdin(Stdio::null())
198	        .stdout(Stdio::null())
199	        .stderr(Stdio::null());
200	    #[cfg(windows)]
201	    {
202	        use std::os::windows::process::CommandExt;
203	        // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW: no
204	        // inherited console, no popup window, its own signal group.
205	        cmd.creation_flags(0x0000_0008 | 0x0000_0200 | 0x0800_0000);
206	    }
207	    cmd.spawn()?;
208	    Ok(())
209	}
210
211	/// Clear `HANDLE_FLAG_INHERIT` on this process's std handles so a spawned child
212	/// (the daemon) cannot inherit a pipe a caller is capturing. Declared inline to
213	/// avoid a winapi dependency for these two calls.
214	#[cfg(windows)]
215	fn block_std_handle_inheritance() {
216	    extern "system" {
217	        fn GetStdHandle(nStdHandle: u32) -> isize;
218	        fn SetHandleInformation(hObject: isize, dwMask: u32, dwFlags: u32) -> i32;
219	    }
220	    const STD_INPUT_HANDLE: u32 = 0xFFFF_FFF6; // -10
221	    const STD_OUTPUT_HANDLE: u32 = 0xFFFF_FFF5; // -11
222	    const STD_ERROR_HANDLE: u32 = 0xFFFF_FFF4; // -12
223	    const HANDLE_FLAG_INHERIT: u32 = 0x1;
224	    const INVALID: isize = -1;
225	    unsafe {
226	        for which in [STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE] {
227	            let h = GetStdHandle(which);
228	            if h != 0 && h != INVALID {
229	                // Best-effort: a console handle that rejects the call is harmless.
230	                SetHandleInformation(h, HANDLE_FLAG_INHERIT, 0);
231	            }
232	        }
233	    }
234	}
235
236	#[cfg(test)]
237	mod tests {
238	    use super::*;
239	    use crate::seedmap::{serve_seed_control, SeedRegistry};
240	    use crate::test_home::with_home;
241
242	    // [unit->REQ-DAEMON-3] with no daemon up, is_running() is false; once a
243	    // seed-control server answers, it flips true and ensure_running() is a no-op
244	    // (the spawn branch — which would need the real `spt` binary — is not taken).
245	    #[test]
246	    fn is_running_tracks_the_seed_channel() {
247	        with_home(|_| {
248	            assert!(!is_running(), "no daemon bound yet");
249
250	            let reg = Arc::new(SeedRegistry::new());
251	            let name = seed_socket_name();
252	            let srv = Arc::clone(&reg);
253	            thread::spawn(move || {
254	                let _ = serve_seed_control(&name, srv);
255	            });
256	            for _ in 0..400 {
257	                if is_running() {
258	                    break;
259	                }
260	                thread::sleep(Duration::from_millis(5));
261	            }
262	            assert!(is_running(), "the seed channel answers → daemon is up");
263	            // ensure_running short-circuits (no real process spawn).
264	            ensure_running().expect("ensure is a no-op when already up");
265	        });
266	    }
267	}
268

//! The daemon process entrypoint + auto-start (B6).
//!
//! [`Daemon::run`] is the body of the hidden `spt daemon` subcommand: it binds
//! the broker (B1/B2 PTY hosting) and the seed-control channel (B6) under the
//! canonical per-home names ([`crate::endpoint`]) and serves both for the life
//! of the process.
//!
//! [`ensure_running`] is the auto-start seam (REQ-DAEMON-3): any `api`
//! invocation calls it; if no daemon answers the seed-control ping it spawns
//! `spt daemon` detached and waits (bounded) for it to come up. The reliable
//! anchor is the seed channel — bound for the whole daemon lifetime, so a ping
//! against it is a true liveness test (`listen` is the interim model's anchor;
//! here the seed channel plays that role for cold-start detection).
// [impl->REQ-DAEMON-3]

use std::io;
use std::process::{Command, Stdio};
use std::sync::Arc;
use std::thread;
use std::time::Duration;

use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};

use crate::broker::{default_journal_path, Broker};
use crate::config::{DaemonConfig, RelayChoice};
use crate::digesthub::{self, DigestHub};
use crate::endpoint::{broker_socket_name, daemon_pid_path, digest_socket_name, seed_socket_name};
use crate::nethost::{NetConfig, NetHost};
use crate::seedmap::{self, SeedRegistry};

/// How long auto-start waits for a freshly-spawned daemon to bind its channel.
const START_TIMEOUT: Duration = Duration::from_secs(5);
/// Poll cadence while waiting for the spawned daemon to come up.
const START_POLL: Duration = Duration::from_millis(25);

/// The per-machine broker/brain daemon process.
pub struct Daemon;

impl Daemon {
    /// Run the daemon: bind the broker (served on a background thread) + the
    /// seed-control channel (the foreground loop + liveness anchor) and serve
    /// until the process is killed. The body of `spt daemon`.
    pub fn run() -> io::Result<()> {
        let reg = Arc::new(SeedRegistry::new());

        // The broker's network host (D4a, ADR-0004 §B): the node's iroh endpoint
        // + live QUIC connections live in the broker so they survive a brain
        // restart. Identity is the durable node.key (stable name across daemon
        // restarts); relay stance comes from daemon.json; mDNS rides the
        // endpoint (closing the §B mDNS/relay-session row by construction). A
        // net failure degrades to a net-less broker — it must not cost PTY
        // hosting (mirrors the broker-bind soft-fail below).
        // [impl->REQ-DAEMON-2]
        let mut node_hex: Option<String> = None;
        let net = {
            let cfg = DaemonConfig::load();
            let relay = match cfg.relay {
                RelayChoice::N0 => RelayPolicy::N0Default,
                RelayChoice::Disabled => RelayPolicy::Disabled,
            };
            match spt_store::nodeid::load_or_create() {
                Ok(identity) => {
                    node_hex = Some(identity.public_key().to_hex());
                    let net_cfg = NetConfig {
                        identity,
                        relay,
                        discovery: LocalDiscovery::Mdns,
                        scope: BindScope::All, // production: real interfaces
                    };
                    match NetHost::start(net_cfg) {
                        Ok(host) => Some(host),
                        Err(e) => {
                            eprintln!("NET_BIND_FAIL: {e} — broker runs net-less");
                            None
                        }
                    }
                }
                Err(e) => {
                    eprintln!("NODE_KEY_FAIL: {e} — broker runs net-less");
                    None
                }
            }
        };

        // The broker hosts PTYs (B1/B2) + the net endpoint (D4a). Serve it on a
        // background thread; a bind failure there must not stop the seed channel
        // (the B6 surface) — the PTY-hosting consumers arrive in B7/B8.
        let net_up = net.is_some();
        match Broker::bind_in_with_net(&broker_socket_name(), default_journal_path(), net) {
            Ok(broker) => {
                thread::spawn(move || {
                    let _ = broker.serve();
                });
                // Inbound net dispatch + outbound peer pump (D9-1): the
                // production trigger loops. Only meaningful with a net host —
                // a net-less broker accepts nothing and dials nobody.
                if net_up {
                    let hex = node_hex.clone().unwrap_or_default();
                    let registry = Arc::new(crate::registryhost::RegistryHost::new(&hex));
                    let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
                    let _ = crate::dispatch::spawn_dispatcher(
                        broker_socket_name(),
                        Arc::clone(&registry),
                        Arc::clone(&stop),
                    );
                    let cfg = DaemonConfig::load();
                    if cfg.peer_pump {
                        let _ = crate::peerloop::spawn_peer_pump(
                            broker_socket_name(),
                            registry,
                            cfg,
                            stop,
                        );
                    }
                }
            }
            Err(e) => eprintln!("BROKER_BIND_FAIL: {e}"),
        }

        // The digest hub (B8) holds the live per-endpoint PTY-digest projections;
        // the digest-control channel serves `spt digest <id>` snapshot pulls +
        // delta-stream subscribes against it. Served on a background thread like
        // the broker. The per-session feed that *populates* the hub is driven by
        // the brain (the parse is logic — ADR-0004 §B), wired as daemon-hosted
        // sessions are spawned through the broker; a bare daemon serves the
        // channel and answers "no live digest" until a session is hosted.
        let digest_hub = Arc::new(DigestHub::new());
        {
            let hub = Arc::clone(&digest_hub);
            thread::spawn(move || {
                let _ = digesthub::serve_digest_control(&digest_socket_name(), hub);
            });
        }

        // Best-effort breadcrumb: record our pid for management/cleanup. Liveness
        // is the socket ping, never this file, so a stale value is harmless.
        let _ = std::fs::write(daemon_pid_path(), std::process::id().to_string());

        // The seed-control channel is the foreground loop. Binding it is what
        // arbitrates the single-daemon-per-home invariant: a second daemon racing
        // to start fails this bind and exits, leaving the winner serving.
        seedmap::serve_seed_control(&seed_socket_name(), reg)
    }
}

/// Whether a daemon is already serving the seed-control channel for this home.
pub fn is_running() -> bool {
    seedmap::ping(&seed_socket_name()).is_ok()
}

/// Auto-start (REQ-DAEMON-3): ensure a daemon is up for this home. A no-op if one
/// already answers; otherwise spawn `spt daemon` detached and wait (bounded) for
/// it to bind. Idempotent + race-tolerant: if two callers spawn at once, one
/// wins the bind and the other exits — and this check passes either way.
pub fn ensure_running() -> io::Result<()> {
    if is_running() {
        return Ok(());
    }
    spawn_detached()?;
    let mut waited = Duration::ZERO;
    while waited < START_TIMEOUT {
        if is_running() {
            return Ok(());
        }
        thread::sleep(START_POLL);
        waited += START_POLL;
    }
    Err(io::Error::new(
        io::ErrorKind::TimedOut,
        "daemon did not come up within the start window",
    ))
}

/// Spawn `spt daemon` as a detached background process. Inherits the environment
/// — so the child resolves the **same** `SPT_HOME`, hence the same canonical
/// socket names — but drops the std handles so it outlives the launching CLI
/// call. The child is independent (not killed when the parent exits): on unix it
/// is reparented to init when the launcher exits; on Windows a child is not in
/// the parent's job by default.
///
/// **Windows handle-inheritance guard.** A caller may capture this process's
/// stdout/stderr through a pipe (`Command::output()`, a harness hook reading the
/// command's output). On Windows `CreateProcess` is invoked with
/// `bInheritHandles = TRUE`, so the long-lived daemon would inherit that pipe's
/// write end and hold it open forever — the capturing reader never sees EOF and
/// hangs. Unix is immune (Rust marks pipe fds `CLOEXEC`). So before spawning we
/// strip `HANDLE_FLAG_INHERIT` from this process's std handles; the daemon then
/// cannot inherit them, the pipe closes when *this* process exits, and the
/// caller's read completes. Stripping the flag does not affect this process's own
/// use of its std handles — only what a child inherits.
fn spawn_detached() -> io::Result<()> {
    #[cfg(windows)]
    block_std_handle_inheritance();
    let exe = std::env::current_exe()?;
    let mut cmd = Command::new(exe);
    cmd.arg("daemon")
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::null());
    #[cfg(windows)]
    {
        use std::os::windows::process::CommandExt;
        // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW: no
        // inherited console, no popup window, its own signal group.
        cmd.creation_flags(0x0000_0008 | 0x0000_0200 | 0x0800_0000);
    }
    cmd.spawn()?;
    Ok(())
}

/// Clear `HANDLE_FLAG_INHERIT` on this process's std handles so a spawned child
/// (the daemon) cannot inherit a pipe a caller is capturing. Declared inline to
/// avoid a winapi dependency for these two calls.
#[cfg(windows)]
fn block_std_handle_inheritance() {
    extern "system" {
        fn GetStdHandle(nStdHandle: u32) -> isize;
        fn SetHandleInformation(hObject: isize, dwMask: u32, dwFlags: u32) -> i32;
    }
    const STD_INPUT_HANDLE: u32 = 0xFFFF_FFF6; // -10
    const STD_OUTPUT_HANDLE: u32 = 0xFFFF_FFF5; // -11
    const STD_ERROR_HANDLE: u32 = 0xFFFF_FFF4; // -12
    const HANDLE_FLAG_INHERIT: u32 = 0x1;
    const INVALID: isize = -1;
    unsafe {
        for which in [STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE] {
            let h = GetStdHandle(which);
            if h != 0 && h != INVALID {
                // Best-effort: a console handle that rejects the call is harmless.
                SetHandleInformation(h, HANDLE_FLAG_INHERIT, 0);
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::seedmap::{serve_seed_control, SeedRegistry};
    use crate::test_home::with_home;

    // [unit->REQ-DAEMON-3] with no daemon up, is_running() is false; once a
    // seed-control server answers, it flips true and ensure_running() is a no-op
    // (the spawn branch — which would need the real `spt` binary — is not taken).
    #[test]
    fn is_running_tracks_the_seed_channel() {
        with_home(|_| {
            assert!(!is_running(), "no daemon bound yet");

            let reg = Arc::new(SeedRegistry::new());
            let name = seed_socket_name();
            let srv = Arc::clone(&reg);
            thread::spawn(move || {
                let _ = serve_seed_control(&name, srv);
            });
            for _ in 0..400 {
                if is_running() {
                    break;
                }
                thread::sleep(Duration::from_millis(5));
            }
            assert!(is_running(), "the seed channel answers → daemon is up");
            // ensure_running short-circuits (no real process spawn).
            ensure_running().expect("ensure is a no-op when already up");
        });
    }
}


Recon complete enough to shape slice. Check detach observation point + spt CLI surface:


crates\spt-store\src\info.rs:130:pub fn set_driven_by(perch_path: &Path, node: Option<&str>) -> std::io::Result<()> {
crates\spt-store\src\info.rs:277:    // [unit->REQ-REACH-1] set_driven_by stamps the remote-drive marker onto an
crates\spt-store\src\info.rs:282:    fn set_driven_by_stamps_and_clears() {
crates\spt-store\src\info.rs:288:        set_driven_by(d.path(), Some("cafe1234")).unwrap();
crates\spt-store\src\info.rs:291:        set_driven_by(d.path(), None).unwrap();
crates\spt-store\src\info.rs:296:        assert!(set_driven_by(empty.path(), Some("cafe1234")).is_err());
crates\spt\src\api\reporting.rs:407:        info::set_driven_by(&perch_path, Some("cafe1234")).unwrap();
crates\spt-daemon\src\attach.rs:61:    let _ = spt_store::info::set_driven_by(&perch, origin_node);


19:fn main() {

19:fn main() {


1	//! `spt` — the single spt-core binary. A **thin** command layer over `spt-msg`:
2	//! it owns I/O (stdin/stdout/stderr) and process exit codes; all messaging
3	//! logic lives in the crate (clean-room separation per the M1 plan).
4	//!
5	//! Subcommands (REQ-MSG-2 stable surface): `send` · `ring` · `ready` · `poll` ·
6	//! `list` · `stop` · `whoami`. The harness-contract inbound surface lives under
7	//! the `api` group (REQ-API-1), dispatched from [`api`].
8	// [impl->REQ-MSG-2]
9
10	mod api;
11	mod cli;
12	mod elevation;
13	mod roster;
14	mod wansend;
15
16	#[cfg(test)]
17	mod testutil;
18
19	fn main() {
20	    let code = cli::run();
21	    std::process::exit(code);
22	}
23

//! `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 elevation;
mod roster;
mod wansend;

#[cfg(test)]
mod testutil;

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


1	//! Remote terminal attach (M4-D5b, REQ-INST-8) — serve a byte-stream viewport
2	//! onto a local session for an operator on another node.
3	//!
4	//! The operator's daemon opened a bidi QUIC stream to this node (D4) and sent
5	//! [`AttachRecord::Request`]; this module is the target-side pump between that
6	//! stream and the local broker-hosted session:
7	//!
8	//! - session `Output` chunks → [`AttachRecord::Output`] lines onto the wire
9	//!   (fire-and-forget sends; `Output.seq` carries the dedup authority);
10	//! - inbound [`AttachRecord::Input`] → [`Brain::send_effect`] with the
11	//!   operator-minted `op_id` — exactly-once at the PTY write through the B5
12	//!   journal (a replayed input record cannot double-type);
13	//! - session `Exit` → [`AttachRecord::Exit`] + stream finish.
14	//!
15	//! ## Exactly-once lives at the EDGES, not the wire sends
16	//!
17	//! Stream sends here are deliberately **unjournaled** (`op_id: None`): a
18	//! journaled [`Brain::net_stream_send`] blocks reading events until its ack
19	//! and *discards* everything interleaved — inside a full-duplex pump loop that
20	//! eats the operator's `Input` records (both sides starve; found the hard way
21	//! in `tests/attach.rs`). Idempotency holds without it because each direction
22	//! dedups at its **effect**: the PTY write is journaled by the operator-minted
23	//! `Input.op_id`, and the operator's viewport dedups `Output` records by their
24	//! session-log `seq` (the render cursor). A post-restart re-serve may
25	//! re-transmit output records on the wire — duplicates are dropped at the
26	//! render cursor, the same discipline the local `OutputLog` replay uses
27	//! (REQ-HAZARD-RESTART-IDEMPOTENT at the boundary that matters).
28	//!
29	//! **Compute + files stay here**; the operator is a viewport (CONTEXT
30	//! §Remote-control vs local operation). The access gate runs before anything
31	//! is served: the [`AttachRecord::Request`] is *unsolicited wire inbound*, so
32	//! [`access_check`] sees the **handshake-proven** origin from the broker's
33	//! stream table — never payload bytes (KNOWN-HAZARDS 7.5). Remote-drive of
34	//! your own running instance is ungated by default (ADR-0009 default-open);
35	//! the D5d whitelist drops in at this same call.
36	//!
37	//! Restart survival composes from D4/B5 with **no new machinery**: the broker
38	//! owns the QUIC stream, the session, its `OutputLog`, and the effect journal.
39	//! A restarted target brain re-attaches the session from its output cursor,
40	//! resubscribes the stream from its net cursor, and re-drives journaled sends;
41	//! a restarted operator brain resubscribes and dedups rendered chunks by
42	//! `Output.seq`.
43
44	use std::io;
45
46	use spt_net::net::attach::{AttachDecoder, AttachRecord};
47	use spt_net::net::ndjson;
48	use spt_store::perch::{resolve_perch_path, ParentHint};
49
50	use crate::access::{access_check, AccessDecision, InboundClass};
51	use crate::brain::{Brain, BrokerEvent};
52	use crate::msg::{decode_bytes, encode_bytes};
53
54	/// Stamp (or clear) the endpoint's `driven_by` remote-drive marker — the
55	/// daemon fact REQ-REACH-1 turns detection on: the agent (and the `spt`
56	/// binary) read it off the perch's `info.json` rather than guessing from
57	/// session heuristics. Best-effort: a session served without a perch (raw
58	/// broker tests) simply has nowhere to record it.
59	fn mark_driven_by(endpoint: &str, origin_node: Option<&str>) {
60	    let perch = resolve_perch_path(endpoint, ParentHint::Infer);
61	    let _ = spt_store::info::set_driven_by(&perch, origin_node);
62	}
63
64	/// Why a serve loop returned.
65	#[derive(Debug, Clone, PartialEq, Eq)]
66	pub enum AttachServeOutcome {
67	    /// The access gate refused the requesting origin — nothing was served.
68	    Refused,
69	    /// The session's child exited; the `Exit` record was sent and the stream
70	    /// finished.
71	    Exited(Option<i32>),
72	    /// The operator closed its send side (detached); the viewport ended with
73	    /// the session still running.
74	    Detached,
75	}
76
77	/// Serve one attach stream against this brain's broker, blocking until the
78	/// session exits, the operator detaches, or the gate refuses.
79	///
80	/// `origin_node` MUST be the handshake-proven remote id from the broker's
81	/// stream table (`NetStreamInfo::remote_id_hex`) — never payload bytes
82	/// (REQ-HAZARD-WAN-ORIGIN-AUTH). `endpoint` names the endpoint whose session
83	/// is being attached, for the gate. `net_from_seq` is this brain's resume
84	/// cursor on the attach stream (0 cold — also the safe post-restart choice:
85	/// the replayed `Request`/`Input` records re-gate and dedup at the PTY
86	/// journal, and re-pumped output dedups at the operator's render cursor).
87	// [impl->REQ-INST-8]
88	// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]
89	pub fn serve_attach(
90	    brain: &mut Brain,
91	    stream_id: u64,
92	    endpoint: &str,
93	    origin_node: &str,
94	    net_from_seq: u64,
95	) -> io::Result<AttachServeOutcome> {
96	    brain.net_stream_subscribe(stream_id, net_from_seq)?;
97	    let mut decoder = AttachDecoder::new();
98	    let mut attached = false;
99
100	    loop {
101	        match brain.read_event()? {
102	            // ── Wire → session: requests and input. ────────────────────────
103	            BrokerEvent::NetStreamData { stream_id: sid, bytes, .. } if sid == stream_id => {
104	                for rec in decoder.push(&bytes) {
105	                    match rec {
106	                        AttachRecord::Request { session_id, from_seq } => {
107	                            // Unsolicited wire inbound — the ADR-0009 gate,
108	                            // under the transport-proven origin.
109	                            if access_check(endpoint, origin_node, InboundClass::Unsolicited)
110	                                == AccessDecision::Deny
111	                            {
112	                                brain.net_stream_send(stream_id, &[], None, true)?;
113	                                return Ok(AttachServeOutcome::Refused);
114	                            }
115	                            brain.attach(session_id, from_seq)?;
116	                            attached = true;
117	                            // Detection is a daemon fact (REQ-REACH-1): the
118	                            // served endpoint is now driven from origin_node.
119	                            // Re-stamped by a successor's re-serve after a
120	                            // brain crash (the same replayed Request that

//! Remote terminal attach (M4-D5b, REQ-INST-8) — serve a byte-stream viewport
//! onto a local session for an operator on another node.
//!
//! The operator's daemon opened a bidi QUIC stream to this node (D4) and sent
//! [`AttachRecord::Request`]; this module is the target-side pump between that
//! stream and the local broker-hosted session:
//!
//! - session `Output` chunks → [`AttachRecord::Output`] lines onto the wire
//!   (fire-and-forget sends; `Output.seq` carries the dedup authority);
//! - inbound [`AttachRecord::Input`] → [`Brain::send_effect`] with the
//!   operator-minted `op_id` — exactly-once at the PTY write through the B5
//!   journal (a replayed input record cannot double-type);
//! - session `Exit` → [`AttachRecord::Exit`] + stream finish.
//!
//! ## Exactly-once lives at the EDGES, not the wire sends
//!
//! Stream sends here are deliberately **unjournaled** (`op_id: None`): a
//! journaled [`Brain::net_stream_send`] blocks reading events until its ack
//! and *discards* everything interleaved — inside a full-duplex pump loop that
//! eats the operator's `Input` records (both sides starve; found the hard way
//! in `tests/attach.rs`). Idempotency holds without it because each direction
//! dedups at its **effect**: the PTY write is journaled by the operator-minted
//! `Input.op_id`, and the operator's viewport dedups `Output` records by their
//! session-log `seq` (the render cursor). A post-restart re-serve may
//! re-transmit output records on the wire — duplicates are dropped at the
//! render cursor, the same discipline the local `OutputLog` replay uses
//! (REQ-HAZARD-RESTART-IDEMPOTENT at the boundary that matters).
//!
//! **Compute + files stay here**; the operator is a viewport (CONTEXT
//! §Remote-control vs local operation). The access gate runs before anything
//! is served: the [`AttachRecord::Request`] is *unsolicited wire inbound*, so
//! [`access_check`] sees the **handshake-proven** origin from the broker's
//! stream table — never payload bytes (KNOWN-HAZARDS 7.5). Remote-drive of
//! your own running instance is ungated by default (ADR-0009 default-open);
//! the D5d whitelist drops in at this same call.
//!
//! Restart survival composes from D4/B5 with **no new machinery**: the broker
//! owns the QUIC stream, the session, its `OutputLog`, and the effect journal.
//! A restarted target brain re-attaches the session from its output cursor,
//! resubscribes the stream from its net cursor, and re-drives journaled sends;
//! a restarted operator brain resubscribes and dedups rendered chunks by
//! `Output.seq`.

use std::io;

use spt_net::net::attach::{AttachDecoder, AttachRecord};
use spt_net::net::ndjson;
use spt_store::perch::{resolve_perch_path, ParentHint};

use crate::access::{access_check, AccessDecision, InboundClass};
use crate::brain::{Brain, BrokerEvent};
use crate::msg::{decode_bytes, encode_bytes};

/// Stamp (or clear) the endpoint's `driven_by` remote-drive marker — the
/// daemon fact REQ-REACH-1 turns detection on: the agent (and the `spt`
/// binary) read it off the perch's `info.json` rather than guessing from
/// session heuristics. Best-effort: a session served without a perch (raw
/// broker tests) simply has nowhere to record it.
fn mark_driven_by(endpoint: &str, origin_node: Option<&str>) {
    let perch = resolve_perch_path(endpoint, ParentHint::Infer);
    let _ = spt_store::info::set_driven_by(&perch, origin_node);
}

/// Why a serve loop returned.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AttachServeOutcome {
    /// The access gate refused the requesting origin — nothing was served.
    Refused,
    /// The session's child exited; the `Exit` record was sent and the stream
    /// finished.
    Exited(Option<i32>),
    /// The operator closed its send side (detached); the viewport ended with
    /// the session still running.
    Detached,
}

/// Serve one attach stream against this brain's broker, blocking until the
/// session exits, the operator detaches, or the gate refuses.
///
/// `origin_node` MUST be the handshake-proven remote id from the broker's
/// stream table (`NetStreamInfo::remote_id_hex`) — never payload bytes
/// (REQ-HAZARD-WAN-ORIGIN-AUTH). `endpoint` names the endpoint whose session
/// is being attached, for the gate. `net_from_seq` is this brain's resume
/// cursor on the attach stream (0 cold — also the safe post-restart choice:
/// the replayed `Request`/`Input` records re-gate and dedup at the PTY
/// journal, and re-pumped output dedups at the operator's render cursor).
// [impl->REQ-INST-8]
// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]
pub fn serve_attach(
    brain: &mut Brain,
    stream_id: u64,
    endpoint: &str,
    origin_node: &str,
    net_from_seq: u64,
) -> io::Result<AttachServeOutcome> {
    brain.net_stream_subscribe(stream_id, net_from_seq)?;
    let mut decoder = AttachDecoder::new();
    let mut attached = false;

    loop {
        match brain.read_event()? {
            // ── Wire → session: requests and input. ────────────────────────
            BrokerEvent::NetStreamData { stream_id: sid, bytes, .. } if sid == stream_id => {
                for rec in decoder.push(&bytes) {
                    match rec {
                        AttachRecord::Request { session_id, from_seq } => {
                            // Unsolicited wire inbound — the ADR-0009 gate,
                            // under the transport-proven origin.
                            if access_check(endpoint, origin_node, InboundClass::Unsolicited)
                                == AccessDecision::Deny
                            {
                                brain.net_stream_send(stream_id, &[], None, true)?;
                                return Ok(AttachServeOutcome::Refused);
                            }
                            brain.attach(session_id, from_seq)?;
                            attached = true;
                            // Detection is a daemon fact (REQ-REACH-1): the
                            // served endpoint is now driven from origin_node.
                            // Re-stamped by a successor's re-serve after a
                            // brain crash (the same replayed Request that


120	                            // brain crash (the same replayed Request that
121	                            // re-attaches); cleared when the viewport ends.
122	                            // [impl->REQ-REACH-1]
123	                            mark_driven_by(endpoint, Some(origin_node));
124	                        }
125	                        AttachRecord::Input { data_b64, op_id } if attached => {
126	                            let bytes = decode_bytes(&data_b64)
127	                                .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
128	                            // Exactly-once at the PTY: the operator-minted id
129	                            // rides the B5 journal.
130	                            brain.send_effect(op_id, &bytes)?;
131	                        }
132	                        // Input before Request, or target-direction records
133	                        // echoed back: protocol noise — ignore (degrade).
134	                        _ => {}
135	                    }
136	                }
137	            }
138	            // The operator closed its send side: the viewport is over.
139	            BrokerEvent::NetStreamEof { stream_id: sid, .. } if sid == stream_id => {
140	                brain.net_stream_send(stream_id, &[], None, true)?;
141	                if attached {
142	                    mark_driven_by(endpoint, None);
143	                }
144	                return Ok(AttachServeOutcome::Detached);
145	            }
146
147	            // ── Session → wire: output and exit. ────────────────────────────
148	            BrokerEvent::Output { seq, bytes, .. } if attached => {
149	                let line = ndjson::encode_line(&AttachRecord::Output {
150	                    seq,
151	                    data_b64: encode_bytes(&bytes),
152	                });
153	                // Unjournaled by design (see module docs): `seq` rides inside
154	                // the record, and the operator's render cursor is the dedup.
155	                brain.net_stream_send(stream_id, &line, None, false)?;
156	            }
157	            BrokerEvent::Exit { code, .. } if attached => {
158	                let line = ndjson::encode_line(&AttachRecord::Exit { code });
159	                brain.net_stream_send(stream_id, &line, None, true)?;
160	                mark_driven_by(endpoint, None);
161	                return Ok(AttachServeOutcome::Exited(code));
162	            }
163
164	            BrokerEvent::Error { message } => {
165	                if attached {
166	                    mark_driven_by(endpoint, None);
167	                }
168	                return Err(io::Error::other(message));
169	            }
170	            // Applied/NetSent acks and unrelated events: not ours to handle.
171	            _ => {}
172	        }
173	    }
174	}
175
176	/// Operator-side: open an attach stream on a dialed connection and send the
177	/// [`AttachRecord::Request`]. Returns the stream id the caller then consumes
178	/// ([`AttachRecord::Output`] frames, deduped by `seq`) and writes
179	/// ([`AttachRecord::Input`] with operator-minted op ids). `open_op` is the
180	/// durable id for the exactly-once stream open (safe to block for its ack —
181	/// no attach traffic can be in flight before the stream exists); the Request
182	/// send itself is unjournaled (see module docs — a replayed Request re-gates
183	/// and re-attaches idempotently).
184	// [impl->REQ-INST-8]
185	pub fn request_attach(
186	    brain: &mut Brain,
187	    conn_id: u64,
188	    session_id: u64,
189	    from_seq: u64,
190	    open_op: u64,
191	) -> io::Result<u64> {
192	    let opened = brain.net_open_stream(conn_id, Some(open_op))?;
193	    let line = ndjson::encode_line(&AttachRecord::Request { session_id, from_seq });
194	    brain.net_stream_send(opened.stream_id, &line, None, false)?;
195	    Ok(opened.stream_id)
196	}
197
198	/// Operator-side: send one input record (the keystrokes) onto an attach
199	/// stream. `wire_op` is the operator-minted durable id the TARGET journals the
200	/// PTY write under — exactly-once at the keys, however often the record
201	/// crosses the wire. The send itself is unjournaled (see module docs).
202	// [impl->REQ-INST-8]
203	pub fn send_attach_input(
204	    brain: &mut Brain,
205	    stream_id: u64,
206	    bytes: &[u8],
207	    wire_op: u64,
208	) -> io::Result<()> {
209	    let line = ndjson::encode_line(&AttachRecord::Input {

                            // brain crash (the same replayed Request that
                            // re-attaches); cleared when the viewport ends.
                            // [impl->REQ-REACH-1]
                            mark_driven_by(endpoint, Some(origin_node));
                        }
                        AttachRecord::Input { data_b64, op_id } if attached => {
                            let bytes = decode_bytes(&data_b64)
                                .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                            // Exactly-once at the PTY: the operator-minted id
                            // rides the B5 journal.
                            brain.send_effect(op_id, &bytes)?;
                        }
                        // Input before Request, or target-direction records
                        // echoed back: protocol noise — ignore (degrade).
                        _ => {}
                    }
                }
            }
            // The operator closed its send side: the viewport is over.
            BrokerEvent::NetStreamEof { stream_id: sid, .. } if sid == stream_id => {
                brain.net_stream_send(stream_id, &[], None, true)?;
                if attached {
                    mark_driven_by(endpoint, None);
                }
                return Ok(AttachServeOutcome::Detached);
            }

            // ── Session → wire: output and exit. ────────────────────────────
            BrokerEvent::Output { seq, bytes, .. } if attached => {
                let line = ndjson::encode_line(&AttachRecord::Output {
                    seq,
                    data_b64: encode_bytes(&bytes),
                });
                // Unjournaled by design (see module docs): `seq` rides inside
                // the record, and the operator's render cursor is the dedup.
                brain.net_stream_send(stream_id, &line, None, false)?;
            }
            BrokerEvent::Exit { code, .. } if attached => {
                let line = ndjson::encode_line(&AttachRecord::Exit { code });
                brain.net_stream_send(stream_id, &line, None, true)?;
                mark_driven_by(endpoint, None);
                return Ok(AttachServeOutcome::Exited(code));
            }

            BrokerEvent::Error { message } => {
                if attached {
                    mark_driven_by(endpoint, None);
                }
                return Err(io::Error::other(message));
            }
            // Applied/NetSent acks and unrelated events: not ours to handle.
            _ => {}
        }
    }
}

/// Operator-side: open an attach stream on a dialed connection and send the
/// [`AttachRecord::Request`]. Returns the stream id the caller then consumes
/// ([`AttachRecord::Output`] frames, deduped by `seq`) and writes
/// ([`AttachRecord::Input`] with operator-minted op ids). `open_op` is the
/// durable id for the exactly-once stream open (safe to block for its ack —
/// no attach traffic can be in flight before the stream exists); the Request
/// send itself is unjournaled (see module docs — a replayed Request re-gates
/// and re-attaches idempotently).
// [impl->REQ-INST-8]
pub fn request_attach(
    brain: &mut Brain,
    conn_id: u64,
    session_id: u64,
    from_seq: u64,
    open_op: u64,
) -> io::Result<u64> {
    let opened = brain.net_open_stream(conn_id, Some(open_op))?;
    let line = ndjson::encode_line(&AttachRecord::Request { session_id, from_seq });
    brain.net_stream_send(opened.stream_id, &line, None, false)?;
    Ok(opened.stream_id)
}

/// Operator-side: send one input record (the keystrokes) onto an attach
/// stream. `wire_op` is the operator-minted durable id the TARGET journals the
/// PTY write under — exactly-once at the keys, however often the record
/// crosses the wire. The send itself is unjournaled (see module docs).
// [impl->REQ-INST-8]
pub fn send_attach_input(
    brain: &mut Brain,
    stream_id: u64,
    bytes: &[u8],
    wire_op: u64,
) -> io::Result<()> {
    let line = ndjson::encode_line(&AttachRecord::Input {


TaskCreate

D9-2-1: resting state machine + Status::Suspended + advertise-follows-state

Pure RestState/RestEvent transition core in spt-daemon/resting.rs; info.json rest_state + dormant_since_ms + auto_suspend_after_ms; DaemonConfig auto_suspend_after_ms knob; registry Status::Suspended (additive serde, routable); advertise_local maps rest_state → Status. Activate REQ-INST-3 [impl, unit]. CI green.


Task #1 created successfully: D9-2-1: resting state machine + Status::Suspended + advertise-follows-state


D9-2-2: transition effects — echo edge + wake resurface + freshness pull

Transition host applies events, persists state, fires effects: run_echo_commune on active→(dormant|suspended) edge (KH 3.3, REQ-INST-4 [impl, unit]); wake edge fires resurface_at_boundary + writes freshness-pull marker the peer pump consumes (forces due_sync). KH 7.4: if >1 agent's echo scheduled by daemon, activate REQ-HAZARD-DAEMON-SCHED-NONBLOCKING + off-scheduler echo. CI green.


Task #2 created successfully: D9-2-2: transition effects — echo edge + wake resurface + freshness pull


D9-2-3: production feed points + spt suspend/wake CLI + INST-6 seam doc

Attach arm feeds Wake on Request / Detach on viewport end; registry inbound arm feeds AttentionShift on peer row transition-to-Active for locally hosted id; spt suspend/spt wake local CLI arms; REQ-INST-6 deferred-message gate seam documented (stays inactive). CI green; plan status updated.


Task #3 created successfully: D9-2-3: production feed points + spt suspend/wake CLI + INST-6 seam doc


Updated task #1 status


1	//! **The** single liveness resolver — daemon-authoritative for daemon-hosted
2	//! perches, per-pid for everything else (KNOWN-HAZARDS 2.5,
3	//! REQ-HAZARD-DAEMON-HOSTED-LIVENESS).
4	//!
5	//! ## Why one resolver
6	//! The sister project, and the spt-core **interim** (M1/M2a/M2b) model, evaluate
7	//! a perch's liveness from its owning process pid: `is_process_alive(info.pid)`,
8	//! with `clean_stale_entries` dropping dead-pid registry rows. That is correct
9	//! while every perch has a *dedicated* per-process owner.
10	//!
11	//! Under ADR-0004 the daemon hosts the Psyche (and any spt-hosted Self) as a
12	//! **loop inside the daemon**, not a separate process. Such a perch holds no
13	//! dedicated pid; its `claude`/summarizer subprocess is **ephemeral** (spawned
14	//! per pulse/commune, then exits). If liveness keyed on that pid, killing the
15	//! summarizer would read the whole perch "offline"; if it keyed on the daemon's
16	//! pid, every hosted endpoint would share one pid and `clean_stale_entries`
17	//! could never distinguish a torn-down endpoint from a live one (2.5).
18	//!
19	//! So for **daemon-hosted** perches liveness is the daemon-managed `status` field
20	//! on `info.json` ([`crate::info::set_status`]); for **external/interim** perches
21	//! it stays the per-pid probe. Both decisions live **here**, behind one resolver
22	//! (mirroring how [`crate::registry::resolve_address`] centralizes stale-clean),
23	//! so the daemon swap is localized and the per-pid assumption never leaks into a
24	//! new call site. The three call sites — `deliver::is_online`,
25	//! `ring::from_has_live_perch`, `roster::enumerate` (perch path) and
26	//! `registry::clean_stale_entries` (registry row) — all route through this module.
27	// [impl->REQ-HAZARD-DAEMON-HOSTED-LIVENESS]
28
29	use std::path::Path;
30
31	use serde_json::Value;
32
33	use crate::perch::{self, ParentHint};
34	use crate::proc::is_process_alive;
35
36	/// Daemon-managed `status` value meaning the hosted endpoint is live.
37	pub const STATUS_ONLINE: &str = "online";
38	/// Daemon-managed `status` value meaning the hosted endpoint is torn down.
39	pub const STATUS_OFFLINE: &str = "offline";
40
41	/// Torn-read retry budget (mirrors [`crate::info`]): a reader may hit the file
42	/// mid-replace; retry a few times, then degrade rather than panic.
43	const READ_ATTEMPTS: u32 = 5;
44
45	/// One tolerant read of a perch's `info.json` as a raw value — schema-minimal
46	/// records (`{"pid": N}` from wrapper-owned listeners) parse where the full
47	/// [`crate::info::InfoJson`] shape would not. `None` on persistent absence/corruption.
48	fn read_raw(perch_path: &Path) -> Option<Value> {
49	    let path = perch_path.join("info.json");
50	    for _ in 0..READ_ATTEMPTS {
51	        if let Ok(content) = std::fs::read_to_string(&path) {
52	            if let Ok(val) = serde_json::from_str::<Value>(&content) {
53	                return Some(val);
54	            }
55	        }
56	    }
57	    None
58	}
59
60	/// The daemon-managed `status` of a perch, if it is daemon-hosted. `Some(_)` ⇒
61	/// daemon-hosted (liveness is authoritative via status); `None` ⇒ external /
62	/// interim (liveness is the per-pid probe).
63	fn read_status(perch_path: &Path) -> Option<String> {
64	    read_raw(perch_path)?
65	        .get("status")
66	        .and_then(|v| v.as_str())
67	        .map(str::to_string)
68	}
69
70	/// Liveness for a perch by its **directory**. THE resolver for the path-aware
71	/// call sites (`is_online`, `from_has_live_perch`, roster).
72	///
73	/// - **Daemon-hosted** (`status` present): live iff `status == "online"`. The
74	///   `info.pid` (an ephemeral summarizer pid, or the shared daemon pid) is
75	///   **never** consulted — killing the summarizer does not flip the perch.
76	/// - **External/interim** (`status` absent): the per-pid probe, preserving the
77	///   exact interim semantics — a numeric pid is probed; a `"BUSY"` sentinel or
78	///   unparseable/absent pid reads alive (the listener is up between polls).
79	// [impl->REQ-HAZARD-DAEMON-HOSTED-LIVENESS]
80	pub fn is_perch_alive(perch_path: &Path) -> bool {
81	    match read_raw(perch_path) {
82	        Some(val) => {
83	            if let Some(status) = val.get("status").and_then(|v| v.as_str()) {
84	                return status == STATUS_ONLINE; // daemon-authoritative; never pid
85	            }
86	            match val.get("pid").and_then(|v| v.as_u64()) {
87	                Some(pid) => is_process_alive(pid as u32),
88	                None => true, // BUSY sentinel / unparseable pid — interim parity
89	            }
90	        }
91	        None => true, // unreadable info.json — interim parity (read_pid None ⇒ alive)
92	    }
93	}
94
95	/// Liveness for a **registry row**: its id plus the pid it was registered under,
96	/// resolved within `owlery` (the registry's own root). Daemon-hosted rows key on
97	/// the endpoint `status`; external rows pid-probe the **row's** pid exactly as
98	/// the interim `clean_stale_entries` did — so the daemon swap touches only the
99	/// hosted rows. An offline daemon-hosted endpoint is cleaned even though the
100	/// (shared) daemon pid is still alive; a still-online one survives a dead
101	/// ephemeral pid.
102	// [impl->REQ-HAZARD-DAEMON-HOSTED-LIVENESS]
103	pub fn is_registry_entry_alive(id: &str, registry_pid: u32, owlery: &Path) -> bool {
104	    let perch_path = perch::resolve_perch_path_in(owlery, id, ParentHint::Infer);
105	    match read_status(&perch_path) {
106	        Some(status) => status == STATUS_ONLINE, // daemon-hosted: authoritative
107	        None => is_process_alive(registry_pid),  // external/interim: pid-probe the row
108	    }
109	}
110
111	#[cfg(test)]
112	mod tests {
113	    use super::*;
114	    use crate::info::{set_status, write_info, InfoJson};
115
116	    /// A pid that is never a real allocation (above Linux pid_max ~4.2M and
117	    /// Windows ranges; positive as i32). Deterministic — avoids pid-reuse flake.
118	    const DEAD_PID: u32 = 2_000_000_000;
119
120	    fn perch() -> tempfile::TempDir {
121	        tempfile::tempdir().unwrap()
122	    }
123
124	    // [unit->REQ-HAZARD-DAEMON-HOSTED-LIVENESS] a daemon-hosted perch marked
125	    // `online` reads alive even though its recorded (summarizer) pid is DEAD —
126	    // killing the summarizer subprocess does not flip the perch offline.
127	    #[test]
128	    fn daemon_hosted_online_survives_dead_pid() {
129	        let d = perch();
130	        // Record the ephemeral summarizer pid (now dead) then mark daemon-online.
131	        write_info(d.path(), &InfoJson::new("doyle-psyche", "t", DEAD_PID, "sid", "psyche")).unwrap();
132	        set_status(d.path(), STATUS_ONLINE).unwrap();
133	        assert!(is_perch_alive(d.path()), "online status is authoritative; the dead pid is ignored");
134	    }
135
136	    // [unit->REQ-HAZARD-DAEMON-HOSTED-LIVENESS] a daemon-hosted perch marked
137	    // `offline` reads dead even though its recorded pid is ALIVE (this process).
138	    #[test]
139	    fn daemon_hosted_offline_overrides_live_pid() {
140	        let d = perch();
141	        write_info(d.path(), &InfoJson::new("doyle-psyche", "t", std::process::id(), "sid", "psyche")).unwrap();
142	        set_status(d.path(), STATUS_OFFLINE).unwrap();
143	        assert!(!is_perch_alive(d.path()), "offline status is authoritative; the live pid is ignored");
144	    }
145
146	    // [unit->REQ-HAZARD-DAEMON-HOSTED-LIVENESS] an external/interim perch (no
147	    // status) still pid-probes: a dead pid reads offline, a live pid reads online.
148	    #[test]
149	    fn external_perch_still_pid_probes() {
150	        let dead = perch();
151	        write_info(dead.path(), &InfoJson::new("ext", "t", DEAD_PID, "sid", "ready_agent")).unwrap();
152	        assert!(!is_perch_alive(dead.path()), "external dead pid reads offline");
153
154	        let live = perch();
155	        write_info(live.path(), &InfoJson::new("ext", "t", std::process::id(), "sid", "ready_agent")).unwrap();
156	        assert!(is_perch_alive(live.path()), "external live pid reads online");
157	    }
158
159	    // [unit->REQ-HAZARD-DAEMON-HOSTED-LIVENESS] BUSY sentinel / unreadable record
160	    // keep the interim "alive" parity (listener up between polls).
161	    #[test]
162	    fn busy_and_missing_keep_interim_parity() {
163	        let busy = perch();
164	        crate::atomic::atomic_write_string(&busy.path().join("info.json"), r#"{"pid":"BUSY"}"#).unwrap();
165	        assert!(is_perch_alive(busy.path()), "BUSY sentinel ⇒ alive (interim parity)");
166	        // No info.json at all.
167	        let missing = perch();
168	        assert!(is_perch_alive(missing.path()), "absent info.json ⇒ alive (interim parity)");
169	    }
170
171	    // [unit->REQ-HAZARD-DAEMON-HOSTED-LIVENESS] registry-row liveness: a
172	    // daemon-hosted row keys on status (offline ⇒ stale even with a live daemon
173	    // pid; online ⇒ live even with a dead ephemeral pid); an external row with
174	    // no perch falls back to probing the registered pid.
175	    #[test]
176	    fn registry_entry_keys_on_status_for_hosted_rows() {
177	        let owlery = perch();
178	        let root = owlery.path();
179
180	        // Daemon-hosted offline endpoint, registered under a LIVE (daemon) pid.
181	        let off = perch::resolve_perch_path_in(root, "off", ParentHint::Infer);
182	        std::fs::create_dir_all(&off).unwrap();
183	        write_info(&off, &InfoJson::new("off", "t", std::process::id(), "sid", "psyche")).unwrap();
184	        set_status(&off, STATUS_OFFLINE).unwrap();
185	        assert!(
186	            !is_registry_entry_alive("off", std::process::id(), root),
187	            "offline hosted row is stale despite a live daemon pid"
188	        );
189
190	        // Daemon-hosted online endpoint, recorded summarizer pid is DEAD.
191	        let on = perch::resolve_perch_path_in(root, "on", ParentHint::Infer);
192	        std::fs::create_dir_all(&on).unwrap();
193	        write_info(&on, &InfoJson::new("on", "t", DEAD_PID, "sid", "psyche")).unwrap();
194	        set_status(&on, STATUS_ONLINE).unwrap();
195	        assert!(is_registry_entry_alive("on", DEAD_PID, root), "online hosted row survives a dead pid");
196
197	        // External row (no perch dir / no status): pid-probe the registered pid.
198	        assert!(!is_registry_entry_alive("ext", DEAD_PID, root), "external dead-pid row is stale");
199	        assert!(is_registry_entry_alive("ext", std::process::id(), root), "external live-pid row is alive");
200	    }
201	}
202

//! **The** single liveness resolver — daemon-authoritative for daemon-hosted
//! perches, per-pid for everything else (KNOWN-HAZARDS 2.5,
//! REQ-HAZARD-DAEMON-HOSTED-LIVENESS).
//!
//! ## Why one resolver
//! The sister project, and the spt-core **interim** (M1/M2a/M2b) model, evaluate
//! a perch's liveness from its owning process pid: `is_process_alive(info.pid)`,
//! with `clean_stale_entries` dropping dead-pid registry rows. That is correct
//! while every perch has a *dedicated* per-process owner.
//!
//! Under ADR-0004 the daemon hosts the Psyche (and any spt-hosted Self) as a
//! **loop inside the daemon**, not a separate process. Such a perch holds no
//! dedicated pid; its `claude`/summarizer subprocess is **ephemeral** (spawned
//! per pulse/commune, then exits). If liveness keyed on that pid, killing the
//! summarizer would read the whole perch "offline"; if it keyed on the daemon's
//! pid, every hosted endpoint would share one pid and `clean_stale_entries`
//! could never distinguish a torn-down endpoint from a live one (2.5).
//!
//! So for **daemon-hosted** perches liveness is the daemon-managed `status` field
//! on `info.json` ([`crate::info::set_status`]); for **external/interim** perches
//! it stays the per-pid probe. Both decisions live **here**, behind one resolver
//! (mirroring how [`crate::registry::resolve_address`] centralizes stale-clean),
//! so the daemon swap is localized and the per-pid assumption never leaks into a
//! new call site. The three call sites — `deliver::is_online`,
//! `ring::from_has_live_perch`, `roster::enumerate` (perch path) and
//! `registry::clean_stale_entries` (registry row) — all route through this module.
// [impl->REQ-HAZARD-DAEMON-HOSTED-LIVENESS]

use std::path::Path;

use serde_json::Value;

use crate::perch::{self, ParentHint};
use crate::proc::is_process_alive;

/// Daemon-managed `status` value meaning the hosted endpoint is live.
pub const STATUS_ONLINE: &str = "online";
/// Daemon-managed `status` value meaning the hosted endpoint is torn down.
pub const STATUS_OFFLINE: &str = "offline";

/// Torn-read retry budget (mirrors [`crate::info`]): a reader may hit the file
/// mid-replace; retry a few times, then degrade rather than panic.
const READ_ATTEMPTS: u32 = 5;

/// One tolerant read of a perch's `info.json` as a raw value — schema-minimal
/// records (`{"pid": N}` from wrapper-owned listeners) parse where the full
/// [`crate::info::InfoJson`] shape would not. `None` on persistent absence/corruption.
fn read_raw(perch_path: &Path) -> Option<Value> {
    let path = perch_path.join("info.json");
    for _ in 0..READ_ATTEMPTS {
        if let Ok(content) = std::fs::read_to_string(&path) {
            if let Ok(val) = serde_json::from_str::<Value>(&content) {
                return Some(val);
            }
        }
    }
    None
}

/// The daemon-managed `status` of a perch, if it is daemon-hosted. `Some(_)` ⇒
/// daemon-hosted (liveness is authoritative via status); `None` ⇒ external /
/// interim (liveness is the per-pid probe).
fn read_status(perch_path: &Path) -> Option<String> {
    read_raw(perch_path)?
        .get("status")
        .and_then(|v| v.as_str())
        .map(str::to_string)
}

/// Liveness for a perch by its **directory**. THE resolver for the path-aware
/// call sites (`is_online`, `from_has_live_perch`, roster).
///
/// - **Daemon-hosted** (`status` present): live iff `status == "online"`. The
///   `info.pid` (an ephemeral summarizer pid, or the shared daemon pid) is
///   **never** consulted — killing the summarizer does not flip the perch.
/// - **External/interim** (`status` absent): the per-pid probe, preserving the
///   exact interim semantics — a numeric pid is probed; a `"BUSY"` sentinel or
///   unparseable/absent pid reads alive (the listener is up between polls).
// [impl->REQ-HAZARD-DAEMON-HOSTED-LIVENESS]
pub fn is_perch_alive(perch_path: &Path) -> bool {
    match read_raw(perch_path) {
        Some(val) => {
            if let Some(status) = val.get("status").and_then(|v| v.as_str()) {
                return status == STATUS_ONLINE; // daemon-authoritative; never pid
            }
            match val.get("pid").and_then(|v| v.as_u64()) {
                Some(pid) => is_process_alive(pid as u32),
                None => true, // BUSY sentinel / unparseable pid — interim parity
            }
        }
        None => true, // unreadable info.json — interim parity (read_pid None ⇒ alive)
    }
}

/// Liveness for a **registry row**: its id plus the pid it was registered under,
/// resolved within `owlery` (the registry's own root). Daemon-hosted rows key on
/// the endpoint `status`; external rows pid-probe the **row's** pid exactly as
/// the interim `clean_stale_entries` did — so the daemon swap touches only the
/// hosted rows. An offline daemon-hosted endpoint is cleaned even though the
/// (shared) daemon pid is still alive; a still-online one survives a dead
/// ephemeral pid.
// [impl->REQ-HAZARD-DAEMON-HOSTED-LIVENESS]
pub fn is_registry_entry_alive(id: &str, registry_pid: u32, owlery: &Path) -> bool {
    let perch_path = perch::resolve_perch_path_in(owlery, id, ParentHint::Infer);
    match read_status(&perch_path) {
        Some(status) => status == STATUS_ONLINE, // daemon-hosted: authoritative
        None => is_process_alive(registry_pid),  // external/interim: pid-probe the row
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::info::{set_status, write_info, InfoJson};

    /// A pid that is never a real allocation (above Linux pid_max ~4.2M and
    /// Windows ranges; positive as i32). Deterministic — avoids pid-reuse flake.
    const DEAD_PID: u32 = 2_000_000_000;

    fn perch() -> tempfile::TempDir {
        tempfile::tempdir().unwrap()
    }

    // [unit->REQ-HAZARD-DAEMON-HOSTED-LIVENESS] a daemon-hosted perch marked
    // `online` reads alive even though its recorded (summarizer) pid is DEAD —
    // killing the summarizer subprocess does not flip the perch offline.
    #[test]
    fn daemon_hosted_online_survives_dead_pid() {
        let d = perch();
        // Record the ephemeral summarizer pid (now dead) then mark daemon-online.
        write_info(d.path(), &InfoJson::new("doyle-psyche", "t", DEAD_PID, "sid", "psyche")).unwrap();
        set_status(d.path(), STATUS_ONLINE).unwrap();
        assert!(is_perch_alive(d.path()), "online status is authoritative; the dead pid is ignored");
    }

    // [unit->REQ-HAZARD-DAEMON-HOSTED-LIVENESS] a daemon-hosted perch marked
    // `offline` reads dead even though its recorded pid is ALIVE (this process).
    #[test]
    fn daemon_hosted_offline_overrides_live_pid() {
        let d = perch();
        write_info(d.path(), &InfoJson::new("doyle-psyche", "t", std::process::id(), "sid", "psyche")).unwrap();
        set_status(d.path(), STATUS_OFFLINE).unwrap();
        assert!(!is_perch_alive(d.path()), "offline status is authoritative; the live pid is ignored");
    }

    // [unit->REQ-HAZARD-DAEMON-HOSTED-LIVENESS] an external/interim perch (no
    // status) still pid-probes: a dead pid reads offline, a live pid reads online.
    #[test]
    fn external_perch_still_pid_probes() {
        let dead = perch();
        write_info(dead.path(), &InfoJson::new("ext", "t", DEAD_PID, "sid", "ready_agent")).unwrap();
        assert!(!is_perch_alive(dead.path()), "external dead pid reads offline");

        let live = perch();
        write_info(live.path(), &InfoJson::new("ext", "t", std::process::id(), "sid", "ready_agent")).unwrap();
        assert!(is_perch_alive(live.path()), "external live pid reads online");
    }

    // [unit->REQ-HAZARD-DAEMON-HOSTED-LIVENESS] BUSY sentinel / unreadable record
    // keep the interim "alive" parity (listener up between polls).
    #[test]
    fn busy_and_missing_keep_interim_parity() {
        let busy = perch();
        crate::atomic::atomic_write_string(&busy.path().join("info.json"), r#"{"pid":"BUSY"}"#).unwrap();
        assert!(is_perch_alive(busy.path()), "BUSY sentinel ⇒ alive (interim parity)");
        // No info.json at all.
        let missing = perch();
        assert!(is_perch_alive(missing.path()), "absent info.json ⇒ alive (interim parity)");
    }

    // [unit->REQ-HAZARD-DAEMON-HOSTED-LIVENESS] registry-row liveness: a
    // daemon-hosted row keys on status (offline ⇒ stale even with a live daemon
    // pid; online ⇒ live even with a dead ephemeral pid); an external row with
    // no perch falls back to probing the registered pid.
    #[test]
    fn registry_entry_keys_on_status_for_hosted_rows() {
        let owlery = perch();
        let root = owlery.path();

        // Daemon-hosted offline endpoint, registered under a LIVE (daemon) pid.
        let off = perch::resolve_perch_path_in(root, "off", ParentHint::Infer);
        std::fs::create_dir_all(&off).unwrap();
        write_info(&off, &InfoJson::new("off", "t", std::process::id(), "sid", "psyche")).unwrap();
        set_status(&off, STATUS_OFFLINE).unwrap();
        assert!(
            !is_registry_entry_alive("off", std::process::id(), root),
            "offline hosted row is stale despite a live daemon pid"
        );

        // Daemon-hosted online endpoint, recorded summarizer pid is DEAD.
        let on = perch::resolve_perch_path_in(root, "on", ParentHint::Infer);
        std::fs::create_dir_all(&on).unwrap();
        write_info(&on, &InfoJson::new("on", "t", DEAD_PID, "sid", "psyche")).unwrap();
        set_status(&on, STATUS_ONLINE).unwrap();
        assert!(is_registry_entry_alive("on", DEAD_PID, root), "online hosted row survives a dead pid");

        // External row (no perch dir / no status): pid-probe the registered pid.
        assert!(!is_registry_entry_alive("ext", DEAD_PID, root), "external dead-pid row is stale");
        assert!(is_registry_entry_alive("ext", std::process::id(), root), "external live-pid row is alive");
    }
}


Recon complete. Build D9-2-1 now. Check lib.rs exports first: