1	//! Shell wake-watcher hosting (M5-D4b — CONTEXT §Shell sleep/wake): the
2	//! offline half of the online/offline mutual exclusivity. While a shell
3	//! instance is **offline** and its manifest declares a `wake_command`, the
4	//! daemon runs that template as a long-running **wake-watcher** child whose
5	//! sole job is to fire a wake; while the shell binary runs, no watcher does
6	//! ([`reconcile_once`] flips between them — spt-core owns the flip).
7	//!
8	//! **Exit-opcode supervision:** the watcher exiting with [`WAKE_OPCODE`] ⇒
9	//! the wake resolution ([`resolve_wake`]) brings the shell online. Any other
10	//! exit is a crash ⇒ respawn with exponential backoff ([`backoff_ms`]) and
11	//! **give-up** after [`WakeParams::give_up_after`] consecutive crashes (a
12	//! durable [`WAKER_GAVE_UP_FILE`] marker the next shell activity — any
13	//! (re)launch — clears; crash-bug safety, the watcher can never crash-loop
14	//! forever). One watcher per offline instance: the in-process [`WakeSet`]
15	//! entry plus the perch's [`WAKER_PID_FILE`] (which also lets a freshly
16	//! booted daemon kill a dead daemon's orphaned watcher before adopting).
17	//!
18	//! **Scheduling (KH 7.4):** every watcher gets its own supervision thread —
19	//! a hung waker stalls nothing; the reconcile loop itself is one bounded
20	//! sweep per tick off the daemon's control surfaces. Watcher children are
21	//! *supervised* (waited) children, never detached-immortal — the KH 5.6
22	//! pipe-inherit shape (an unwaitable child holding a capture pipe) does not
23	//! arise; stdio is null regardless.
24	// [impl->REQ-SHELL-2]
25
26	use std::collections::HashMap;
27	use std::path::Path;
28	use std::process::{Command, Stdio};
29	use std::sync::atomic::{AtomicBool, Ordering};
30	use std::sync::{Arc, Mutex};
31	use std::thread::JoinHandle;
32	use std::time::Duration;
33
34	use spt_runtime::manifest::{AdapterKind, Manifest, Shell};
35	use spt_runtime::registry::AdapterRecord;
36	use spt_store::shellinfo::{self, SHELL_STATUS_OFFLINE};
37
38	/// The wake opcode (documented in `docs/MANIFEST.md` §Shell adapters): a
39	/// wake-watcher exiting with **86** fires the wake resolution; every other
40	/// exit is a crash.
41	pub const WAKE_OPCODE: i32 = 86;
42
43	/// The running watcher's OS pid, parked on the shell perch — the
44	/// one-watcher-per-instance lock and the cross-daemon-restart kill handle.
45	pub const WAKER_PID_FILE: &str = "waker.pid";
46
47	/// The give-up latch: present ⇒ the watcher crash-looped past its budget and
48	/// the reconciler must NOT restart it. Cleared by the next shell activity
49	/// (any (re)launch — [`crate::shellhost::launch_shell`]).
50	pub const WAKER_GAVE_UP_FILE: &str = "waker.gaveup";
51
52	/// Watcher supervision knobs (injectable for tests; defaults are production).
53	#[derive(Debug, Clone, Copy)]
54	pub struct WakeParams {
55	    /// First-crash respawn delay; doubles per consecutive crash.
56	    pub backoff_base_ms: u64,
57	    /// Backoff ceiling.
58	    pub backoff_cap_ms: u64,
59	    /// Consecutive crash-exits before the give-up latch drops.
60	    pub give_up_after: u32,
61	}
62
63	impl Default for WakeParams {
64	    fn default() -> Self {
65	        Self {
66	            backoff_base_ms: 1_000,
67	            backoff_cap_ms: 60_000,
68	            give_up_after: 6,
69	        }
70	    }
71	}
72
73	/// Exponential backoff for `failures` consecutive crash-exits:
74	/// `base × 2^(failures-1)`, saturating at the cap (the
75	/// `spt-net::pairing::ratelimit` recipe). Zero failures ⇒ no wait.
76	// [impl->REQ-SHELL-2]
77	pub fn backoff_ms(params: &WakeParams, failures: u32) -> u64 {
78	    if failures == 0 {
79	        return 0;
80	    }
81	    params
82	        .backoff_base_ms
83	        .saturating_mul(1u64 << (failures - 1).min(20))
84	        .min(params.backoff_cap_ms)
85	}
86
87	/// The live watcher registry: one supervision thread per offline instance,
88	/// keyed `owner/shell_id`. Shared between the daemon's reconcile loop and
89	/// anything that must stop a watcher.
90	#[derive(Default)]
91	pub struct WakeSet {
92	    inner: Mutex<HashMap<(String, String), WatcherHandle>>,
93	}
94
95	struct WatcherHandle {
96	    stop: Arc<AtomicBool>,
97	    thread: JoinHandle<()>,
98	}
99
100	impl WakeSet {
101	    pub fn new() -> Self {
102	        Self::default()
103	    }
104
105	    /// How many watchers are live (finished threads pruned first).
106	    pub fn len(&self) -> usize {
107	        let mut map = self.inner.lock().unwrap_or_else(|p| p.into_inner());
108	        map.retain(|_, h| !h.thread.is_finished());
109	        map.len()
110	    }
111
112	    pub fn is_empty(&self) -> bool {
113	        self.len() == 0
114	    }
115
116	    fn contains(&self, owner: &str, shell_id: &str) -> bool {
117	        let mut map = self.inner.lock().unwrap_or_else(|p| p.into_inner());
118	        match map.get(&(owner.to_string(), shell_id.to_string())) {
119	            Some(h) if !h.thread.is_finished() => true,
120	            Some(_) => {
121	                map.remove(&(owner.to_string(), shell_id.to_string()));
122	                false
123	            }
124	            None => false,
125	        }
126	    }
127
128	    fn insert(&self, owner: &str, shell_id: &str, handle: WatcherHandle) {
129	        let mut map = self.inner.lock().unwrap_or_else(|p| p.into_inner());
130	        map.insert((owner.to_string(), shell_id.to_string()), handle);
131	    }
132
133	    /// Signal one watcher to stop and kill its child (the perch pid file is
134	    /// the kill handle). The thread sees the kill as a non-opcode exit with
135	    /// its stop flag up and returns without counting a failure.
136	    fn stop_watcher(&self, owlery: &Path, owner: &str, shell_id: &str) {
137	        let handle = {
138	            let mut map = self.inner.lock().unwrap_or_else(|p| p.into_inner());
139	            map.remove(&(owner.to_string(), shell_id.to_string()))
140	        };
141	        if let Some(h) = handle {
142	            h.stop.store(true, Ordering::SeqCst);
143	            let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, owner, shell_id);
144	            kill_waker_at(&perch);
145	            let _ = h.thread.join();
146	        }
147	    }
148	}
149
150	/// Kill the recorded waker pid (if any) and retire the pid file — the
151	/// mutual-exclusivity enforcement point and the orphan cleanup
152	/// ([`crate::shellhost::launch_shell`] calls this before the binary rises;
153	/// the reconciler calls it before adopting an instance a dead daemon left).
154	// [impl->REQ-SHELL-2]
155	pub fn kill_waker_at(shell_perch: &Path) {
156	    let pid_file = shell_perch.join(WAKER_PID_FILE);
157	    if let Ok(s) = std::fs::read_to_string(&pid_file) {
158	        if let Ok(pid) = s.trim().parse::<u32>() {
159	            if pid != 0 && spt_store::proc::is_process_alive(pid) {
160	                crate::shellhost::kill_shell_pid(pid);
161	            }
162	        }
163	    }
164	    let _ = std::fs::remove_file(pid_file);
165	}
166
167	/// Clear the give-up latch — any (re)launch is "next shell activity".
168	pub fn clear_gave_up(shell_perch: &Path) {
169	    let _ = std::fs::remove_file(shell_perch.join(WAKER_GAVE_UP_FILE));
170	}
171
172	/// One watcher's supervision loop (runs on its own thread): spawn the
173	/// tokenized `wake_command`, park the pid, wait; `exit(WAKE_OPCODE)` ⇒ run
174	/// `resolve` once and finish; any other exit ⇒ respawn with exponential
175	/// backoff until the give-up budget, then drop the durable latch. A raised
176	/// `stop` flag ends the loop without counting the (killed) child as a crash.
177	// [impl->REQ-SHELL-2]
178	pub fn watcher_run(
179	    owlery: &Path,
180	    owner: &str,
181	    shell_id: &str,
182	    tokens: &[String],
183	    params: &WakeParams,
184	    stop: &AtomicBool,
185	    resolve: impl FnOnce() -> Result<String, String>,
186	) {
187	    let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, owner, shell_id);
188	    let Some((program, args)) = tokens.split_first() else {
189	        eprintln!("WAKER_EMPTY:{owner}/{shell_id}: empty wake_command");
190	        return;
191	    };
192	    let mut failures = 0u32;
193	    while !stop.load(Ordering::SeqCst) {
194	        let mut cmd = Command::new(program);
195	        cmd.args(args)
196	            .stdin(Stdio::null())
197	            .stdout(Stdio::null())
198	            .stderr(Stdio::null());
199	        // The daemon (a console-less DETACHED process) hosting a
200	        // console-subsystem waker would otherwise pop a visible Terminal
201	        // window per spawn on Windows 11 (the KH 5.6-adjacent D3e lesson —
202	        // mock-shell's spt_cmd does the same).
203	        #[cfg(windows)]
204	        {
205	            use std::os::windows::process::CommandExt;
206	            cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
207	        }
208	        let child = cmd.spawn();
209	        let mut child = match child {
210	            Ok(c) => c,
211	            Err(e) => {
212	                // An unspawnable waker is a permanent condition — latch now.
213	                eprintln!("WAKER_SPAWN_FAIL:{owner}/{shell_id}: {e} (giving up)");
214	                let _ = std::fs::write(perch.join(WAKER_GAVE_UP_FILE), b"");
215	                return;
216	            }
217	        };
218	        let _ = std::fs::write(perch.join(WAKER_PID_FILE), child.id().to_string());
219	        let status = child.wait();
220	        let _ = std::fs::remove_file(perch.join(WAKER_PID_FILE));
221	        if stop.load(Ordering::SeqCst) {
222	            return; // stopped from outside — the kill is not a crash
223	        }
224	        match status.ok().and_then(|s| s.code()) {
225	            Some(WAKE_OPCODE) => {
226	                match resolve() {
227	                    Ok(what) => eprintln!("SHELL_WAKE:{owner}/{shell_id}: {what}"),
228	                    Err(e) => eprintln!("SHELL_WAKE_FAIL:{owner}/{shell_id}: {e}"),
229	                }
230	                return;
231	            }
232	            _ => {
233	                failures += 1;
234	                if failures >= params.give_up_after {
235	                    eprintln!(
236	                        "WAKER_GAVE_UP:{owner}/{shell_id}: {failures} consecutive crash-exits \
237	                         (cleared by the next relink/launch)"
238	                    );
239	                    let _ = std::fs::write(perch.join(WAKER_GAVE_UP_FILE), b"");
240	                    return;
241	                }
242	                // Backoff in small slices so a stop request lands promptly.
243	                let mut left = backoff_ms(params, failures);
244	                while left > 0 && !stop.load(Ordering::SeqCst) {
245	                    let step = left.min(50);
246	                    std::thread::sleep(Duration::from_millis(step));
247	                    left -= step;
248	                }
249	            }
250	        }
251	    }
252	}
253
254	/// The **state-keyed wake resolution** (M5-D4c — CONTEXT §Shell sleep/wake):
255	/// key on the owner's local rest record, then bring the shell online —
256	///
257	/// - owner **dormant** (resting warm, still running) ⇒ touch nothing on the
258	///   endpoint, just relaunch the shell binary;
259	/// - owner **suspended** ⇒ revive the owner first
260	///   ([`crate::resting::daemon_rest_event`] `Wake` — its cascade relaunches
261	///   the owner's *persistent* shells, so this fn launches only if the
262	///   cascade did not already), then the shell;
263	/// - owner **active** (or recordless — a pre-resting/interim perch) ⇒ just
264	///   relaunch the shell;
265	/// - **no local instance of the owner** ⇒ refuse, naming the deferral: the
266	///   `shell_wake_spawn_anywhere` fresh-spawn branch rides
267	///   instantiate-anywhere (the D1c grant shape is its seam), and the
268	///   *active-elsewhere cross-node attach* arm upgrades with presence/MRA
269	///   (D6) + cross-node link (D8c) — D4 resolves against the local node.
270	///
271	/// The shell's bind onlines the perch (the D3b contract) — this fn never
272	/// flips status.
273	// [impl->REQ-SHELL-2]
274	pub fn resolve_wake(
275	    owlery: &Path,
276	    owner: &str,
277	    shell_id: &str,
278	    adapter_name: &str,
279	    shell: &Shell,
280	) -> Result<String, String> {
281	    // Shells hang off flat-Self perches only (the D3a layout decision).
282	    let owner_perch = owlery.join(owner);
283	    if !owner_perch.join("info.json").exists() {
284	        // No local instance — consult presence (M5-D6b, REQ-PRES-1): a
285	        // routable instance of the owner on ANOTHER node gets the wake
286	        // FORWARDED there through the D5b remote rest op (the target node's
287	        // own cascade relaunches ITS persistent shells). This local shell
288	        // stays offline — the cross-node shell *link* is D8c; say so.
289	        // [impl->REQ-PRES-1]
290	        if let Some(node) = remote_owner_node(owner) {
291	            return match forward_wake(owner, &node) {
292	                Ok(outcome) => Ok(format!(
293	                    "WAKE_FORWARDED:{owner}@{node}: {outcome}; this shell stays offline \
294	                     here (cross-node shell link lands at D8c)"
295	                )),
296	                Err(e) => Err(format!("WAKE_FORWARD_FAIL:{owner}@{node}: {e}")),
297	            };
298	        }
299	        return Err(format!(
300	            "WAKE_NO_REACHABLE_INSTANCE:{owner}: no instance of the owner on any \
301	             reachable node — a fresh-spawn-to-wake rides the deferred \
302	             instantiate-anywhere capability (the shell_wake_spawn_anywhere grant \
303	             shape is its seam)"
304	        ));
305	    }
306	    let state = crate::resting::read_rest(&owner_perch).map(|r| r.state);
307	    let mut did = String::new();
308	    if state == Some(crate::resting::RestState::Suspended) {
309	        match crate::resting::daemon_rest_event(owner, crate::resting::RestEvent::Wake, None) {
310	            Ok(_) => did.push_str("revived owner; "),
311	            Err(e) => return Err(format!("revive owner: {e}")),
312	        }
313	    } else if state == Some(crate::resting::RestState::Dormant) {
314	        did.push_str("owner dormant (left in place); ");
315	    }
316	    // Never double-launch a live binary: the revive's wake cascade may have
317	    // relaunched this (persistent) instance already — and two reconcilers
318	    // (a fresh daemon adopting + a stale watcher firing) may race a wake.
319	    // A parked pid that probes alive means somebody won; stand down.
320	    let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, owner, shell_id);
321	    let live_pid = std::fs::read_to_string(perch.join(crate::shellhost::SHELL_PID_FILE))
322	        .ok()
323	        .and_then(|s| s.trim().parse::<u32>().ok())
324	        .filter(|&p| p != 0 && spt_store::proc::is_process_alive(p));
325	    if let Some(p) = live_pid {
326	        return Ok(format!("{did}already relaunched pid={p} (online at bind)"));
327	    }
328	    let pid = crate::shellhost::launch_shell(owlery, owner, shell_id, adapter_name, shell)?;
329	    Ok(format!("{did}relaunched pid={pid} (online at bind)"))
330	}
331
332	/// The node (hex) holding a routable instance of `owner` per the gossiped
333	/// registry snapshots, preferring the most-recently-active row when several
334	/// nodes hold one (the presence datum as the tiebreak; absent data ranks
335	/// last). `None` = the owner exists nowhere reachable.
336	// [impl->REQ-PRES-1]
337	fn remote_owner_node(owner: &str) -> Option<String> {
338	    let regs = crate::presence::load_registry_snapshots(&crate::presence::registry_snapshot_dir());
339	    let local = crate::presence::local_node_hex();
340	    let mut best: Option<(u64, String)> = None;
341	    for reg in regs.values() {
342	        for row in reg.instances(owner) {
343	            if row.node == local || !row.status.routable() {
344	                continue;
345	            }
346	            let ts = row.last_active_ms.unwrap_or(0);
347	            let better = match &best {
348	                Some((cur, _)) => ts > *cur,
349	                None => true,
350	            };
351	            if better {
352	                best = Some((ts, row.node.clone()));
353	            }
354	        }
355	    }
356	    best.map(|(_, node)| node)
357	}
358
359	/// Forward a wake to `node` through the D5b remote rest op (the remote-drive
360	/// trust class — the target's access gate decides). One dial, one request,
361	/// one reply; a refusal (no reply) surfaces as an error.
362	// [impl->REQ-PRES-1]
363	fn forward_wake(owner: &str, node: &str) -> Result<String, String> {
364	    use spt_net::net::endpoint::addr_for_node_hex;
365	    let addr = addr_for_node_hex(node)
366	        .and_then(|a| serde_json::to_value(a).ok())
367	        .ok_or_else(|| format!("no dialable address for {node}"))?;
368	    let mut brain =
369	        crate::brain::Brain::cold_start(&crate::endpoint::broker_socket_name(), now_ms())
370	            .map_err(|e| format!("broker connect: {e}"))?;
371	    let conn = brain
372	        .net_dial(addr, None)
373	        .map_err(|e| format!("dial: {e}"))?;
374	    let op_id = format!("{}:wakefwd:{}", crate::presence::local_node_hex(), now_ms());
375	    match crate::resthost::request_rest(
376	        &mut brain,
377	        conn.conn_id,
378	        owner,
379	        spt_net::net::rest::REST_EVENT_WAKE,
380	        &op_id,
381	        now_ms(),
382	    )
383	    .map_err(|e| format!("rest op: {e}"))?
384	    {
385	        crate::resthost::RestRequestOutcome::Edge(d) => Ok(format!("woke ({d})")),
386	        crate::resthost::RestRequestOutcome::NoEdge => Ok("already awake".to_string()),
387	        crate::resthost::RestRequestOutcome::Failed(e) => Err(e),
388	        crate::resthost::RestRequestOutcome::NoReply => {
389	            Err("refused or dropped (no reply)".to_string())
390	        }
391	    }
392	}
393
394	/// Resolve one adapter **option**'s `[shell]` section through the merged view
395	/// (composite addressing): `adapter_option` is the stored `<adapter>[:profile]`
396	/// string, split → parent lookup in `registered` → profile overlay (shipped or
397	/// local). Returns an **owned** [`Shell`] — a profile produces a fresh merged
398	/// manifest, not a borrow into the parent slice. A bare name resolves to the
399	/// parent unmodified; a deregistered parent or a failed overlay yields `None`.
400	fn shell_section_of(
401	    registered: &[(AdapterRecord, Manifest)],
402	    adapters_dir: &Path,
403	    adapter_option: &str,
404	) -> Option<Shell> {
405	    spt_runtime::registry::resolve_option_in(registered, adapters_dir, adapter_option)
406	        .ok()
407	        .filter(|m| m.adapter.kind == AdapterKind::Shell)
408	        .and_then(|m| m.shell)
409	}
410
411	/// Fill a `wake_command` template. The substitution catalog matches the spawn
412	/// template's (`id`, `adapter_name`, `link_token`) so one manifest vocabulary
413	/// serves both — but the waker's token is minted **unparked**: it substitutes
414	/// (template compatibility) without ever verifying against the perch, because
415	/// an offline link has no live credential by design (the close retired it; a
416	/// waker wakes by *exit code*, not by driving the link).
417	fn now_ms() -> u64 {
418	    crate::brain::now_ms()
419	}
420
421	fn fill_wake_command(
422	    shell_id: &str,
423	    adapter_name: &str,
424	    wake_command: &str,
425	) -> Result<Vec<String>, String> {
426	    let keys = std::collections::BTreeMap::from([
427	        ("id".to_string(), shell_id.to_string()),
428	        ("adapter_name".to_string(), adapter_name.to_string()),
429	        (
430	            "link_token".to_string(),
431	            crate::shellhost::mint_link_token(),
432	        ),
433	    ]);
434	    // [impl->REQ-HAZARD-TEMPLATE-ARGV-FILL] tokenize-template-then-fill-each: a
435	    // multi-word/quote/semicolon {key} value is exactly one argv element.
436	    let tokens =
437	        spt_runtime::runtime::fill_template_tokens(wake_command, &keys).map_err(|e| e.to_string())?;
438	    if tokens.is_empty() {
439	        return Err("empty wake_command".into());
440	    }
441	    Ok(tokens)
442	}
443
444	/// One reconcile sweep — the invariant holder for the online/offline mutual
445	/// exclusivity (CONTEXT: "spt-core flips between them"):
446	///
447	/// - **offline + `wake_command` + no give-up latch + no live watcher** ⇒
448	///   kill any orphaned waker pid (a dead daemon's leftover), then start a
449	///   supervision thread.
450	/// - **not offline (onlined / torn down / deregistered) + live watcher** ⇒
451	///   stop it (flag + kill).
452	///
453	/// Runs at daemon boot and every tick — lifecycle flips happen in CLI-process
454	/// library code, so the *reconciler* holds the invariant, not the flipping
455	/// caller; a flip is at most one tick stale.
456	// [impl->REQ-SHELL-2]
457	pub fn reconcile_once(
458	    owlery: &Path,
459	    registered: &[(AdapterRecord, Manifest)],
460	    adapters_dir: &Path,
461	    set: &Arc<WakeSet>,
462	    params: &WakeParams,
463	) {
464	    // Stop side first: watchers whose instance is gone or no longer eligible.
465	    let live: Vec<(String, String)> = {
466	        let mut map = set.inner.lock().unwrap_or_else(|p| p.into_inner());
467	        map.retain(|_, h| !h.thread.is_finished());
468	        map.keys().cloned().collect()
469	    };
470	    for (owner, shell_id) in live {
471	        let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, &owner, &shell_id);
472	        let still_eligible = shellinfo::read_shell_info(&perch)
473	            .map(|i| i.status == SHELL_STATUS_OFFLINE)
474	            .unwrap_or(false);
475	        if !still_eligible {
476	            set.stop_watcher(owlery, &owner, &shell_id);
477	        }
478	    }
479
480	    // Start side: every offline instance with a wake_command and headroom.
481	    for owner in spt_store::perch::list_self_perch_ids(owlery) {
482	        for (shell_id, info) in shellinfo::list_shells(owlery, &owner) {
483	            if info.status != SHELL_STATUS_OFFLINE || set.contains(&owner, &shell_id) {
484	                continue;
485	            }
486	            let Some(shell) = shell_section_of(registered, adapters_dir, &info.adapter_name) else {
487	                continue; // deregistered adapter: nothing to run
488	            };
489	            let Some(wake_command) = shell.wake_command.as_deref() else {
490	                continue;
491	            };
492	            let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, &owner, &shell_id);
493	            if perch.join(WAKER_GAVE_UP_FILE).exists() {
494	                continue; // crash-latched until the next shell activity
495	            }
496	            // Adopt cleanly: a dead daemon's watcher must not double-run.
497	            kill_waker_at(&perch);
498	            let tokens = match fill_wake_command(&shell_id, &info.adapter_name, wake_command) {
499	                Ok(t) => t,
500	                Err(e) => {
501	                    eprintln!("WAKER_TEMPLATE:{owner}/{shell_id}: {e}");
502	                    continue;
503	                }
504	            };
505	            let stop = Arc::new(AtomicBool::new(false));
506	            let handle = {
507	                let stop = Arc::clone(&stop);
508	                let owlery = owlery.to_path_buf();
509	                let owner = owner.clone();
510	                let shell_id = shell_id.clone();
511	                let adapter_name = info.adapter_name.clone();
512	                let params = *params;
513	                std::thread::spawn(move || {
514	                    watcher_run(&owlery, &owner, &shell_id, &tokens, &params, &stop, || {
515	                        resolve_wake(&owlery, &owner, &shell_id, &adapter_name, &shell)
516	                    });
517	                })
518	            };
519	            set.insert(
520	                &owner,
521	                &shell_id,
522	                WatcherHandle {
523	                    stop,
524	                    thread: handle,
525	                },
526	            );
527	        }
528	    }
529	}
530
531	/// The reconcile cadence (ms): lifecycle flips land in CLI processes, so the
532	/// daemon's loop is the invariant holder — a flip is at most one tick stale.
533	pub const RECONCILE_INTERVAL_MS: u64 = 5_000;
534
535	/// Spawn the daemon's wake host: one thread sweeping [`reconcile_once`] at
536	/// boot and every [`RECONCILE_INTERVAL_MS`] until `stop`. The registered set
537	/// is re-read each sweep (adapter add/remove lands between ticks).
538	// [impl->REQ-SHELL-2]
539	pub fn spawn_wake_host(stop: Arc<AtomicBool>) -> JoinHandle<()> {
540	    std::thread::spawn(move || {
541	        let set = Arc::new(WakeSet::new());
542	        let params = WakeParams::default();
543	        while !stop.load(Ordering::SeqCst) {
544	            let owlery = spt_store::perch::owlery_dir();
545	            let adapters_dir = spt_store::perch::adapters_dir();
546	            let registered = spt_runtime::registry::registered(&adapters_dir);
547	            reconcile_once(&owlery, &registered, &adapters_dir, &set, &params);
548	            // Sleep in slices so a stop lands promptly.
549	            let mut left = RECONCILE_INTERVAL_MS;
550	            while left > 0 && !stop.load(Ordering::SeqCst) {
551	                let step = left.min(100);
552	                std::thread::sleep(Duration::from_millis(step));
553	                left -= step;
554	            }
555	        }
556	    })
557	}
558
559	#[cfg(test)]
560	mod tests {
561	    use super::*;
562	    use spt_store::shellinfo::{spawn_record, SHELL_STATUS_ONLINE};
563
564	    fn fast_params() -> WakeParams {
565	        WakeParams {
566	            backoff_base_ms: 10,
567	            backoff_cap_ms: 40,
568	            give_up_after: 3,
569	        }
570	    }
571
572	    fn seed_owner(owlery: &Path, owner: &str) {
573	        let p = owlery.join(owner);
574	        std::fs::create_dir_all(&p).unwrap();
575	        std::fs::write(p.join("info.json"), "{}").unwrap();
576	    }
577
578	    /// Tokens for a waker that exits with `code` immediately.
579	    fn exit_with(code: i32) -> Vec<String> {
580	        #[cfg(windows)]
581	        return vec!["cmd".into(), "/c".into(), format!("exit {code}")];
582	        #[cfg(unix)]
583	        return vec!["sh".into(), "-c".into(), format!("exit {code}")];
584	    }
585
586	    /// Tokens for a waker that appends one line to `tally` then exits 1.
587	    fn crash_tallying(tally: &Path) -> Vec<String> {
588	        #[cfg(windows)]
589	        return vec![
590	            "powershell".into(),
591	            "-NoProfile".into(),
592	            "-Command".into(),
593	            format!("Add-Content -Path '{}' -Value x; exit 1", tally.display()),
594	        ];
595	        #[cfg(unix)]
596	        return vec![
597	            "sh".into(),
598	            "-c".into(),
599	            format!("echo x >> '{}'; exit 1", tally.display()),
600	        ];
601	    }
602
603	    // [unit->REQ-SHELL-2] the backoff curve: base × 2^(n-1), saturating at
604	    // the cap; zero failures wait nothing.
605	    #[test]
606	    fn backoff_curve_doubles_to_the_cap() {
607	        let p = WakeParams {
608	            backoff_base_ms: 100,
609	            backoff_cap_ms: 1_500,
610	            give_up_after: 6,
611	        };
612	        assert_eq!(backoff_ms(&p, 0), 0);
613	        assert_eq!(backoff_ms(&p, 1), 100);
614	        assert_eq!(backoff_ms(&p, 2), 200);
615	        assert_eq!(backoff_ms(&p, 4), 800);
616	        assert_eq!(backoff_ms(&p, 5), 1_500, "saturates at the cap");
617	        assert_eq!(
618	            backoff_ms(&p, 60),
619	            1_500,
620	            "huge failure counts can't overflow"
621	        );
622	    }
623
624	    // [unit->REQ-SHELL-2] exit-opcode supervision: exit(86) fires the wake
625	    // resolution exactly once and the loop ends; the pid file is retired.
626	    #[test]
627	    fn watcher_opcode_exit_fires_resolution_once() {
628	        let tmp = tempfile::tempdir().unwrap();
629	        let owlery = tmp.path();
630	        seed_owner(owlery, "doyle");
631	        let id = spawn_record(owlery, "doyle", "mock-shell", None).unwrap();
632	        let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, "doyle", &id);
633
634	        let fired = std::sync::atomic::AtomicU32::new(0);
635	        let stop = AtomicBool::new(false);
636	        watcher_run(
637	            owlery,
638	            "doyle",
639	            &id,
640	            &exit_with(WAKE_OPCODE),
641	            &fast_params(),
642	            &stop,
643	            || {
644	                fired.fetch_add(1, Ordering::SeqCst);
645	                Ok("test-resolved".into())
646	            },
647	        );
648	        assert_eq!(
649	            fired.load(Ordering::SeqCst),
650	            1,
651	            "resolution fired exactly once"
652	        );
653	        assert!(!perch.join(WAKER_PID_FILE).exists(), "pid file retired");
654	        assert!(
655	            !perch.join(WAKER_GAVE_UP_FILE).exists(),
656	            "an opcode exit is not a crash"
657	        );
658	    }
659
660	    // [unit->REQ-SHELL-2] crash-exit supervision: a crashing waker respawns
661	    // (with backoff) exactly give_up_after times, then the durable give-up
662	    // latch drops and the resolution never fires; clear_gave_up re-arms.
663	    #[test]
664	    fn watcher_crash_exits_respawn_then_give_up() {
665	        let tmp = tempfile::tempdir().unwrap();
666	        let owlery = tmp.path();
667	        seed_owner(owlery, "doyle");
668	        let id = spawn_record(owlery, "doyle", "mock-shell", None).unwrap();
669	        let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, "doyle", &id);
670	        let tally = perch.join("waker-tally.txt");
671
672	        let stop = AtomicBool::new(false);
673	        watcher_run(
674	            owlery,
675	            "doyle",
676	            &id,
677	            &crash_tallying(&tally),
678	            &fast_params(),
679	            &stop,
680	            || -> Result<String, String> { panic!("a crash-exit must never resolve a wake") },
681	        );
682	        let runs = std::fs::read_to_string(&tally).unwrap().lines().count();
683	        assert_eq!(runs, 3, "respawned exactly to the give-up budget");
684	        assert!(
685	            perch.join(WAKER_GAVE_UP_FILE).exists(),
686	            "the durable latch dropped"
687	        );
688
689	        clear_gave_up(&perch);
690	        assert!(
691	            !perch.join(WAKER_GAVE_UP_FILE).exists(),
692	            "shell activity re-arms"
693	        );
694	    }
695
696	    /// A `[shell]` section parsed from TOML (the manifest is the only
697	    /// constructor — its serde defaults are part of the contract).
698	    fn shell_section(spawn: &str, extra: &str) -> Shell {
699	        let toml_src = format!(
700	            "[adapter]\nname = \"mock-wake\"\nkind = \"shell\"\nversion = \"1\"\n\
701	             min_spt_core_version = \"0\"\n\n[shell]\nspawn = '{spawn}'\n{extra}\n"
702	        );
703	        spt_runtime::Manifest::from_toml_str(&toml_src)
704	            .unwrap()
705	            .shell
706	            .unwrap()
707	    }
708
709	    #[cfg(windows)]
710	    const NOOP: &str = "cmd /c exit 0";
711	    #[cfg(unix)]
712	    const NOOP: &str = "true";
713
714	    // [unit->REQ-SHELL-2] wake resolution, the no-reachable branch: no local
715	    // instance of the owner ⇒ refuse naming the instantiate-anywhere
716	    // deferral — never a silent no-op, never a fresh spawn.
717	    #[test]
718	    fn resolve_wake_refuses_without_a_reachable_owner() {
719	        let tmp = tempfile::tempdir().unwrap();
720	        let owlery = tmp.path();
721	        // The shell perch exists; the OWNER's perch does not (home node gone).
722	        let id = spawn_record(owlery, "ghost", "mock-wake", None).unwrap();
723	        let err =
724	            resolve_wake(owlery, "ghost", &id, "mock-wake", &shell_section(NOOP, "")).unwrap_err();
725	        assert!(err.contains("WAKE_NO_REACHABLE_INSTANCE"), "{err}");
726	        assert!(
727	            err.contains("instantiate-anywhere"),
728	            "the deferral is named: {err}"
729	        );
730	    }
731
732	    // [unit->REQ-SHELL-2] wake resolution, the dormant branch: the owner is
733	    // resting warm — touch nothing on the endpoint, just relaunch the shell.
734	    #[test]
735	    fn resolve_wake_leaves_a_dormant_owner_and_relaunches() {
736	        use spt_store::info::{write_info, InfoJson};
737	        let tmp = tempfile::tempdir().unwrap();
738	        let owlery = tmp.path();
739	        let owner_perch = owlery.join("ling");
740	        std::fs::create_dir_all(&owner_perch).unwrap();
741	        write_info(
742	            &owner_perch,
743	            &InfoJson::new("ling", "t", 4242, "sid", "live_agent"),
744	        )
745	        .unwrap();
746	        crate::resting::write_rest(&owner_perch, crate::resting::RestState::Dormant, 1_000)
747	            .unwrap();
748
749	        let id = spawn_record(owlery, "ling", "mock-wake", None).unwrap();
750	        let msg = resolve_wake(owlery, "ling", &id, "mock-wake", &shell_section(NOOP, ""))
751	            .expect("resolves");
752	        assert!(msg.contains("owner dormant"), "{msg}");
753	        assert!(msg.contains("relaunched"), "{msg}");
754	        let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, "ling", &id);
755	        assert!(
756	            perch.join(crate::shellhost::SHELL_PID_FILE).exists(),
757	            "binary relaunched"
758	        );
759	        assert_eq!(
760	            crate::resting::read_rest(&owner_perch).unwrap().state,
761	            crate::resting::RestState::Dormant,
762	            "a dormant owner is left in place"
763	        );
764	    }
765
766	    // [unit->REQ-SHELL-2] wake resolution, the suspended branch: revive the
767	    // owner FIRST (the real daemon_rest_event — its wake cascade relaunches
768	    // persistent shells, and the resolution then must NOT double-launch a
769	    // live binary); a non-persistent shell is launched by the resolution
770	    // itself after the revive.
771	    #[test]
772	    fn resolve_wake_revives_a_suspended_owner_without_double_launch() {
773	        crate::test_home::with_home(|home| {
774	            use spt_store::info::{write_info, InfoJson};
775	            use spt_store::perch;
776	            let owlery = perch::owlery_dir();
777	            let owner_perch =
778	                perch::resolve_perch_path("ling", spt_store::perch::ParentHint::Infer);
779	            std::fs::create_dir_all(&owner_perch).unwrap();
780	            write_info(
781	                &owner_perch,
782	                &InfoJson::new("ling", "t", 4242, "sid", "live_agent"),
783	            )
784	            .unwrap();
785	            crate::resting::write_rest(&owner_perch, crate::resting::RestState::Suspended, 1_000)
786	                .unwrap();
787
788	            // A registered PERSISTENT sleeper adapter — the revive's wake
789	            // cascade relaunches it and the binary stays alive.
790	            #[cfg(windows)]
791	            let sleeper = "ping -n 30 127.0.0.1";
792	            #[cfg(unix)]
793	            let sleeper = "sleep 30";
794	            let adapters = perch::adapters_dir();
795	            for (name, spawn, extra) in [
796	                ("mock-wake", sleeper, "persistent = true"),
797	                ("mock-noper", NOOP, ""), // the cascade must skip this one
798	            ] {
799	                let src = home.join("srcs").join(name);
800	                std::fs::create_dir_all(&src).unwrap();
801	                std::fs::write(
802	                    src.join("manifest.toml"),
803	                    format!(
804	                        "[adapter]\nname = \"{name}\"\nkind = \"shell\"\nversion = \"1\"\n\
805	                         min_spt_core_version = \"0\"\n\n[shell]\nspawn = '{spawn}'\n{extra}\n"
806	                    ),
807	                )
808	                .unwrap();
809	                spt_runtime::registry::register(&adapters, &src, 1).unwrap();
810	            }
811	            let shell = shell_section(sleeper, "persistent = true");
812
813	            let id = spawn_record(&owlery, "ling", "mock-wake", None).unwrap();
814	            let msg = resolve_wake(&owlery, "ling", &id, "mock-wake", &shell).expect("resolves");
815	            assert!(msg.contains("revived owner"), "{msg}");
816	            assert!(
817	                msg.contains("already relaunched"),
818	                "a live cascade launch is never doubled: {msg}"
819	            );
820	            assert_eq!(
821	                crate::resting::read_rest(&owner_perch).unwrap().state,
822	                crate::resting::RestState::Active,
823	                "suspended owner revived"
824	            );
825
826	            // Re-suspend and resolve a NON-persistent shell of the same
827	            // owner: the revive's cascade skips it (its registered adapter
828	            // is not persistent), so the resolution launches it itself.
829	            crate::resting::daemon_rest_event("ling", crate::resting::RestEvent::Suspend, None)
830	                .expect("ok")
831	                .expect("edge");
832	            let shell_np = shell_section(NOOP, "");
833	            let id2 = spawn_record(&owlery, "ling", "mock-noper", None).unwrap();
834	            let msg =
835	                resolve_wake(&owlery, "ling", &id2, "mock-noper", &shell_np).expect("resolves");
836	            assert!(msg.contains("revived owner"), "{msg}");
837	            assert!(msg.contains("relaunched pid="), "{msg}");
838	        });
839	    }
840
841	    // [unit->REQ-SHELL-2] the reconciler holds the mutual exclusivity: an
842	    // offline instance with a wake_command gets exactly ONE watcher (a second
843	    // sweep never doubles it); a give-up latch suppresses adoption; onlining
844	    // the instance stops the watcher on the next sweep.
845	    #[test]
846	    fn reconcile_flips_watchers_with_instance_state() {
847	        let tmp = tempfile::tempdir().unwrap();
848	        let owlery = tmp.path();
849	        seed_owner(owlery, "doyle");
850
851	        // A registered shell adapter whose waker sleeps (stays alive between
852	        // sweeps) — the long-running watcher case.
853	        #[cfg(windows)]
854	        let (noop, sleeper) = ("cmd /c exit 0", "ping -n 30 127.0.0.1");
855	        #[cfg(unix)]
856	        let (noop, sleeper) = ("true", "sleep 30");
857	        let adapters_dir = tmp.path().join("adapters");
858	        let src = tmp.path().join("srcs").join("mock-wake");
859	        std::fs::create_dir_all(&src).unwrap();
860	        std::fs::write(
861	            src.join("manifest.toml"),
862	            format!(
863	                "[adapter]\nname = \"mock-wake\"\nkind = \"shell\"\nversion = \"1\"\n\
864	                 min_spt_core_version = \"0\"\n\n[shell]\nspawn = '{noop}'\n\
865	                 wake_command = '{sleeper}'\n"
866	            ),
867	        )
868	        .unwrap();
869	        spt_runtime::registry::register(&adapters_dir, &src, 1).unwrap();
870	        let registered = spt_runtime::registry::registered(&adapters_dir);
871
872	        let id = spawn_record(owlery, "doyle", "mock-wake", None).unwrap();
873	        let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, "doyle", &id);
874
875	        let set = Arc::new(WakeSet::new());
876	        let params = fast_params();
877
878	        // Offline (the spawn_record default) ⇒ one watcher; never two.
879	        reconcile_once(owlery, &registered, &adapters_dir, &set, &params);
880	        assert_eq!(set.len(), 1, "offline + wake_command ⇒ a watcher");
881	        reconcile_once(owlery, &registered, &adapters_dir, &set, &params);
882	        assert_eq!(set.len(), 1, "a second sweep never doubles it");
883	        // The child parked its pid (it is the long sleeper, still up).
884	        for _ in 0..100 {
885	            if perch.join(WAKER_PID_FILE).exists() {
886	                break;
887	            }
888	            std::thread::sleep(Duration::from_millis(20));
889	        }
890	        let waker_pid: u32 = std::fs::read_to_string(perch.join(WAKER_PID_FILE))
891	            .expect("waker pid parked")
892	            .trim()
893	            .parse()
894	            .unwrap();
895	        assert!(
896	            spt_store::proc::is_process_alive(waker_pid),
897	            "the waker runs while offline"
898	        );
899
900	        // Online the instance ⇒ the next sweep stops the watcher + kills the
901	        // child (mutual exclusivity, the binary side wins).
902	        let mut info = shellinfo::read_shell_info(&perch).unwrap();
903	        info.status = SHELL_STATUS_ONLINE.to_string();
904	        shellinfo::write_shell_info(&perch, &info).unwrap();
905	        reconcile_once(owlery, &registered, &adapters_dir, &set, &params);
906	        assert_eq!(set.len(), 0, "onlined instance keeps no watcher");
907	        assert!(
908	            !spt_store::proc::is_process_alive(waker_pid),
909	            "the waker child was killed"
910	        );
911
912	        // Back offline but crash-latched ⇒ no adoption.
913	        let mut info = shellinfo::read_shell_info(&perch).unwrap();
914	        info.status = SHELL_STATUS_OFFLINE.to_string();
915	        shellinfo::write_shell_info(&perch, &info).unwrap();
916	        std::fs::write(perch.join(WAKER_GAVE_UP_FILE), b"").unwrap();
917	        reconcile_once(owlery, &registered, &adapters_dir, &set, &params);
918	        assert_eq!(set.len(), 0, "the give-up latch suppresses adoption");
919	    }
920
921	    // [unit->REQ-MANIFEST-2] the wake seam resolves the **merged view**: a
922	    // shipped profile that adds `wake_command` makes a `<adapter>:<profile>`
923	    // instance wakeable, while the bare parent (no wake_command) stays inert —
924	    // proof the daemon re-resolves the stored composite, not the parent.
925	    #[test]
926	    fn reconcile_resolves_profile_overlay() {
927	        let tmp = tempfile::tempdir().unwrap();
928	        let owlery = tmp.path();
929	        seed_owner(owlery, "doyle");
930
931	        #[cfg(windows)]
932	        let (noop, sleeper) = ("cmd /c exit 0", "ping -n 30 127.0.0.1");
933	        #[cfg(unix)]
934	        let (noop, sleeper) = ("true", "sleep 30");
935	        let adapters_dir = tmp.path().join("adapters");
936	        let src = tmp.path().join("srcs").join("mock-wakeprof");
937	        std::fs::create_dir_all(&src).unwrap();
938	        std::fs::write(
939	            src.join("manifest.toml"),
940	            format!(
941	                "[adapter]\nname = \"mock-wakeprof\"\nkind = \"shell\"\nversion = \"1\"\n\
942	                 min_spt_core_version = \"0\"\n\n[shell]\nspawn = '{noop}'\n\n\
943	                 [profiles.waker]\n[profiles.waker.shell]\nwake_command = '{sleeper}'\n"
944	            ),
945	        )
946	        .unwrap();
947	        spt_runtime::registry::register(&adapters_dir, &src, 1).unwrap();
948	        let registered = spt_runtime::registry::registered(&adapters_dir);
949
950	        // A bare-parent instance: no wake_command in the parent ⇒ never woken.
951	        spawn_record(owlery, "doyle", "mock-wakeprof", None).unwrap();
952	        // A profiled instance: mint a clean id off the parent, then store the
953	        // composite as its adapter_name (the id stays colon-free; the carrier
954	        // holds the option — the #8 identity split).
955	        let pid = spawn_record(owlery, "doyle", "mock-wakeprof", None).unwrap();
956	        let pperch = spt_store::perch::resolve_shell_perch_path_in(owlery, "doyle", &pid);
957	        let mut info = shellinfo::read_shell_info(&pperch).unwrap();
958	        info.adapter_name = "mock-wakeprof:waker".to_string();
959	        shellinfo::write_shell_info(&pperch, &info).unwrap();
960
961	        let set = Arc::new(WakeSet::new());
962	        let params = fast_params();
963	        reconcile_once(owlery, &registered, &adapters_dir, &set, &params);
964	        assert_eq!(
965	            set.len(),
966	            1,
967	            "only the profiled instance (overlay adds wake_command) gets a watcher"
968	        );
969	        assert!(set.contains("doyle", &pid), "the profiled instance is the one watched");
970
971	        set.stop_watcher(owlery, "doyle", &pid);
972	    }
973	}
974

//! Shell wake-watcher hosting (M5-D4b — CONTEXT §Shell sleep/wake): the
//! offline half of the online/offline mutual exclusivity. While a shell
//! instance is **offline** and its manifest declares a `wake_command`, the
//! daemon runs that template as a long-running **wake-watcher** child whose
//! sole job is to fire a wake; while the shell binary runs, no watcher does
//! ([`reconcile_once`] flips between them — spt-core owns the flip).
//!
//! **Exit-opcode supervision:** the watcher exiting with [`WAKE_OPCODE`] ⇒
//! the wake resolution ([`resolve_wake`]) brings the shell online. Any other
//! exit is a crash ⇒ respawn with exponential backoff ([`backoff_ms`]) and
//! **give-up** after [`WakeParams::give_up_after`] consecutive crashes (a
//! durable [`WAKER_GAVE_UP_FILE`] marker the next shell activity — any
//! (re)launch — clears; crash-bug safety, the watcher can never crash-loop
//! forever). One watcher per offline instance: the in-process [`WakeSet`]
//! entry plus the perch's [`WAKER_PID_FILE`] (which also lets a freshly
//! booted daemon kill a dead daemon's orphaned watcher before adopting).
//!
//! **Scheduling (KH 7.4):** every watcher gets its own supervision thread —
//! a hung waker stalls nothing; the reconcile loop itself is one bounded
//! sweep per tick off the daemon's control surfaces. Watcher children are
//! *supervised* (waited) children, never detached-immortal — the KH 5.6
//! pipe-inherit shape (an unwaitable child holding a capture pipe) does not
//! arise; stdio is null regardless.
// [impl->REQ-SHELL-2]

use std::collections::HashMap;
use std::path::Path;
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use std::time::Duration;

use spt_runtime::manifest::{AdapterKind, Manifest, Shell};
use spt_runtime::registry::AdapterRecord;
use spt_store::shellinfo::{self, SHELL_STATUS_OFFLINE};

/// The wake opcode (documented in `docs/MANIFEST.md` §Shell adapters): a
/// wake-watcher exiting with **86** fires the wake resolution; every other
/// exit is a crash.
pub const WAKE_OPCODE: i32 = 86;

/// The running watcher's OS pid, parked on the shell perch — the
/// one-watcher-per-instance lock and the cross-daemon-restart kill handle.
pub const WAKER_PID_FILE: &str = "waker.pid";

/// The give-up latch: present ⇒ the watcher crash-looped past its budget and
/// the reconciler must NOT restart it. Cleared by the next shell activity
/// (any (re)launch — [`crate::shellhost::launch_shell`]).
pub const WAKER_GAVE_UP_FILE: &str = "waker.gaveup";

/// Watcher supervision knobs (injectable for tests; defaults are production).
#[derive(Debug, Clone, Copy)]
pub struct WakeParams {
    /// First-crash respawn delay; doubles per consecutive crash.
    pub backoff_base_ms: u64,
    /// Backoff ceiling.
    pub backoff_cap_ms: u64,
    /// Consecutive crash-exits before the give-up latch drops.
    pub give_up_after: u32,
}

impl Default for WakeParams {
    fn default() -> Self {
        Self {
            backoff_base_ms: 1_000,
            backoff_cap_ms: 60_000,
            give_up_after: 6,
        }
    }
}

/// Exponential backoff for `failures` consecutive crash-exits:
/// `base × 2^(failures-1)`, saturating at the cap (the
/// `spt-net::pairing::ratelimit` recipe). Zero failures ⇒ no wait.
// [impl->REQ-SHELL-2]
pub fn backoff_ms(params: &WakeParams, failures: u32) -> u64 {
    if failures == 0 {
        return 0;
    }
    params
        .backoff_base_ms
        .saturating_mul(1u64 << (failures - 1).min(20))
        .min(params.backoff_cap_ms)
}

/// The live watcher registry: one supervision thread per offline instance,
/// keyed `owner/shell_id`. Shared between the daemon's reconcile loop and
/// anything that must stop a watcher.
#[derive(Default)]
pub struct WakeSet {
    inner: Mutex<HashMap<(String, String), WatcherHandle>>,
}

struct WatcherHandle {
    stop: Arc<AtomicBool>,
    thread: JoinHandle<()>,
}

impl WakeSet {
    pub fn new() -> Self {
        Self::default()
    }

    /// How many watchers are live (finished threads pruned first).
    pub fn len(&self) -> usize {
        let mut map = self.inner.lock().unwrap_or_else(|p| p.into_inner());
        map.retain(|_, h| !h.thread.is_finished());
        map.len()
    }

    pub fn is_empty(&self) -> bool {
        self.len() == 0
    }

    fn contains(&self, owner: &str, shell_id: &str) -> bool {
        let mut map = self.inner.lock().unwrap_or_else(|p| p.into_inner());
        match map.get(&(owner.to_string(), shell_id.to_string())) {
            Some(h) if !h.thread.is_finished() => true,
            Some(_) => {
                map.remove(&(owner.to_string(), shell_id.to_string()));
                false
            }
            None => false,
        }
    }

    fn insert(&self, owner: &str, shell_id: &str, handle: WatcherHandle) {
        let mut map = self.inner.lock().unwrap_or_else(|p| p.into_inner());
        map.insert((owner.to_string(), shell_id.to_string()), handle);
    }

    /// Signal one watcher to stop and kill its child (the perch pid file is
    /// the kill handle). The thread sees the kill as a non-opcode exit with
    /// its stop flag up and returns without counting a failure.
    fn stop_watcher(&self, owlery: &Path, owner: &str, shell_id: &str) {
        let handle = {
            let mut map = self.inner.lock().unwrap_or_else(|p| p.into_inner());
            map.remove(&(owner.to_string(), shell_id.to_string()))
        };
        if let Some(h) = handle {
            h.stop.store(true, Ordering::SeqCst);
            let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, owner, shell_id);
            kill_waker_at(&perch);
            let _ = h.thread.join();
        }
    }
}

/// Kill the recorded waker pid (if any) and retire the pid file — the
/// mutual-exclusivity enforcement point and the orphan cleanup
/// ([`crate::shellhost::launch_shell`] calls this before the binary rises;
/// the reconciler calls it before adopting an instance a dead daemon left).
// [impl->REQ-SHELL-2]
pub fn kill_waker_at(shell_perch: &Path) {
    let pid_file = shell_perch.join(WAKER_PID_FILE);
    if let Ok(s) = std::fs::read_to_string(&pid_file) {
        if let Ok(pid) = s.trim().parse::<u32>() {
            if pid != 0 && spt_store::proc::is_process_alive(pid) {
                crate::shellhost::kill_shell_pid(pid);
            }
        }
    }
    let _ = std::fs::remove_file(pid_file);
}

/// Clear the give-up latch — any (re)launch is "next shell activity".
pub fn clear_gave_up(shell_perch: &Path) {
    let _ = std::fs::remove_file(shell_perch.join(WAKER_GAVE_UP_FILE));
}

/// One watcher's supervision loop (runs on its own thread): spawn the
/// tokenized `wake_command`, park the pid, wait; `exit(WAKE_OPCODE)` ⇒ run
/// `resolve` once and finish; any other exit ⇒ respawn with exponential
/// backoff until the give-up budget, then drop the durable latch. A raised
/// `stop` flag ends the loop without counting the (killed) child as a crash.
// [impl->REQ-SHELL-2]
pub fn watcher_run(
    owlery: &Path,
    owner: &str,
    shell_id: &str,
    tokens: &[String],
    params: &WakeParams,
    stop: &AtomicBool,
    resolve: impl FnOnce() -> Result<String, String>,
) {
    let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, owner, shell_id);
    let Some((program, args)) = tokens.split_first() else {
        eprintln!("WAKER_EMPTY:{owner}/{shell_id}: empty wake_command");
        return;
    };
    let mut failures = 0u32;
    while !stop.load(Ordering::SeqCst) {
        let mut cmd = Command::new(program);
        cmd.args(args)
            .stdin(Stdio::null())
            .stdout(Stdio::null())
            .stderr(Stdio::null());
        // The daemon (a console-less DETACHED process) hosting a
        // console-subsystem waker would otherwise pop a visible Terminal
        // window per spawn on Windows 11 (the KH 5.6-adjacent D3e lesson —
        // mock-shell's spt_cmd does the same).
        #[cfg(windows)]
        {
            use std::os::windows::process::CommandExt;
            cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
        }
        let child = cmd.spawn();
        let mut child = match child {
            Ok(c) => c,
            Err(e) => {
                // An unspawnable waker is a permanent condition — latch now.
                eprintln!("WAKER_SPAWN_FAIL:{owner}/{shell_id}: {e} (giving up)");
                let _ = std::fs::write(perch.join(WAKER_GAVE_UP_FILE), b"");
                return;
            }
        };
        let _ = std::fs::write(perch.join(WAKER_PID_FILE), child.id().to_string());
        let status = child.wait();
        let _ = std::fs::remove_file(perch.join(WAKER_PID_FILE));
        if stop.load(Ordering::SeqCst) {
            return; // stopped from outside — the kill is not a crash
        }
        match status.ok().and_then(|s| s.code()) {
            Some(WAKE_OPCODE) => {
                match resolve() {
                    Ok(what) => eprintln!("SHELL_WAKE:{owner}/{shell_id}: {what}"),
                    Err(e) => eprintln!("SHELL_WAKE_FAIL:{owner}/{shell_id}: {e}"),
                }
                return;
            }
            _ => {
                failures += 1;
                if failures >= params.give_up_after {
                    eprintln!(
                        "WAKER_GAVE_UP:{owner}/{shell_id}: {failures} consecutive crash-exits \
                         (cleared by the next relink/launch)"
                    );
                    let _ = std::fs::write(perch.join(WAKER_GAVE_UP_FILE), b"");
                    return;
                }
                // Backoff in small slices so a stop request lands promptly.
                let mut left = backoff_ms(params, failures);
                while left > 0 && !stop.load(Ordering::SeqCst) {
                    let step = left.min(50);
                    std::thread::sleep(Duration::from_millis(step));
                    left -= step;
                }
            }
        }
    }
}

/// The **state-keyed wake resolution** (M5-D4c — CONTEXT §Shell sleep/wake):
/// key on the owner's local rest record, then bring the shell online —
///
/// - owner **dormant** (resting warm, still running) ⇒ touch nothing on the
///   endpoint, just relaunch the shell binary;
/// - owner **suspended** ⇒ revive the owner first
///   ([`crate::resting::daemon_rest_event`] `Wake` — its cascade relaunches
///   the owner's *persistent* shells, so this fn launches only if the
///   cascade did not already), then the shell;
/// - owner **active** (or recordless — a pre-resting/interim perch) ⇒ just
///   relaunch the shell;
/// - **no local instance of the owner** ⇒ refuse, naming the deferral: the
///   `shell_wake_spawn_anywhere` fresh-spawn branch rides
///   instantiate-anywhere (the D1c grant shape is its seam), and the
///   *active-elsewhere cross-node attach* arm upgrades with presence/MRA
///   (D6) + cross-node link (D8c) — D4 resolves against the local node.
///
/// The shell's bind onlines the perch (the D3b contract) — this fn never
/// flips status.
// [impl->REQ-SHELL-2]
pub fn resolve_wake(
    owlery: &Path,
    owner: &str,
    shell_id: &str,
    adapter_name: &str,
    shell: &Shell,
) -> Result<String, String> {
    // Shells hang off flat-Self perches only (the D3a layout decision).
    let owner_perch = owlery.join(owner);
    if !owner_perch.join("info.json").exists() {
        // No local instance — consult presence (M5-D6b, REQ-PRES-1): a
        // routable instance of the owner on ANOTHER node gets the wake
        // FORWARDED there through the D5b remote rest op (the target node's
        // own cascade relaunches ITS persistent shells). This local shell
        // stays offline — the cross-node shell *link* is D8c; say so.
        // [impl->REQ-PRES-1]
        if let Some(node) = remote_owner_node(owner) {
            return match forward_wake(owner, &node) {
                Ok(outcome) => Ok(format!(
                    "WAKE_FORWARDED:{owner}@{node}: {outcome}; this shell stays offline \
                     here (cross-node shell link lands at D8c)"
                )),
                Err(e) => Err(format!("WAKE_FORWARD_FAIL:{owner}@{node}: {e}")),
            };
        }
        return Err(format!(
            "WAKE_NO_REACHABLE_INSTANCE:{owner}: no instance of the owner on any \
             reachable node — a fresh-spawn-to-wake rides the deferred \
             instantiate-anywhere capability (the shell_wake_spawn_anywhere grant \
             shape is its seam)"
        ));
    }
    let state = crate::resting::read_rest(&owner_perch).map(|r| r.state);
    let mut did = String::new();
    if state == Some(crate::resting::RestState::Suspended) {
        match crate::resting::daemon_rest_event(owner, crate::resting::RestEvent::Wake, None) {
            Ok(_) => did.push_str("revived owner; "),
            Err(e) => return Err(format!("revive owner: {e}")),
        }
    } else if state == Some(crate::resting::RestState::Dormant) {
        did.push_str("owner dormant (left in place); ");
    }
    // Never double-launch a live binary: the revive's wake cascade may have
    // relaunched this (persistent) instance already — and two reconcilers
    // (a fresh daemon adopting + a stale watcher firing) may race a wake.
    // A parked pid that probes alive means somebody won; stand down.
    let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, owner, shell_id);
    let live_pid = std::fs::read_to_string(perch.join(crate::shellhost::SHELL_PID_FILE))
        .ok()
        .and_then(|s| s.trim().parse::<u32>().ok())
        .filter(|&p| p != 0 && spt_store::proc::is_process_alive(p));
    if let Some(p) = live_pid {
        return Ok(format!("{did}already relaunched pid={p} (online at bind)"));
    }
    let pid = crate::shellhost::launch_shell(owlery, owner, shell_id, adapter_name, shell)?;
    Ok(format!("{did}relaunched pid={pid} (online at bind)"))
}

/// The node (hex) holding a routable instance of `owner` per the gossiped
/// registry snapshots, preferring the most-recently-active row when several
/// nodes hold one (the presence datum as the tiebreak; absent data ranks
/// last). `None` = the owner exists nowhere reachable.
// [impl->REQ-PRES-1]
fn remote_owner_node(owner: &str) -> Option<String> {
    let regs = crate::presence::load_registry_snapshots(&crate::presence::registry_snapshot_dir());
    let local = crate::presence::local_node_hex();
    let mut best: Option<(u64, String)> = None;
    for reg in regs.values() {
        for row in reg.instances(owner) {
            if row.node == local || !row.status.routable() {
                continue;
            }
            let ts = row.last_active_ms.unwrap_or(0);
            let better = match &best {
                Some((cur, _)) => ts > *cur,
                None => true,
            };
            if better {
                best = Some((ts, row.node.clone()));
            }
        }
    }
    best.map(|(_, node)| node)
}

/// Forward a wake to `node` through the D5b remote rest op (the remote-drive
/// trust class — the target's access gate decides). One dial, one request,
/// one reply; a refusal (no reply) surfaces as an error.
// [impl->REQ-PRES-1]
fn forward_wake(owner: &str, node: &str) -> Result<String, String> {
    use spt_net::net::endpoint::addr_for_node_hex;
    let addr = addr_for_node_hex(node)
        .and_then(|a| serde_json::to_value(a).ok())
        .ok_or_else(|| format!("no dialable address for {node}"))?;
    let mut brain =
        crate::brain::Brain::cold_start(&crate::endpoint::broker_socket_name(), now_ms())
            .map_err(|e| format!("broker connect: {e}"))?;
    let conn = brain
        .net_dial(addr, None)
        .map_err(|e| format!("dial: {e}"))?;
    let op_id = format!("{}:wakefwd:{}", crate::presence::local_node_hex(), now_ms());
    match crate::resthost::request_rest(
        &mut brain,
        conn.conn_id,
        owner,
        spt_net::net::rest::REST_EVENT_WAKE,
        &op_id,
        now_ms(),
    )
    .map_err(|e| format!("rest op: {e}"))?
    {
        crate::resthost::RestRequestOutcome::Edge(d) => Ok(format!("woke ({d})")),
        crate::resthost::RestRequestOutcome::NoEdge => Ok("already awake".to_string()),
        crate::resthost::RestRequestOutcome::Failed(e) => Err(e),
        crate::resthost::RestRequestOutcome::NoReply => {
            Err("refused or dropped (no reply)".to_string())
        }
    }
}

/// Resolve one adapter **option**'s `[shell]` section through the merged view
/// (composite addressing): `adapter_option` is the stored `<adapter>[:profile]`
/// string, split → parent lookup in `registered` → profile overlay (shipped or
/// local). Returns an **owned** [`Shell`] — a profile produces a fresh merged
/// manifest, not a borrow into the parent slice. A bare name resolves to the
/// parent unmodified; a deregistered parent or a failed overlay yields `None`.
fn shell_section_of(
    registered: &[(AdapterRecord, Manifest)],
    adapters_dir: &Path,
    adapter_option: &str,
) -> Option<Shell> {
    spt_runtime::registry::resolve_option_in(registered, adapters_dir, adapter_option)
        .ok()
        .filter(|m| m.adapter.kind == AdapterKind::Shell)
        .and_then(|m| m.shell)
}

/// Fill a `wake_command` template. The substitution catalog matches the spawn
/// template's (`id`, `adapter_name`, `link_token`) so one manifest vocabulary
/// serves both — but the waker's token is minted **unparked**: it substitutes
/// (template compatibility) without ever verifying against the perch, because
/// an offline link has no live credential by design (the close retired it; a
/// waker wakes by *exit code*, not by driving the link).
fn now_ms() -> u64 {
    crate::brain::now_ms()
}

fn fill_wake_command(
    shell_id: &str,
    adapter_name: &str,
    wake_command: &str,
) -> Result<Vec<String>, String> {
    let keys = std::collections::BTreeMap::from([
        ("id".to_string(), shell_id.to_string()),
        ("adapter_name".to_string(), adapter_name.to_string()),
        (
            "link_token".to_string(),
            crate::shellhost::mint_link_token(),
        ),
    ]);
    // [impl->REQ-HAZARD-TEMPLATE-ARGV-FILL] tokenize-template-then-fill-each: a
    // multi-word/quote/semicolon {key} value is exactly one argv element.
    let tokens =
        spt_runtime::runtime::fill_template_tokens(wake_command, &keys).map_err(|e| e.to_string())?;
    if tokens.is_empty() {
        return Err("empty wake_command".into());
    }
    Ok(tokens)
}

/// One reconcile sweep — the invariant holder for the online/offline mutual
/// exclusivity (CONTEXT: "spt-core flips between them"):
///
/// - **offline + `wake_command` + no give-up latch + no live watcher** ⇒
///   kill any orphaned waker pid (a dead daemon's leftover), then start a
///   supervision thread.
/// - **not offline (onlined / torn down / deregistered) + live watcher** ⇒
///   stop it (flag + kill).
///
/// Runs at daemon boot and every tick — lifecycle flips happen in CLI-process
/// library code, so the *reconciler* holds the invariant, not the flipping
/// caller; a flip is at most one tick stale.
// [impl->REQ-SHELL-2]
pub fn reconcile_once(
    owlery: &Path,
    registered: &[(AdapterRecord, Manifest)],
    adapters_dir: &Path,
    set: &Arc<WakeSet>,
    params: &WakeParams,
) {
    // Stop side first: watchers whose instance is gone or no longer eligible.
    let live: Vec<(String, String)> = {
        let mut map = set.inner.lock().unwrap_or_else(|p| p.into_inner());
        map.retain(|_, h| !h.thread.is_finished());
        map.keys().cloned().collect()
    };
    for (owner, shell_id) in live {
        let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, &owner, &shell_id);
        let still_eligible = shellinfo::read_shell_info(&perch)
            .map(|i| i.status == SHELL_STATUS_OFFLINE)
            .unwrap_or(false);
        if !still_eligible {
            set.stop_watcher(owlery, &owner, &shell_id);
        }
    }

    // Start side: every offline instance with a wake_command and headroom.
    for owner in spt_store::perch::list_self_perch_ids(owlery) {
        for (shell_id, info) in shellinfo::list_shells(owlery, &owner) {
            if info.status != SHELL_STATUS_OFFLINE || set.contains(&owner, &shell_id) {
                continue;
            }
            let Some(shell) = shell_section_of(registered, adapters_dir, &info.adapter_name) else {
                continue; // deregistered adapter: nothing to run
            };
            let Some(wake_command) = shell.wake_command.as_deref() else {
                continue;
            };
            let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, &owner, &shell_id);
            if perch.join(WAKER_GAVE_UP_FILE).exists() {
                continue; // crash-latched until the next shell activity
            }
            // Adopt cleanly: a dead daemon's watcher must not double-run.
            kill_waker_at(&perch);
            let tokens = match fill_wake_command(&shell_id, &info.adapter_name, wake_command) {
                Ok(t) => t,
                Err(e) => {
                    eprintln!("WAKER_TEMPLATE:{owner}/{shell_id}: {e}");
                    continue;
                }
            };
            let stop = Arc::new(AtomicBool::new(false));
            let handle = {
                let stop = Arc::clone(&stop);
                let owlery = owlery.to_path_buf();
                let owner = owner.clone();
                let shell_id = shell_id.clone();
                let adapter_name = info.adapter_name.clone();
                let params = *params;
                std::thread::spawn(move || {
                    watcher_run(&owlery, &owner, &shell_id, &tokens, &params, &stop, || {
                        resolve_wake(&owlery, &owner, &shell_id, &adapter_name, &shell)
                    });
                })
            };
            set.insert(
                &owner,
                &shell_id,
                WatcherHandle {
                    stop,
                    thread: handle,
                },
            );
        }
    }
}

/// The reconcile cadence (ms): lifecycle flips land in CLI processes, so the
/// daemon's loop is the invariant holder — a flip is at most one tick stale.
pub const RECONCILE_INTERVAL_MS: u64 = 5_000;

/// Spawn the daemon's wake host: one thread sweeping [`reconcile_once`] at
/// boot and every [`RECONCILE_INTERVAL_MS`] until `stop`. The registered set
/// is re-read each sweep (adapter add/remove lands between ticks).
// [impl->REQ-SHELL-2]
pub fn spawn_wake_host(stop: Arc<AtomicBool>) -> JoinHandle<()> {
    std::thread::spawn(move || {
        let set = Arc::new(WakeSet::new());
        let params = WakeParams::default();
        while !stop.load(Ordering::SeqCst) {
            let owlery = spt_store::perch::owlery_dir();
            let adapters_dir = spt_store::perch::adapters_dir();
            let registered = spt_runtime::registry::registered(&adapters_dir);
            reconcile_once(&owlery, &registered, &adapters_dir, &set, &params);
            // Sleep in slices so a stop lands promptly.
            let mut left = RECONCILE_INTERVAL_MS;
            while left > 0 && !stop.load(Ordering::SeqCst) {
                let step = left.min(100);
                std::thread::sleep(Duration::from_millis(step));
                left -= step;
            }
        }
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use spt_store::shellinfo::{spawn_record, SHELL_STATUS_ONLINE};

    fn fast_params() -> WakeParams {
        WakeParams {
            backoff_base_ms: 10,
            backoff_cap_ms: 40,
            give_up_after: 3,
        }
    }

    fn seed_owner(owlery: &Path, owner: &str) {
        let p = owlery.join(owner);
        std::fs::create_dir_all(&p).unwrap();
        std::fs::write(p.join("info.json"), "{}").unwrap();
    }

    /// Tokens for a waker that exits with `code` immediately.
    fn exit_with(code: i32) -> Vec<String> {
        #[cfg(windows)]
        return vec!["cmd".into(), "/c".into(), format!("exit {code}")];
        #[cfg(unix)]
        return vec!["sh".into(), "-c".into(), format!("exit {code}")];
    }

    /// Tokens for a waker that appends one line to `tally` then exits 1.
    fn crash_tallying(tally: &Path) -> Vec<String> {
        #[cfg(windows)]
        return vec![
            "powershell".into(),
            "-NoProfile".into(),
            "-Command".into(),
            format!("Add-Content -Path '{}' -Value x; exit 1", tally.display()),
        ];
        #[cfg(unix)]
        return vec![
            "sh".into(),
            "-c".into(),
            format!("echo x >> '{}'; exit 1", tally.display()),
        ];
    }

    // [unit->REQ-SHELL-2] the backoff curve: base × 2^(n-1), saturating at
    // the cap; zero failures wait nothing.
    #[test]
    fn backoff_curve_doubles_to_the_cap() {
        let p = WakeParams {
            backoff_base_ms: 100,
            backoff_cap_ms: 1_500,
            give_up_after: 6,
        };
        assert_eq!(backoff_ms(&p, 0), 0);
        assert_eq!(backoff_ms(&p, 1), 100);
        assert_eq!(backoff_ms(&p, 2), 200);
        assert_eq!(backoff_ms(&p, 4), 800);
        assert_eq!(backoff_ms(&p, 5), 1_500, "saturates at the cap");
        assert_eq!(
            backoff_ms(&p, 60),
            1_500,
            "huge failure counts can't overflow"
        );
    }

    // [unit->REQ-SHELL-2] exit-opcode supervision: exit(86) fires the wake
    // resolution exactly once and the loop ends; the pid file is retired.
    #[test]
    fn watcher_opcode_exit_fires_resolution_once() {
        let tmp = tempfile::tempdir().unwrap();
        let owlery = tmp.path();
        seed_owner(owlery, "doyle");
        let id = spawn_record(owlery, "doyle", "mock-shell", None).unwrap();
        let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, "doyle", &id);

        let fired = std::sync::atomic::AtomicU32::new(0);
        let stop = AtomicBool::new(false);
        watcher_run(
            owlery,
            "doyle",
            &id,
            &exit_with(WAKE_OPCODE),
            &fast_params(),
            &stop,
            || {
                fired.fetch_add(1, Ordering::SeqCst);
                Ok("test-resolved".into())
            },
        );
        assert_eq!(
            fired.load(Ordering::SeqCst),
            1,
            "resolution fired exactly once"
        );
        assert!(!perch.join(WAKER_PID_FILE).exists(), "pid file retired");
        assert!(
            !perch.join(WAKER_GAVE_UP_FILE).exists(),
            "an opcode exit is not a crash"
        );
    }

    // [unit->REQ-SHELL-2] crash-exit supervision: a crashing waker respawns
    // (with backoff) exactly give_up_after times, then the durable give-up
    // latch drops and the resolution never fires; clear_gave_up re-arms.
    #[test]
    fn watcher_crash_exits_respawn_then_give_up() {
        let tmp = tempfile::tempdir().unwrap();
        let owlery = tmp.path();
        seed_owner(owlery, "doyle");
        let id = spawn_record(owlery, "doyle", "mock-shell", None).unwrap();
        let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, "doyle", &id);
        let tally = perch.join("waker-tally.txt");

        let stop = AtomicBool::new(false);
        watcher_run(
            owlery,
            "doyle",
            &id,
            &crash_tallying(&tally),
            &fast_params(),
            &stop,
            || -> Result<String, String> { panic!("a crash-exit must never resolve a wake") },
        );
        let runs = std::fs::read_to_string(&tally).unwrap().lines().count();
        assert_eq!(runs, 3, "respawned exactly to the give-up budget");
        assert!(
            perch.join(WAKER_GAVE_UP_FILE).exists(),
            "the durable latch dropped"
        );

        clear_gave_up(&perch);
        assert!(
            !perch.join(WAKER_GAVE_UP_FILE).exists(),
            "shell activity re-arms"
        );
    }

    /// A `[shell]` section parsed from TOML (the manifest is the only
    /// constructor — its serde defaults are part of the contract).
    fn shell_section(spawn: &str, extra: &str) -> Shell {
        let toml_src = format!(
            "[adapter]\nname = \"mock-wake\"\nkind = \"shell\"\nversion = \"1\"\n\
             min_spt_core_version = \"0\"\n\n[shell]\nspawn = '{spawn}'\n{extra}\n"
        );
        spt_runtime::Manifest::from_toml_str(&toml_src)
            .unwrap()
            .shell
            .unwrap()
    }

    #[cfg(windows)]
    const NOOP: &str = "cmd /c exit 0";
    #[cfg(unix)]
    const NOOP: &str = "true";

    // [unit->REQ-SHELL-2] wake resolution, the no-reachable branch: no local
    // instance of the owner ⇒ refuse naming the instantiate-anywhere
    // deferral — never a silent no-op, never a fresh spawn.
    #[test]
    fn resolve_wake_refuses_without_a_reachable_owner() {
        let tmp = tempfile::tempdir().unwrap();
        let owlery = tmp.path();
        // The shell perch exists; the OWNER's perch does not (home node gone).
        let id = spawn_record(owlery, "ghost", "mock-wake", None).unwrap();
        let err =
            resolve_wake(owlery, "ghost", &id, "mock-wake", &shell_section(NOOP, "")).unwrap_err();
        assert!(err.contains("WAKE_NO_REACHABLE_INSTANCE"), "{err}");
        assert!(
            err.contains("instantiate-anywhere"),
            "the deferral is named: {err}"
        );
    }

    // [unit->REQ-SHELL-2] wake resolution, the dormant branch: the owner is
    // resting warm — touch nothing on the endpoint, just relaunch the shell.
    #[test]
    fn resolve_wake_leaves_a_dormant_owner_and_relaunches() {
        use spt_store::info::{write_info, InfoJson};
        let tmp = tempfile::tempdir().unwrap();
        let owlery = tmp.path();
        let owner_perch = owlery.join("ling");
        std::fs::create_dir_all(&owner_perch).unwrap();
        write_info(
            &owner_perch,
            &InfoJson::new("ling", "t", 4242, "sid", "live_agent"),
        )
        .unwrap();
        crate::resting::write_rest(&owner_perch, crate::resting::RestState::Dormant, 1_000)
            .unwrap();

        let id = spawn_record(owlery, "ling", "mock-wake", None).unwrap();
        let msg = resolve_wake(owlery, "ling", &id, "mock-wake", &shell_section(NOOP, ""))
            .expect("resolves");
        assert!(msg.contains("owner dormant"), "{msg}");
        assert!(msg.contains("relaunched"), "{msg}");
        let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, "ling", &id);
        assert!(
            perch.join(crate::shellhost::SHELL_PID_FILE).exists(),
            "binary relaunched"
        );
        assert_eq!(
            crate::resting::read_rest(&owner_perch).unwrap().state,
            crate::resting::RestState::Dormant,
            "a dormant owner is left in place"
        );
    }

    // [unit->REQ-SHELL-2] wake resolution, the suspended branch: revive the
    // owner FIRST (the real daemon_rest_event — its wake cascade relaunches
    // persistent shells, and the resolution then must NOT double-launch a
    // live binary); a non-persistent shell is launched by the resolution
    // itself after the revive.
    #[test]
    fn resolve_wake_revives_a_suspended_owner_without_double_launch() {
        crate::test_home::with_home(|home| {
            use spt_store::info::{write_info, InfoJson};
            use spt_store::perch;
            let owlery = perch::owlery_dir();
            let owner_perch =
                perch::resolve_perch_path("ling", spt_store::perch::ParentHint::Infer);
            std::fs::create_dir_all(&owner_perch).unwrap();
            write_info(
                &owner_perch,
                &InfoJson::new("ling", "t", 4242, "sid", "live_agent"),
            )
            .unwrap();
            crate::resting::write_rest(&owner_perch, crate::resting::RestState::Suspended, 1_000)
                .unwrap();

            // A registered PERSISTENT sleeper adapter — the revive's wake
            // cascade relaunches it and the binary stays alive.
            #[cfg(windows)]
            let sleeper = "ping -n 30 127.0.0.1";
            #[cfg(unix)]
            let sleeper = "sleep 30";
            let adapters = perch::adapters_dir();
            for (name, spawn, extra) in [
                ("mock-wake", sleeper, "persistent = true"),
                ("mock-noper", NOOP, ""), // the cascade must skip this one
            ] {
                let src = home.join("srcs").join(name);
                std::fs::create_dir_all(&src).unwrap();
                std::fs::write(
                    src.join("manifest.toml"),
                    format!(
                        "[adapter]\nname = \"{name}\"\nkind = \"shell\"\nversion = \"1\"\n\
                         min_spt_core_version = \"0\"\n\n[shell]\nspawn = '{spawn}'\n{extra}\n"
                    ),
                )
                .unwrap();
                spt_runtime::registry::register(&adapters, &src, 1).unwrap();
            }
            let shell = shell_section(sleeper, "persistent = true");

            let id = spawn_record(&owlery, "ling", "mock-wake", None).unwrap();
            let msg = resolve_wake(&owlery, "ling", &id, "mock-wake", &shell).expect("resolves");
            assert!(msg.contains("revived owner"), "{msg}");
            assert!(
                msg.contains("already relaunched"),
                "a live cascade launch is never doubled: {msg}"
            );
            assert_eq!(
                crate::resting::read_rest(&owner_perch).unwrap().state,
                crate::resting::RestState::Active,
                "suspended owner revived"
            );

            // Re-suspend and resolve a NON-persistent shell of the same
            // owner: the revive's cascade skips it (its registered adapter
            // is not persistent), so the resolution launches it itself.
            crate::resting::daemon_rest_event("ling", crate::resting::RestEvent::Suspend, None)
                .expect("ok")
                .expect("edge");
            let shell_np = shell_section(NOOP, "");
            let id2 = spawn_record(&owlery, "ling", "mock-noper", None).unwrap();
            let msg =
                resolve_wake(&owlery, "ling", &id2, "mock-noper", &shell_np).expect("resolves");
            assert!(msg.contains("revived owner"), "{msg}");
            assert!(msg.contains("relaunched pid="), "{msg}");
        });
    }

    // [unit->REQ-SHELL-2] the reconciler holds the mutual exclusivity: an
    // offline instance with a wake_command gets exactly ONE watcher (a second
    // sweep never doubles it); a give-up latch suppresses adoption; onlining
    // the instance stops the watcher on the next sweep.
    #[test]
    fn reconcile_flips_watchers_with_instance_state() {
        let tmp = tempfile::tempdir().unwrap();
        let owlery = tmp.path();
        seed_owner(owlery, "doyle");

        // A registered shell adapter whose waker sleeps (stays alive between
        // sweeps) — the long-running watcher case.
        #[cfg(windows)]
        let (noop, sleeper) = ("cmd /c exit 0", "ping -n 30 127.0.0.1");
        #[cfg(unix)]
        let (noop, sleeper) = ("true", "sleep 30");
        let adapters_dir = tmp.path().join("adapters");
        let src = tmp.path().join("srcs").join("mock-wake");
        std::fs::create_dir_all(&src).unwrap();
        std::fs::write(
            src.join("manifest.toml"),
            format!(
                "[adapter]\nname = \"mock-wake\"\nkind = \"shell\"\nversion = \"1\"\n\
                 min_spt_core_version = \"0\"\n\n[shell]\nspawn = '{noop}'\n\
                 wake_command = '{sleeper}'\n"
            ),
        )
        .unwrap();
        spt_runtime::registry::register(&adapters_dir, &src, 1).unwrap();
        let registered = spt_runtime::registry::registered(&adapters_dir);

        let id = spawn_record(owlery, "doyle", "mock-wake", None).unwrap();
        let perch = spt_store::perch::resolve_shell_perch_path_in(owlery, "doyle", &id);

        let set = Arc::new(WakeSet::new());
        let params = fast_params();

        // Offline (the spawn_record default) ⇒ one watcher; never two.
        reconcile_once(owlery, &registered, &adapters_dir, &set, &params);
        assert_eq!(set.len(), 1, "offline + wake_command ⇒ a watcher");
        reconcile_once(owlery, &registered, &adapters_dir, &set, &params);
        assert_eq!(set.len(), 1, "a second sweep never doubles it");
        // The child parked its pid (it is the long sleeper, still up).
        for _ in 0..100 {
            if perch.join(WAKER_PID_FILE).exists() {
                break;
            }
            std::thread::sleep(Duration::from_millis(20));
        }
        let waker_pid: u32 = std::fs::read_to_string(perch.join(WAKER_PID_FILE))
            .expect("waker pid parked")
            .trim()
            .parse()
            .unwrap();
        assert!(
            spt_store::proc::is_process_alive(waker_pid),
            "the waker runs while offline"
        );

        // Online the instance ⇒ the next sweep stops the watcher + kills the
        // child (mutual exclusivity, the binary side wins).
        let mut info = shellinfo::read_shell_info(&perch).unwrap();
        info.status = SHELL_STATUS_ONLINE.to_string();
        shellinfo::write_shell_info(&perch, &info).unwrap();
        reconcile_once(owlery, &registered, &adapters_dir, &set, &params);
        assert_eq!(set.len(), 0, "onlined instance keeps no watcher");
        assert!(
            !spt_store::proc::is_process_alive(waker_pid),
            "the waker child was killed"
        );

        // Back offline but crash-latched ⇒ no adoption.
        let mut info = shellinfo::read_shell_info(&perch).unwrap();
        info.status = SHELL_STATUS_OFFLINE.to_string();
        shellinfo::write_shell_info(&perch, &info).unwrap();
        std::fs::write(perch.join(WAKER_GAVE_UP_FILE), b"").unwrap();
        reconcile_once(owlery, &registered, &adapters_dir, &set, &params);
        assert_eq!(set.len(), 0, "the give-up latch suppresses adoption");
    }

    // [unit->REQ-MANIFEST-2] the wake seam resolves the **merged view**: a
    // shipped profile that adds `wake_command` makes a `<adapter>:<profile>`
    // instance wakeable, while the bare parent (no wake_command) stays inert —
    // proof the daemon re-resolves the stored composite, not the parent.
    #[test]
    fn reconcile_resolves_profile_overlay() {
        let tmp = tempfile::tempdir().unwrap();
        let owlery = tmp.path();
        seed_owner(owlery, "doyle");

        #[cfg(windows)]
        let (noop, sleeper) = ("cmd /c exit 0", "ping -n 30 127.0.0.1");
        #[cfg(unix)]
        let (noop, sleeper) = ("true", "sleep 30");
        let adapters_dir = tmp.path().join("adapters");
        let src = tmp.path().join("srcs").join("mock-wakeprof");
        std::fs::create_dir_all(&src).unwrap();
        std::fs::write(
            src.join("manifest.toml"),
            format!(
                "[adapter]\nname = \"mock-wakeprof\"\nkind = \"shell\"\nversion = \"1\"\n\
                 min_spt_core_version = \"0\"\n\n[shell]\nspawn = '{noop}'\n\n\
                 [profiles.waker]\n[profiles.waker.shell]\nwake_command = '{sleeper}'\n"
            ),
        )
        .unwrap();
        spt_runtime::registry::register(&adapters_dir, &src, 1).unwrap();
        let registered = spt_runtime::registry::registered(&adapters_dir);

        // A bare-parent instance: no wake_command in the parent ⇒ never woken.
        spawn_record(owlery, "doyle", "mock-wakeprof", None).unwrap();
        // A profiled instance: mint a clean id off the parent, then store the
        // composite as its adapter_name (the id stays colon-free; the carrier
        // holds the option — the #8 identity split).
        let pid = spawn_record(owlery, "doyle", "mock-wakeprof", None).unwrap();
        let pperch = spt_store::perch::resolve_shell_perch_path_in(owlery, "doyle", &pid);
        let mut info = shellinfo::read_shell_info(&pperch).unwrap();
        info.adapter_name = "mock-wakeprof:waker".to_string();
        shellinfo::write_shell_info(&pperch, &info).unwrap();

        let set = Arc::new(WakeSet::new());
        let params = fast_params();
        reconcile_once(owlery, &registered, &adapters_dir, &set, &params);
        assert_eq!(
            set.len(),
            1,
            "only the profiled instance (overlay adds wake_command) gets a watcher"
        );
        assert!(set.contains("doyle", &pid), "the profiled instance is the one watched");

        set.stop_watcher(owlery, "doyle", &pid);
    }
}


crates\spt-term\src\winprog.rs:24:/// spawn; `prefix_args` precede the caller's own args (e.g. `["/d", "/c", path]`
crates\xtask\src\main.rs:1144:            ConvergeState::StagedAwaitingConsent { staged } => {
crates\xtask\src\main.rs:1145:                format!("StagedAwaitingConsent  v{staged}  (run: spt update apply)")
crates\xtask\src\main.rs:1273:            ConvergeState::StagedAwaitingConsent { staged: 7 },
crates\xtask\src\main.rs:1284:        assert!(table.contains("StagedAwaitingConsent  v7  (run: spt update apply)"));
crates\spt-daemon\tests\notifsync.rs:163:            "consent",
crates\spt-proto\src\id.rs:52:/// True if `c` is permitted in an endpoint id.
crates\spt-daemon\tests\wanmsg.rs:285:        "from is reply metadata, not an authorization subject"
crates\spt-runtime\src\registry.rs:525:/// breaks a manifest invariant or **loosens a consent floor** (tighten-only).
crates\spt-runtime\src\registry.rs:1040:        // Loosen the consent floor (remembered -> none).
crates\spt-runtime\src\registry.rs:1289:        // An unknown target is a clean NotRegistered (not a panic).
crates\spt-proto\src\event.rs:131:/// Whether `origin` is a **user-backed** origin permitted to author `user-msg`
crates\spt-live\src\psyche.rs:89:/// `keys` supplies the adapter-template substitution values the caller owns
crates\spt-net\src\net\wanmsg.rs:22://! authorization subject.
crates\spt-net\src\net\wanmsg.rs:42:    /// anonymous). Metadata only; never an authorization subject.
crates\spt-runtime\src\profile.rs:78:/// re-runs the manifest invariants **and** the tighten-only consent floor.
crates\spt-runtime\src\profile.rs:82:/// consent floor.
crates\spt-runtime\src\profile.rs:170:/// at the cap outright; `approve` permits it behind a per-spawn approval.
crates\spt-runtime\src\profile.rs:178:/// Consent floors are **tighten-only** (CONTEXT.md §adapter profile): a profile
crates\spt-runtime\src\profile.rs:188:                "profile loosens a consent floor: require_approval {:?} is laxer than parent {:?} (tighten-only)",
crates\spt-runtime\src\profile.rs:194:                "profile loosens a consent floor: over_cap {:?} is laxer than parent {:?} (tighten-only)",
crates\spt-daemon\tests\twohost.rs:360:                // Gated default (REQ-UPD-4): self-heal stages + consent-notifies.
crates\spt-daemon\tests\twohost.rs:1080:    // under the derived trusted key, staged it, and surfaced a CONSENT notif
crates\spt-daemon\tests\twohost.rs:1094:    rig_wait("update: consent notif surfaced at A", rig.wait, || {
crates\spt-daemon\tests\twohost.rs:1097:            .map(|rows| rows.iter().any(|r| r.kind == "consent"))
crates\spt-daemon\tests\twohost.rs:1171:    // pump's consent producer and this rung's producer.
crates\spt-net\src\net\shelllink.rs:112:        with_junk.extend_from_slice(b"{\"kind\":\"spawn_anywhere_at_m9\",\"x\":1}\n");
crates\spt-daemon\tests\attach.rs:159:    let broker_owned = target_broker.to_string();
crates\spt-daemon\tests\pump.rs:404:                // Gated default (REQ-UPD-4) — the consent notif is the assert.
crates\spt-daemon\tests\pump.rs:449:    //    production keys file — and surfaced it as a CONSENT notif (REQ-UPD-4
crates\spt-daemon\tests\pump.rs:458:    converge("A holds the consent notif", || {
crates\spt-daemon\tests\pump.rs:461:            .map(|rows| rows.iter().any(|r| r.kind == "consent"))
crates\spt-runtime\src\manifest.rs:556:/// A capability may carry its own **act-gate** (M11, REQ-CONSENT-3): the same
crates\spt-runtime\src\manifest.rs:561:/// authorizes a storage-class act. Spawn gates govern existence; capability
crates\spt-runtime\src\manifest.rs:563:// [impl->REQ-CONSENT-3]
crates\spt-runtime\src\manifest.rs:651:/// Per-shell instantiation-approval mode (`require_approval`). Reuses the consent
crates\spt-runtime\src\manifest.rs:810:        // [shell.capabilities] act-gates (M11, REQ-CONSENT-3): a `class_key`
crates\spt-runtime\src\manifest.rs:813:        // [impl->REQ-CONSENT-3]
crates\spt-runtime\src\manifest.rs:1302:    // [unit->REQ-CONSENT-3] a capability carries its own act-gate: the
crates\spt-net\src\net\mesh\seedproof.rs:3://! Authorization in the subnet mesh is a **live per-connection proof of
crates\spt\tests\shell_sleepwake_e2e.rs:12://! (the shell end, gated by the manifest `can_shutdown` pre-consent authored
crates\spt\tests\shell_sleepwake_e2e.rs:91:fn sleep_wake_cycle_drives_from_both_ends() {
crates\spt\tests\shell_sleepwake_e2e.rs:110:    // an exit-86 waker + the can_shutdown pre-consent. The ephemeral sibling
crates\spt\tests\shell_sleepwake_e2e.rs:184:    // The can_shutdown pre-consent was authored AT SPAWN (D1c→D4d).
crates\spt-daemon\tests\propagate.rs:571:// Pending → StagedAwaitingConsent → Applied and the status-only query
crates\spt-daemon\tests\propagate.rs:606:    // Round 2: the rollout staged v7 on the lab node — awaiting consent.
crates\spt-daemon\tests\propagate.rs:629:        ConvergeState::StagedAwaitingConsent { staged: target }
crates\spt\tests\shell_e2e.rs:253:    // (non-TTY = CONSENT_PENDING); the grant row flips the same spawn silent.
crates\spt-daemon\src\notif.rs:22://!   (produce → replicable row → fire); the update-consent gate and the
crates\spt-daemon\src\notif.rs:43:use crate::consent::ConsentTarget;

[Showing results with pagination = limit: 50]


/grill-me help me brainstorm a potential adapter. what is the best form for this idea? -- ## a "forum" shell? - multiple agents attach to the singular shell instance (is this possible?) - when one age…


Read the machinery. **Verdict: the targeted-wake primitive already exists and — critically — it's gated on the *target*, not the caller.** This is the best possible news for your design.

**What `resolve_wake` (shellwake.rs:274) actually does, keyed on the target:**
- target **dormant** → relaunch in place
- target **suspended** → `daemon_rest_event(target, Wake)` revives it (shellwake.rs:308)
- target has **no local instance** → consult presence; a routable instance on another node → `forward_wake(target, node)` over the remote rest op — and the comment is the key: *"the remote-drive trust class — **the target's access gate decides**"* (shellwake.rs:361)
- nowhere reachable → refuse, **unless** the target's **`shell_wake_spawn_anywhere`** flag pre-consents a fresh-spawn

**So your caller-ownership worry: there is NO caller-must-own-target guard.** Authorization is entirely **target-side** — the target node's access gate + the target's own pre-consent flag. The shell watcher's owner-binding is purely **structural**: `WakeSet` is keyed `(owner, shell_id)` and passes `owner` as the target, but every underlying fn (`daemon_rest_event(id,…)`, `remote_owner_node(id)`, `forward_wake(id,…)`, `REST_EVENT_WAKE`) is **parameterized by an arbitrary id**. Decouple the target from the watcher's owner and it just works. And `spt endpoint wake <id>` already exists as a command (CHANGELOG.md:295).

**The real new bit isn't the wake — it's the trigger shape.** A shell watcher is the *offline half*: it runs only while its owner is down, fires **one** wake via exit-opcode 86, then dies (shellwake.rs:225). Your forum needs the opposite: a **persistent, always-on contact surface** that fires **many** wakes for **different** targets over time, and — the deep reason your instinct is right — it must run **even when every member agent is offline** (that's how you "draw in" the first one). That's exactly the offline-watcher's defining property (spt-core supervises it independent of agent liveness), generalized: drop *offline-only*, drop *single-target*, drop *exit-opcode-once*.

**So "generalize the wake seam" concretely = three relaxations:**
1. `kind="shell"` → endpoint adapters too (today `shell_section_of` hard-filters `AdapterKind::Shell`, shellwake.rs:407)
2. one-shot exit-opcode → a **long-running supervised contact binary** that calls `endpoint wake <id>` in-band
3. owner-target → **explicit arbitrary target id** (machinery already supports it)

The supervision scaffolding (backoff, give-up latch, one-per-instance lock, orphan-kill, brain-side reconcile) is all built and reusable.

**One residual code-verify** (for the grill-with-docs phase): confirm the `spt endpoint wake <id>` *CLI entrypoint* itself has no caller-auth check beyond the target-side gate. `resolve_wake` + `forward_wake` strongly imply target-only, but the CLI command handler is the one spot I haven't read.

That's the whole core change, sized. Last branch:


AskUserQuestion

Question: What's the shape of the generalized wake seam — how does spt-core host the forum adapter's persistent contact surface?

Choices:
Generalize wake_command: an endpoint adapter declares a long-running binary spt-core boot-launches + supervises (reuse backoff/give-up/one-per-instance), running independent of any agent's liveness. It calls 'endpoint wake <id>' in-band per ping. Runs even when all members offline = can draw in the first agent. The full generalization.
[forum] contact_command = '...'
spt-core: boot-launch + supervise
  (reuse backoff/give-up/lock)
runs even when 0 agents online
binary calls: endpoint wake <id>
target-gated, arbitrary id
The forum endpoint is just an always-ONLINE endpoint process (not the offline-half watcher). It holds the Discord socket and calls 'endpoint wake <id>'. Pure reuse, zero seam change. Cost: if it's modeled as 'online', what supervises/restarts it + runs it when nothing else is? Re-derives supervision you'd get free from option 1.
forum endpoint = always online
holds Discord socket
calls endpoint wake <id>
no wake_command change
BUT: who supervises/restarts it?
  re-invents offline-watcher supervision
Smallest first cut: keep the exit-opcode shell watcher, just decouple its target from owner (let a watcher wake an explicit id). Defer the persistent-contact-binary generalization. Proves targeted wake end-to-end before building the always-on contact model. Doesn't yet enable Discord, but de-risks the core change.
step 1 (now): watcher target = explicit id
  (decouple from owner)
step 2 (later): persistent contact binary
proves targeted-wake first
Discord adapter waits for step 2
Design is sufficiently mapped. Switch to /grill-with-docs to capture decisions in CONTEXT.md + an ADR (Forum endpoint type, generalized wake seam, target-side gating, spt-discord adapter sketch) and do the residual code-verify. Stop grilling, start documenting.
design mapped enough
-> /grill-with-docs
   - CONTEXT.md: Forum endpoint
   - ADR: generalized wake seam
   - verify endpoint-wake CLI auth
   - spt-discord adapter sketch


Your questions have been answered: "What's the shape of the generalized wake seam — how does spt-core host the forum adapter's persistent contact surface?"="Persistent supervised contact binary" selected preview:
[forum] contact_command = '...'
spt-core: boot-launch + supervise
  (reuse backoff/give-up/lock)
runs even when 0 agents online
binary calls: endpoint wake <id>
target-gated, arbitrary id. You can now continue with these answers in mind.

Question: What's the shape of the generalized wake seam — how does spt-core host the forum adapter's persistent contact surface?

Choices:
Generalize wake_command: an endpoint adapter declares a long-running binary spt-core boot-launches + supervises (reuse backoff/give-up/one-per-instance), running independent of any agent's liveness. It calls 'endpoint wake <id>' in-band per ping. Runs even when all members offline = can draw in the first agent. The full generalization.
[forum] contact_command = '...'
spt-core: boot-launch + supervise
  (reuse backoff/give-up/lock)
runs even when 0 agents online
binary calls: endpoint wake <id>
target-gated, arbitrary id
The forum endpoint is just an always-ONLINE endpoint process (not the offline-half watcher). It holds the Discord socket and calls 'endpoint wake <id>'. Pure reuse, zero seam change. Cost: if it's modeled as 'online', what supervises/restarts it + runs it when nothing else is? Re-derives supervision you'd get free from option 1.
forum endpoint = always online
holds Discord socket
calls endpoint wake <id>
no wake_command change
BUT: who supervises/restarts it?
  re-invents offline-watcher supervision
Smallest first cut: keep the exit-opcode shell watcher, just decouple its target from owner (let a watcher wake an explicit id). Defer the persistent-contact-binary generalization. Proves targeted wake end-to-end before building the always-on contact model. Doesn't yet enable Discord, but de-risks the core change.
step 1 (now): watcher target = explicit id
  (decouple from owner)
step 2 (later): persistent contact binary
proves targeted-wake first
Discord adapter waits for step 2
Design is sufficiently mapped. Switch to /grill-with-docs to capture decisions in CONTEXT.md + an ADR (Forum endpoint type, generalized wake seam, target-side gating, spt-discord adapter sketch) and do the residual code-verify. Stop grilling, start documenting.
design mapped enough
-> /grill-with-docs
   - CONTEXT.md: Forum endpoint
   - ADR: generalized wake seam
   - verify endpoint-wake CLI auth
   - spt-discord adapter sketch


API Error: 529 Overloaded. This is a server-side issue, usually temporary — try again in a moment. If it persists, check https://status.claude.com.


<owl_reachability_notice>
You have an active owl perch. When spawning subagents or doing work, use run_in_background: true so you stay reachable for incoming messages.
</owl_reachability_notice>


CAVEMAN MODE ACTIVE (full). Drop articles/filler/pleasantries/hedging. Fragments OK. Code/commits/security: write normal.