diff --git a/tools/claude-spt/src/hook.rs b/tools/claude-spt/src/hook.rs index ee5c078..2da213f 100644 --- a/tools/claude-spt/src/hook.rs +++ b/tools/claude-spt/src/hook.rs @@ -103,6 +103,13 @@ pub trait HookEnv { fn env(&self, key: &str) -> Option; /// The seed pid the dispatch wrapper captured (`--host-pid`). fn host_pid(&self) -> Option; + /// The claude host winpid THIS process resolved from the live process tree (win32: toolhelp + /// walk own pid → parents until a `HOST_BINARIES` basename; unix: None — dispatch's `$PPID` + /// is real there). Authoritative over [`HookEnv::host_pid`] on win32, where msys collapses + /// dispatch's `$PPID` to the pseudo-init `1` whenever the sh parent is a native process + /// (gaki-n field report 2026-07-16 — a NORMAL windowed session, not just headless git-bash). + /// [impl->REQ-LIVE-ANCHOR-WIN32] + fn self_resolved_host_pid(&self) -> Option; /// $HOME (or %USERPROFILE%) — the spill-path root. fn home(&self) -> String; /// The additionalContext byte cap ($SPTC_CTX_CAP, else [`DEFAULT_CAP`]). @@ -359,6 +366,51 @@ fn is_plausible_pid(s: &str) -> bool { s.trim().parse::().map(|n| n > 1).unwrap_or(false) } +/// The host-process exe basenames that anchor a CC session — MIRRORS the manifest's +/// `[adapter] host_binaries` (the pid→binary match-key core walks at bind time); the two lists +/// must stay in lockstep or the hook exports an anchor core cannot resolve. Compared lowercase, +/// `.exe` stripped, EXACT match (our own binary's basename `claude-spt` must never match). +/// [impl->REQ-LIVE-ANCHOR-WIN32] +#[cfg_attr(not(windows), allow(dead_code))] // walk wired on win32 only; units exercise it everywhere +const HOST_BINARIES: &[&str] = &["claude"]; + +/// Walk `table` (rows of `(pid, parent pid, exe basename — lowercase, no `.exe`)`) upward from +/// `start` and return the first ancestor whose basename EXACTLY matches a `hosts` entry. Pure — +/// the win32 toolhelp snapshot feeds it; units feed synthetic tables. Depth-capped + visited-set +/// guarded: a stale snapshot can carry pid-reuse loops, and an unguarded walk would spin the +/// SessionStart hook. `start` itself is checked too (harmless: our basename never matches). +/// [impl->REQ-LIVE-ANCHOR-WIN32] +#[cfg_attr(not(windows), allow(dead_code))] // walk wired on win32 only; units exercise it everywhere +fn find_host_anchor(table: &[(u32, u32, String)], start: u32, hosts: &[&str]) -> Option { + let map: std::collections::HashMap = + table.iter().map(|(pid, ppid, name)| (*pid, (*ppid, name.as_str()))).collect(); + let mut pid = start; + let mut seen = Vec::new(); + for _ in 0..32 { + let (ppid, name) = *map.get(&pid)?; + if hosts.contains(&name) { + return Some(pid); + } + if seen.contains(&pid) { + return None; + } + seen.push(pid); + pid = ppid; + } + None +} + +/// Resolution order for the claude host anchor pid (the SessionStart seed `--pid` AND the +/// `SPT_HOST_PID` export): the live process-tree self-resolution wins when it produced a +/// plausible pid (win32 — authoritative, immune to the msys `$PPID` collapse), else a plausible +/// dispatch `--host-pid` (unix always lands here: real `$PPID`, no walk), else None (the caller +/// logs loud and the live skill's proc-walk fallback engages). [impl->REQ-LIVE-ANCHOR-WIN32] +fn host_anchor(self_resolved: Option, dispatch_pid: Option) -> Option { + self_resolved + .filter(|p| is_plausible_pid(p)) + .or_else(|| dispatch_pid.filter(|p| is_plausible_pid(p))) +} + /// Peer-presence gate for the ring brief: `spt subnet status` output has peers iff it has >1 /// non-empty line (header + ≥1 subnet row). Line-count only — never parses a column value (the /// columnar layout is human-formatted, not a hook contract). [impl->REQ-DIST-SESSIONSTART-BRIEF] @@ -422,20 +474,33 @@ fn append_resume(brief: &str, resume: &str) -> String { } } -/// Does this commune content carry the checkpoint trigger? [unit->REQ-DIST-CHECKPOINT-COMMUNE] -fn has_checkpoint(content: &str) -> bool { - content.contains("!!checkpoint!!") +/// The wake marker an across-commune embeds (v0.23.0 vocabulary — it marks the WAKE message). +/// [impl->REQ-WAKE-RENAME-STAGED] +const WAKE_MARKER: &str = "!!wake!!"; +/// The legacy marker, still ACCEPTED for one transition release (old habits + old Psyche briefs). +/// Agent-facing verbiage no longer teaches it (hard cutover — Claude Code ships an official +/// /checkpoint skill that semantically collides). [impl->REQ-WAKE-RENAME-STAGED] +const LEGACY_WAKE_MARKER: &str = "!!checkpoint!!"; + +/// Does this commune content carry the wake trigger (either marker generation)? +/// [unit->REQ-DIST-CHECKPOINT-COMMUNE] [unit->REQ-WAKE-RENAME-STAGED] +fn has_wake_marker(content: &str) -> bool { + content.contains(WAKE_MARKER) || content.contains(LEGACY_WAKE_MARKER) } -/// Extract a CUSTOM wake directive from commune content — the trimmed text between the first PAIR of -/// `!!checkpoint!!` markers. Empty when there is fewer than one pair (a single marker = default wake, -/// supplied by the translation binary; none = not a checkpoint). [unit->REQ-DIST-CHECKPOINT-COMMUNE] -fn checkpoint_wake(content: &str) -> String { - const M: &str = "!!checkpoint!!"; - let Some(first) = content.find(M) else { return String::new() }; - let after_first = first + M.len(); - let Some(rel) = content[after_first..].find(M) else { return String::new() }; - content[after_first..after_first + rel].trim().to_string() +/// Extract a CUSTOM wake directive from commune content — the trimmed text between the first PAIR +/// of same-generation wake markers. Empty when there is fewer than one pair (a single marker = +/// default wake, supplied by the translation binary; none = not an across-commune). The pair must +/// be one generation — `!!wake!!` text `!!checkpoint!!` is two lone markers, not a pair. +/// [unit->REQ-DIST-CHECKPOINT-COMMUNE] [unit->REQ-WAKE-RENAME-STAGED] +fn wake_directive(content: &str) -> String { + for m in [WAKE_MARKER, LEGACY_WAKE_MARKER] { + let Some(first) = content.find(m) else { continue }; + let after_first = first + m.len(); + let Some(rel) = content[after_first..].find(m) else { continue }; + return content[after_first..after_first + rel].trim().to_string(); + } + String::new() } /// Is this tool call a Write to THIS agent's own commune file `-commune.md`? Suffix match @@ -445,13 +510,17 @@ fn is_commune_write(tool: &str, file_path: &str, id: &str) -> bool { tool == "Write" && !id.is_empty() && file_path.ends_with(&format!("{id}-commune.md")) } -/// Build the structured checkpoint self-send payload: a custom wake rides as `wake`, otherwise it is -/// omitted so the translation binary applies its own default. [impl->REQ-DIST-CHECKPOINT-COMMUNE] -fn checkpoint_payload(wake: &str) -> String { - if wake.is_empty() { - "{\"checkpoint\":\"v1\"}".to_string() +/// Build the structured wake-ARM self-send payload: a custom directive rides as `directive`, +/// otherwise it is omitted so the translation binary applies its own default. +/// EMIT FLIPPED (v0.24.0, REQ-WAKE-EMIT-FLIP — stage 2 of the staged rename): sends the NEW +/// `{"wake_arm":"v1"}` keys. Safe because v0.23.0 shipped receive-both, so every resident +/// translate this release can meet parses the new shape; the legacy `{"checkpoint":"v1"}` +/// ACCEPTANCE retires next release. [impl->REQ-DIST-CHECKPOINT-COMMUNE] [impl->REQ-WAKE-EMIT-FLIP] +fn wake_arm_payload(directive: &str) -> String { + if directive.is_empty() { + "{\"wake_arm\":\"v1\"}".to_string() } else { - format!("{{\"checkpoint\":\"v1\",\"wake\":\"{}\"}}", json_escape(wake)) + format!("{{\"wake_arm\":\"v1\",\"directive\":\"{}\"}}", json_escape(directive)) } } @@ -468,27 +537,27 @@ fn wake_park_rel(endpoint_id: &str) -> String { format!("state/wake/{safe}.park") } -/// ARM a checkpoint (shared by the PostToolUse commune-Write path and the mid-turn commune-tag +/// ARM the wake (shared by the PostToolUse commune-Write path and the mid-turn commune-tag /// path): park the RESOLVED wake directive (custom pair-text, else the shared default) for the /// post-clear stub turn to read back, mark the perch idle so the loopback lands on an idle input -/// box, and self-send the `{"checkpoint":"v1"}` ARM through our own translation binary +/// box, and self-send the wake-ARM envelope through our own translation binary /// (--force-native: only the translation binary's stdin parses the marker — never the active-poll -/// channel, the ENLYZEAM misdelivery mode). The payload still carries the custom wake so a stale -/// RUNNING translate (pre-stub) keeps full-typing it — the documented skew fallback. +/// channel, the ENLYZEAM misdelivery mode). The payload still carries the custom directive so a +/// stale RUNNING translate (pre-stub) keeps full-typing it — the documented skew fallback. /// [impl->REQ-STUB-WAKE] [impl->REQ-HAZARD-CHECKPOINT-CLEAR-RACE] -fn arm_checkpoint(env: &mut dyn HookEnv, id: &str, sid: &str, content: &str) { - let wake = checkpoint_wake(content); +fn arm_wake(env: &mut dyn HookEnv, id: &str, sid: &str, content: &str) { + let wake = wake_directive(content); let directive = if wake.is_empty() { crate::translate::DEFAULT_WAKE } else { &wake }; if !env.write_adapter_state(&wake_park_rel(id), directive) { - // Loud, not fatal: the stub turn falls back to the default directive, so the checkpoint + // Loud, not fatal: the stub turn falls back to the default directive, so the across-commune // still wakes — but a custom directive would be lost, and that must never be silent. - env.log(&format!("checkpoint: wake park write FAILED for {id} — stub turn will wake with the default directive")); + env.log(&format!("wake-arm: wake park write FAILED for {id} — stub turn will wake with the default directive")); } env.spt(&["api", "--adapter", ADAPTER, "state", "idle", id, "--session-id", sid], None, &[]); - let payload = checkpoint_payload(&wake); + let payload = wake_arm_payload(&wake); env.spt( &["send", "--from", id, id, "--json-payload", &payload, "--force-native"], - Some("checkpoint requested"), + Some("wake requested"), &[], ); } @@ -838,8 +907,10 @@ fn handle_session_start(env: &mut dyn HookEnv, v: &Value) { } _ => { // seed (harness-hosted): adapter-agnostic (NO --adapter) — resolved at bind time from the - // seed's parent pid via host_binaries. The host pid is passed by dispatch (--host-pid). - let pid = env.host_pid().unwrap_or_default(); + // seed's parent pid via host_binaries. Anchor = the process-tree self-resolution when it + // produced one (win32 — immune to the msys $PPID collapse), else dispatch's --host-pid. + // [impl->REQ-LIVE-ANCHOR-WIN32] + let pid = host_anchor(env.self_resolved_host_pid(), env.host_pid()).unwrap_or_default(); env.spt(&["api", "seed", "--pid", &pid, "--session-id", &sid], None, &[]); } } @@ -859,13 +930,13 @@ fn handle_session_start(env: &mut dyn HookEnv, v: &Value) { } } - // FIRE the armed checkpoint wake AFTER a /clear rebuild. The translation binary withheld the wake + // FIRE the armed wake AFTER a /clear rebuild. The translation binary withheld the wake // (it emitted /clear-only) until CC finished the clear and re-ran THIS SessionStart. On a `clear` - // boundary we mark idle + self-send {"checkpoint_fire":"v1"} through our OWN translation binary + // boundary we mark idle + self-send {"wake_fire":"v1"} through our OWN translation binary // (--force-native — never spool to the active poll); the binary emits the armed wake into the now- // clean session, or no-ops if nothing was armed (this fires on EVERY clear, statelessly). Ordering // is guaranteed: the wake can only land after this hook, which only runs after /clear completes. - // ONLY `clear` — no checkpoint variant leverages /compact. [impl->REQ-HAZARD-CHECKPOINT-CLEAR-RACE] + // ONLY `clear` — no wake variant leverages /compact. [impl->REQ-HAZARD-CHECKPOINT-CLEAR-RACE] if src == "clear" { if let Some(eid) = endpoint_id.as_deref() { env.spt(&["api", "--adapter", ADAPTER, "state", "idle", eid, "--session-id", &sid], None, &[]); @@ -891,8 +962,8 @@ fn handle_session_start(env: &mut dyn HookEnv, v: &Value) { )), } env.spt( - &["send", "--from", eid, eid, "--json-payload", checkpoint_fire_payload(), "--force-native"], - Some("checkpoint fire"), + &["send", "--from", eid, eid, "--json-payload", wake_fire_payload(), "--force-native"], + Some("wake fire"), &[], ); } @@ -907,21 +978,22 @@ fn handle_session_start(env: &mut dyn HookEnv, v: &Value) { // (env_remove) already strips these from the roles that must not inherit them. env.append_env_file(&format!("export OWL_SESSION_ID={sid}")); env.append_env_file(&format!("export SPT_ADAPTER={ADAPTER}")); - // The claude.exe anchor pid ($PPID the dispatch captured = the seed pid). A `/sptc:live` relay is - // launched via the Monitor tool under a `bash.exe` child, whose pid breaks BOTH by-pid adapter - // resolution (ADAPTER_UNRESOLVED) and seed-anchor discovery (NO_SEED); the live skill passes this - // env as `--parent-pid` to pin the real anchor. Env-file vars are inherited by tool/Monitor - // children (field-confirmed), so the skill reads it as $SPT_HOST_PID. GUARD: export only a - // PLAUSIBLE pid (>1) — under a headless `claude -p` in git-bash the msys $PPID is the pseudo-init - // `1` (bogus), and a wrong anchor is worse than none (omitting lets the skill's fallback proc-walk - // engage; hertz 2026-07-09). The real-winpid resolution — which also heals the seed anchor — is a - // tracked follow-up. [impl->REQ-SKILL-LIVE] - match env.host_pid() { - Some(hpid) if is_plausible_pid(&hpid) => env.append_env_file(&format!("export SPT_HOST_PID={hpid}")), - Some(hpid) if !hpid.trim().is_empty() => env.log(&format!( - "claude-spt hook: SPT_HOST_PID not exported — host pid '{hpid}' is not a plausible anchor (msys pseudo-init under headless git-bash?); /sptc:live will fall back to a process-tree walk" + // The claude.exe anchor pid. A `/sptc:live` relay is launched via the Monitor tool under a + // `bash.exe` child, whose pid breaks BOTH by-pid adapter resolution (ADAPTER_UNRESOLVED) and + // seed-anchor discovery (NO_SEED); the live skill passes this env as `--parent-pid` to pin the + // real anchor. Env-file vars are inherited by tool/Monitor children (field-confirmed), so the + // skill reads it as $SPT_HOST_PID. Resolution (host_anchor): the process-tree self-resolution + // wins when it produced one (win32 toolhelp walk — immune to the msys $PPID collapse, which hit + // a NORMAL windowed session in gaki-n's 2026-07-16 field report, not just headless git-bash), + // else a PLAUSIBLE dispatch --host-pid (>1; unix always lands here), else OMIT loud — a wrong + // anchor is worse than none (the skill's fallback proc-walk engages only when the var is unset; + // hertz 2026-07-09). [impl->REQ-SKILL-LIVE] [impl->REQ-LIVE-ANCHOR-WIN32] + match host_anchor(env.self_resolved_host_pid(), env.host_pid()) { + Some(hpid) => env.append_env_file(&format!("export SPT_HOST_PID={hpid}")), + None => env.log(&format!( + "claude-spt hook: SPT_HOST_PID not exported — no claude ancestor in the process tree and dispatch host pid {:?} is not a plausible anchor (msys pseudo-init?); /sptc:live will fall back to a process-tree walk", + env.host_pid().unwrap_or_default() )), - _ => {} } // Brief (skip subagent sessions). Perched (bind/boundary) → identity brief + durable resume; @@ -1271,19 +1343,29 @@ fn write_commune_file(env: &mut dyn HookEnv, id: &str, body: &str) -> bool { env.write_spill(&dir.join(format!("{id}-commune.md")).to_string_lossy(), body) } +/// The shortform confirm [`scan_and_dispatch`] built: the display text plus whether any peer send +/// FAILED delivery. The split carries the Stop-path asymmetry: success confirms are droppable +/// end-of-turn noise (F-035), failure confirms must always reach the agent. +/// [impl->REQ-TAG-SEND-FAILURE-LOUD] +struct ScanConfirm { + text: String, + failed: bool, +} + /// The tag-driven peer-messaging + commune-output scan (NEXT-WORKLOAD-PLAN Item 2). Tails this /// session's CC TRANSCRIPT from a byte cursor — NOT the async `endpoint digest` (whose per-entry `seq` /// is assigned AFTER the Stop hook fires, so a tag that is the turn's LAST output was missed until the /// next hook, v0.17.0/1). CC writes the completed assistant message to the transcript BEFORE it fires /// Stop, so reading it here delivers an end-of-turn tag immediately, universally (live + spt-hosted), /// with no background poll. Dispatches every `@` peer message and every `>>commune<<` -/// shortcut in the newly-appended output, and RETURNS the shortform confirm string (or None) for the -/// caller to PLACE: PreToolUse inlines it as mid-turn additionalContext (it rides its own active -/// window); Stop DROPS it (end-of-turn — a self-send would spool and later surface on an UNRELATED -/// relay-woken turn, the F-035 UX wart). Also advances the cursor by the bytes consumed. +/// shortcut in the newly-appended output, and RETURNS the shortform confirm (or None) for the +/// caller to PLACE: PreToolUse inlines its text as mid-turn additionalContext (it rides its own +/// active window); Stop drops a pure-success confirm (end-of-turn — a self-send would spool and +/// later surface on an UNRELATED relay-woken turn, the F-035 UX wart) but SELF-SENDS one carrying +/// a delivery failure (REQ-TAG-SEND-FAILURE-LOUD). Also advances the cursor by the bytes consumed. /// `transcript_path` is the CC hook payload's common field. No-op (None) when there is no self perch or /// no transcript path. [impl->REQ-TAG-PEER-MESSAGING] [impl->REQ-COMMUNE-OUTPUT-SHORTCUT] -fn scan_and_dispatch(env: &mut dyn HookEnv, id: &str, sid: &str, transcript_path: &str) -> Option { +fn scan_and_dispatch(env: &mut dyn HookEnv, id: &str, sid: &str, transcript_path: &str) -> Option { if id.is_empty() || transcript_path.is_empty() { return None; } @@ -1350,7 +1432,10 @@ fn scan_and_dispatch(env: &mut dyn HookEnv, id: &str, sid: &str, transcript_path if self_dropped { parts.push("self-target dropped".to_string()); } - Some(format!("[tag-send] {}", parts.join(" · "))) + Some(ScanConfirm { + text: format!("[tag-send] {}", parts.join(" · ")), + failed: !unreachable.is_empty(), + }) } else { None }; @@ -1359,8 +1444,8 @@ fn scan_and_dispatch(env: &mut dyn HookEnv, id: &str, sid: &str, transcript_path // checkpoint marker, fire the SAME self-send loopback as the Write-path PostToolUse handler. for body in &plan.communes { write_commune_file(env, id, body); - if has_checkpoint(body) { - arm_checkpoint(env, id, sid, body); + if has_wake_marker(body) { + arm_wake(env, id, sid, body); } } @@ -1403,7 +1488,7 @@ fn handle_pre_tool_use(env: &mut dyn HookEnv, v: &Value) { out = if out.is_empty() { block } else { format!("{block}\n{out}") }; } if let Some(c) = tag_confirm { - let block = format!("\n{c}\n"); + let block = format!("\n{}\n", c.text); out = if out.is_empty() { block } else { format!("{out}\n{block}") }; } if is_agent_spawn_tool(&field(v, "tool_name")) { @@ -1439,10 +1524,27 @@ fn handle_stop(env: &mut dyn HookEnv, v: &Value) { // Backstop leg: a tag/commune emitted as the turn's FINAL output (no subsequent tool call) is not // seen by PreToolUse — catch it here. CC has written the final assistant message to the transcript // before this Stop fires, so the end-of-turn tag is visible; the byte cursor dedups vs the mid-turn - // scan. Peer sends + the commune shortcut still fire; the returned confirm is DROPPED — the turn is - // over, there is no active window to inline it into, and self-sending it would spool and surface on - // a later UNRELATED relay-woken turn (F-035). The agent is done and will not re-send by Bash. - let _end_of_turn_confirm = scan_and_dispatch(env, &id, &sid, &field(v, "transcript_path")); + // scan. Peer sends + the commune shortcut still fire. Confirm placement is ASYMMETRIC + // (REQ-TAG-SEND-FAILURE-LOUD, slammie-n→gaki-n 2026-07-16): a pure-SUCCESS confirm is DROPPED — + // the turn is over, no active window to inline it into, and self-sending it would spool and + // surface on a later UNRELATED relay-woken turn (F-035 noise). A confirm carrying a FAILURE is + // self-sent as a normal (spoolable) message — a late failure notice beats the total silence that + // had slammie-n re-trying blind for hours; if even the self-send refuses (a dead own-perch), + // the failure goes to the hook log LOUDLY, never nowhere. [impl->REQ-TAG-SEND-FAILURE-LOUD] + if let Some(c) = scan_and_dispatch(env, &id, &sid, &field(v, "transcript_path")) { + if c.failed { + let landed = env + .spt_send(&["send", "--from", &id, &id], Some(&c.text)) + .map(|o| send_landed(&o)) + .unwrap_or(false); + if !landed { + env.log(&format!( + "claude-spt hook: end-of-turn tag-send FAILURE could not be surfaced to {id} (self-send refused) — {}", + c.text + )); + } + } + } env.spt(&["api", "--adapter", ADAPTER, "state", "idle", &id, "--session-id", &sid], None, &[]); } @@ -1647,20 +1749,22 @@ fn handle_post_tool_use(env: &mut dyn HookEnv, v: &Value) { return; } let content = nested(v, "tool_input", "content"); - if !has_checkpoint(&content) { + if !has_wake_marker(&content) { return; } let sid = field(v, "session_id"); // Park the directive + mark idle + self-send the ARM loopback (shared with the commune-tag // path). [impl->REQ-HAZARD-CHECKPOINT-CLEAR-RACE] - arm_checkpoint(env, &id, &sid, &content); + arm_wake(env, &id, &sid, &content); } /// The post-clear FIRE signal payload — a fixed marker carried in the json attr. The translation -/// binary, on seeing `{"checkpoint_fire":"v1"}`, emits the armed wake (or no-ops if none is armed). -/// [impl->REQ-HAZARD-CHECKPOINT-CLEAR-RACE] -fn checkpoint_fire_payload() -> &'static str { - "{\"checkpoint_fire\":\"v1\"}" +/// binary, on seeing the fire envelope, emits the armed wake (or no-ops if none is armed). +/// EMIT FLIPPED (v0.24.0, REQ-WAKE-EMIT-FLIP): sends the NEW `wake_fire` key — v0.23.0 shipped +/// receive-both, so every resident translate parses it; legacy `checkpoint_fire` ACCEPTANCE +/// retires next release. [impl->REQ-HAZARD-CHECKPOINT-CLEAR-RACE] [impl->REQ-WAKE-EMIT-FLIP] +fn wake_fire_payload() -> &'static str { + "{\"wake_fire\":\"v1\"}" } /// The boundary-rename signal payload: carries the spawn display name (from $SPT_SESSION_NAME, the @@ -1782,6 +1886,65 @@ fn which(name: &str) -> bool { false } +/// The live win32 process table via a kernel32 toolhelp snapshot — rows feed the pure +/// [`find_host_anchor`] walk. HAND-ROLLED FFI, deliberately: the crate is dependency-light +/// (serde_json only) so the daemon can spawn it bare, and a PowerShell/wmic shell-out would +/// reintroduce the interpreter-on-PATH + startup-latency hazard this binary exists to avoid +/// (wmic is gone on current Win11 anyway). kernel32 is always present and already in the msvc +/// default link set. Best-effort: any API failure returns an empty table (caller degrades to +/// the dispatch `--host-pid` path). [impl->REQ-LIVE-ANCHOR-WIN32] +#[cfg(windows)] +mod winproc { + #[repr(C)] + struct ProcessEntry32W { + dw_size: u32, + cnt_usage: u32, + th32_process_id: u32, + th32_default_heap_id: usize, + th32_module_id: u32, + cnt_threads: u32, + th32_parent_process_id: u32, + pc_pri_class_base: i32, + dw_flags: u32, + sz_exe_file: [u16; 260], + } + + const TH32CS_SNAPPROCESS: u32 = 0x0000_0002; + const INVALID_HANDLE_VALUE: isize = -1; + + #[link(name = "kernel32")] + extern "system" { + fn CreateToolhelp32Snapshot(dw_flags: u32, th32_process_id: u32) -> isize; + fn Process32FirstW(h_snapshot: isize, lppe: *mut ProcessEntry32W) -> i32; + fn Process32NextW(h_snapshot: isize, lppe: *mut ProcessEntry32W) -> i32; + fn CloseHandle(h_object: isize) -> i32; + } + + /// Snapshot the process table as `(pid, parent pid, exe basename — lowercase, `.exe` + /// stripped)` rows. Empty on any API failure, never an error. + pub fn process_table() -> Vec<(u32, u32, String)> { + let mut rows = Vec::new(); + unsafe { + let snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if snap == INVALID_HANDLE_VALUE { + return rows; + } + let mut entry: ProcessEntry32W = std::mem::zeroed(); + entry.dw_size = std::mem::size_of::() as u32; + let mut ok = Process32FirstW(snap, &mut entry); + while ok != 0 { + let len = entry.sz_exe_file.iter().position(|&c| c == 0).unwrap_or(260); + let name = String::from_utf16_lossy(&entry.sz_exe_file[..len]).to_ascii_lowercase(); + let name = name.strip_suffix(".exe").unwrap_or(&name).to_string(); + rows.push((entry.th32_process_id, entry.th32_parent_process_id, name)); + ok = Process32NextW(snap, &mut entry); + } + CloseHandle(snap); + } + rows + } +} + impl HookEnv for SysEnv { fn spt(&mut self, args: &[&str], stdin: Option<&str>, extra_env: &[(&str, &str)]) -> Option { use std::io::Write; @@ -1987,6 +2150,17 @@ impl HookEnv for SysEnv { self.host_pid.clone() } + #[cfg(windows)] + fn self_resolved_host_pid(&self) -> Option { + find_host_anchor(&winproc::process_table(), std::process::id(), HOST_BINARIES) + .map(|p| p.to_string()) + } + + #[cfg(not(windows))] + fn self_resolved_host_pid(&self) -> Option { + None // unix dispatch's $PPID is real — no walk needed. [impl->REQ-LIVE-ANCHOR-WIN32] + } + fn home(&self) -> String { std::env::var("HOME") .or_else(|_| std::env::var("USERPROFILE")) @@ -2260,17 +2434,24 @@ mod tests { ); } - // [unit->REQ-DIST-CHECKPOINT-COMMUNE] + // [unit->REQ-DIST-CHECKPOINT-COMMUNE] [unit->REQ-WAKE-RENAME-STAGED] #[test] - fn checkpoint_detect_and_wake() { - assert!(has_checkpoint("delta ... !!checkpoint!!")); - assert!(has_checkpoint("!!checkpoint!! wake !!checkpoint!!")); - assert!(!has_checkpoint("an ordinary commune delta")); - assert!(!has_checkpoint("")); - assert_eq!(checkpoint_wake("work ... !!checkpoint!!"), ""); // single → default - assert_eq!(checkpoint_wake("body !!checkpoint!! Resume T2c now !!checkpoint!! more"), "Resume T2c now"); - assert_eq!(checkpoint_wake("delta\n!!checkpoint!! wire the hook !!checkpoint!!\nend"), "wire the hook"); - assert_eq!(checkpoint_wake("no markers here"), ""); + fn wake_marker_detect_and_directive() { + // New-generation marker. + assert!(has_wake_marker("delta ... !!wake!!")); + assert!(has_wake_marker("!!wake!! resume !!wake!!")); + // Legacy marker still accepted (one-release transition). + assert!(has_wake_marker("delta ... !!checkpoint!!")); + assert!(has_wake_marker("!!checkpoint!! wake !!checkpoint!!")); + assert!(!has_wake_marker("an ordinary commune delta")); + assert!(!has_wake_marker("")); + assert_eq!(wake_directive("work ... !!wake!!"), ""); // single → default + assert_eq!(wake_directive("body !!wake!! Resume T2c now !!wake!! more"), "Resume T2c now"); + assert_eq!(wake_directive("delta\n!!wake!! wire the hook !!wake!!\nend"), "wire the hook"); + assert_eq!(wake_directive("body !!checkpoint!! Resume T2c now !!checkpoint!! more"), "Resume T2c now"); + assert_eq!(wake_directive("no markers here"), ""); + // Mixed generations never pair: two lone markers → default directive. + assert_eq!(wake_directive("a !!wake!! text !!checkpoint!! b"), ""); } // [unit->REQ-DIST-CHECKPOINT-COMMUNE] @@ -2284,12 +2465,13 @@ mod tests { assert!(!is_commune_write("Write", "/home/x/.claude/perri-commune.md", "")); } - // [unit->REQ-DIST-CHECKPOINT-COMMUNE] + // [unit->REQ-DIST-CHECKPOINT-COMMUNE] [unit->REQ-WAKE-EMIT-FLIP] — the wire is the NEW + // wake_arm keys (v0.24.0 flip; v0.23.0 shipped receive-both so every resident parses them). #[test] - fn checkpoint_payload_shape() { - assert_eq!(checkpoint_payload(""), "{\"checkpoint\":\"v1\"}"); - assert_eq!(checkpoint_payload("Resume now"), "{\"checkpoint\":\"v1\",\"wake\":\"Resume now\"}"); - assert_eq!(checkpoint_payload("a\"b"), "{\"checkpoint\":\"v1\",\"wake\":\"a\\\"b\"}"); + fn wake_arm_payload_shape() { + assert_eq!(wake_arm_payload(""), "{\"wake_arm\":\"v1\"}"); + assert_eq!(wake_arm_payload("Resume now"), "{\"wake_arm\":\"v1\",\"directive\":\"Resume now\"}"); + assert_eq!(wake_arm_payload("a\"b"), "{\"wake_arm\":\"v1\",\"directive\":\"a\\\"b\"}"); } // [unit->REQ-DIST-RESUME-CONTEXT] @@ -2335,6 +2517,9 @@ mod tests { /// per-channel vecs cannot express. ops: Vec, host_pid: Option, + /// The synthetic process-tree self-resolution result (None = walk found no claude + /// ancestor — the unix default and the walk-failure case). + self_resolved_pid: Option, cap: usize, } @@ -2359,6 +2544,7 @@ mod tests { park_commits: Vec::new(), ops: Vec::new(), host_pid: Some("4242".into()), + self_resolved_pid: None, cap: 9000, } } @@ -2385,6 +2571,11 @@ mod tests { self.host_pid = Some(pid.into()); self } + /// Provide a process-tree self-resolution result (the win32 toolhelp walk's answer). + fn with_self_resolved_pid(mut self, pid: &str) -> Self { + self.self_resolved_pid = Some(pid.into()); + self + } /// Preload an adapter-state file (e.g. the prior-sid rotation proof). fn with_state(mut self, rel: &str, content: &str) -> Self { self.state.insert(rel.into(), content.into()); @@ -2482,6 +2673,9 @@ mod tests { fn host_pid(&self) -> Option { self.host_pid.clone() } + fn self_resolved_host_pid(&self) -> Option { + self.self_resolved_pid.clone() + } fn home(&self) -> String { "/home/x".to_string() } @@ -3012,6 +3206,72 @@ mod tests { assert!(env.logs.iter().any(|l| l.contains("SPT_HOST_PID not exported")), "loud skip logged"); } + // [unit->REQ-LIVE-ANCHOR-WIN32] — the pure toolhelp walk: first EXACT host-basename ancestor + // wins; our own `claude-spt` basename never matches; loops and truncated chains return None. + #[test] + fn find_host_anchor_walks_to_the_claude_ancestor() { + // gaki-n's shape: claude-spt(90) ← sh(80) ← bash(70) ← claude(21772) ← terminal(5) + let table = vec![ + (90u32, 80u32, "claude-spt".to_string()), + (80, 70, "sh".to_string()), + (70, 21772, "bash".to_string()), + (21772, 5, "claude".to_string()), + (5, 4, "windowsterminal".to_string()), + ]; + assert_eq!(find_host_anchor(&table, 90, HOST_BINARIES), Some(21772)); + // Exact match only: `claude-spt` (the walk's own start row) must never satisfy the probe. + assert_eq!(find_host_anchor(&table, 90, &["claud"]), None, "no prefix/substring match"); + // No claude ancestor (a node/ccs host) → None, caller degrades to the dispatch pid. + let no_claude = vec![(90u32, 80u32, "claude-spt".to_string()), (80, 1, "sh".to_string())]; + assert_eq!(find_host_anchor(&no_claude, 90, HOST_BINARIES), None); + // Pid-reuse loop in a stale snapshot must terminate, not spin the hook. + let looped = vec![(90u32, 80u32, "claude-spt".to_string()), (80, 90, "sh".to_string())]; + assert_eq!(find_host_anchor(&looped, 90, HOST_BINARIES), None, "cycle guard"); + // Chain leaving the table (parent not snapshotted) → None. + assert_eq!(find_host_anchor(&table, 7, HOST_BINARIES), None, "unknown start pid"); + } + + // [unit->REQ-LIVE-ANCHOR-WIN32] — the REAL toolhelp snapshot (win32 only): a wrong + // PROCESSENTRY32W layout returns garbage SILENTLY, so assert the live table contains our own + // pid with a sane basename and a resolvable parent chain. + #[cfg(windows)] + #[test] + fn winproc_snapshot_contains_self_with_sane_fields() { + let table = winproc::process_table(); + assert!(!table.is_empty(), "snapshot must not be empty"); + let me = std::process::id(); + let row = table.iter().find(|(pid, _, _)| *pid == me).expect("own pid present"); + assert!(!row.2.is_empty() && !row.2.ends_with(".exe"), "basename lowercased, .exe stripped: {:?}", row.2); + assert!(row.1 > 0, "parent pid populated"); + } + + // [unit->REQ-LIVE-ANCHOR-WIN32] — resolution order: self-resolution wins, plausible dispatch + // pid is the fallback, both-bogus is None (the loud-omit path). + #[test] + fn host_anchor_resolution_order() { + assert_eq!(host_anchor(Some("21772".into()), Some("4242".into())), Some("21772".into())); + assert_eq!(host_anchor(None, Some("4242".into())), Some("4242".into())); + assert_eq!(host_anchor(Some("1".into()), Some("4242".into())), Some("4242".into())); + assert_eq!(host_anchor(None, Some("1".into())), None); + assert_eq!(host_anchor(None, None), None); + } + + // [unit->REQ-LIVE-ANCHOR-WIN32] — the gaki-n scenario end-to-end: msys collapsed dispatch's + // $PPID to `1` on a NORMAL windowed win32 session; the toolhelp self-resolution must carry + // BOTH consumers (the seed --pid and the SPT_HOST_PID export). + #[test] + fn session_start_self_resolved_pid_heals_seed_and_export() { + let mut env = Recorder::new(|args| match args { + ["subnet", "status"] => Some("no subnets".into()), + _ => Some(String::new()), + }) + .with_host_pid("1") + .with_self_resolved_pid("21772"); + handle_session_start(&mut env, &json!({"session_id":"s9","source":"startup"})); + assert!(env.call_lines().iter().any(|l| l == "api seed --pid 21772 --session-id s9"), "seed healed"); + assert!(env.env_lines.iter().any(|l| l == "export SPT_HOST_PID=21772"), "export healed"); + } + #[test] fn session_start_bind_path_binds_and_briefs() { let mut env = Recorder::new(|args| match args { @@ -3489,7 +3749,7 @@ mod tests { let lines = env.call_lines(); assert!(lines.iter().any(|l| l == "api --adapter claude-spt state idle perri --session-id s1"), "idle first"); assert!( - lines.iter().any(|l| l == "send --from perri perri --json-payload {\"checkpoint\":\"v1\",\"wake\":\"go now\"} --force-native"), + lines.iter().any(|l| l == "send --from perri perri --json-payload {\"wake_arm\":\"v1\",\"directive\":\"go now\"} --force-native"), "self-send carries the custom wake AND --force-native; got {:?}", lines ); @@ -3681,11 +3941,12 @@ mod tests { assert!(!env.out().contains(""), "no note without a perch"); } - // [unit->REQ-HAZARD-CHECKPOINT-CLEAR-RACE] + // [unit->REQ-HAZARD-CHECKPOINT-CLEAR-RACE] [unit->REQ-WAKE-EMIT-FLIP] #[test] - fn clear_boundary_fires_checkpoint_fire_force_native() { + fn clear_boundary_fires_wake_fire_force_native() { // A `clear` SessionStart on an spt-hosted endpoint marks idle + self-sends the FIRE signal // through the translation binary (--force-native) so the armed wake lands post-clear. + // The wire is the NEW wake_fire key (v0.24.0 flip). let mut env = Recorder::new(|_| Some(String::new())).with_env("SPT_ENDPOINT_ID", "ball-b"); handle_session_start(&mut env, &json!({"session_id":"s-new","source":"clear"})); let lines = env.call_lines(); @@ -3695,8 +3956,8 @@ mod tests { lines ); assert!( - lines.iter().any(|l| l == "send --from ball-b ball-b --json-payload {\"checkpoint_fire\":\"v1\"} --force-native"), - "fires checkpoint_fire via --force-native; got {:?}", + lines.iter().any(|l| l == "send --from ball-b ball-b --json-payload {\"wake_fire\":\"v1\"} --force-native"), + "fires wake_fire via --force-native; got {:?}", lines ); } @@ -3716,7 +3977,7 @@ mod tests { let rename = lines.iter().position(|l| { l == "send --from ball-b ball-b --json-payload {\"rename\":\"v1\",\"name\":\"ball-b @ NODE (proj/)\"} --force-native" }); - let fire = lines.iter().position(|l| l.contains("checkpoint_fire")); + let fire = lines.iter().position(|l| l.contains("wake_fire")); assert!(rename.is_some(), "rename send (verbatim name, --force-native) present; got {lines:?}"); assert!(fire.is_some(), "fire send still present; got {lines:?}"); assert!(rename.unwrap() < fire.unwrap(), "rename must precede fire; got {lines:?}"); @@ -3731,7 +3992,7 @@ mod tests { handle_session_start(&mut env, &json!({"session_id":"s-new","source":"clear"})); let lines = env.call_lines(); assert!(!lines.iter().any(|l| l.contains("\"rename\"")), "no rename send without a name: {lines:?}"); - assert!(lines.iter().any(|l| l.contains("checkpoint_fire")), "fire unaffected: {lines:?}"); + assert!(lines.iter().any(|l| l.contains("wake_fire")), "fire unaffected: {lines:?}"); assert!( env.logs.iter().any(|l| l.contains("RENAME_SKIP:no-name")), "loud skip breadcrumb required; got {:?}", @@ -3768,9 +4029,9 @@ mod tests { } #[test] - fn non_clear_starts_do_not_fire_checkpoint() { + fn non_clear_starts_do_not_fire_wake() { // Only `clear` fires. A compact boundary, a bind, and a plain startup must NOT self-send it - // (no checkpoint variant leverages /compact; bind/startup never armed a wake). + // (no wake variant leverages /compact; bind/startup never armed a wake). for (src, eid) in [("compact", Some("ball-b")), ("startup", Some("ball-b")), ("startup", None)] { let mut env = Recorder::new(|_| Some(String::new())); if let Some(e) = eid { @@ -3778,7 +4039,7 @@ mod tests { } handle_session_start(&mut env, &json!({"session_id":"s","source":src})); assert!( - !env.call_lines().iter().any(|l| l.contains("checkpoint_fire")), + !env.call_lines().iter().any(|l| l.contains("wake_fire")), "source={src} eid={eid:?} must NOT fire: {:?}", env.call_lines() ); @@ -4165,7 +4426,8 @@ mod tests { // The confirm is RETURNED for the caller to inline — never self-sent (no --active-only round-trip). assert!(!lines.iter().any(|l| l.contains("--active-only")), "no self-send confirm: {lines:?}"); let c = confirm.expect("confirm returned"); - assert!(c.contains("delivered → doyle, carol"), "confirm body: {c:?}"); + assert!(c.text.contains("delivered → doyle, carol"), "confirm body: {:?}", c.text); + assert!(!c.failed, "all delivered → not a failure confirm"); assert!( env.state_writes.iter().any(|(k, v)| k == "state/digest/perri.pos" && *v == end), "cursor advanced to EOF ({end}): {:?}", @@ -4212,8 +4474,8 @@ mod tests { assert!(env.call_lines().iter().any(|l| l == "send --from perri lia")); // ...and the RETURNED confirm body reports lia as DELIVERED, never NO PERCH. let c = confirm.expect("confirm returned with a body"); - assert!(c.contains("delivered → lia"), "confirm reports delivered: {c:?}"); - assert!(!c.contains("NO PERCH"), "no false NO PERCH: {c:?}"); + assert!(c.text.contains("delivered → lia"), "confirm reports delivered: {:?}", c.text); + assert!(!c.text.contains("NO PERCH"), "no false NO PERCH: {:?}", c.text); } // [unit->REQ-TAG-PEER-MESSAGING] an unreachable target is surfaced in the RETURNED confirm (never a @@ -4231,7 +4493,8 @@ mod tests { let confirm = scan_and_dispatch(&mut env, "perri", "s", "/t.jsonl"); assert!(!env.call_lines().iter().any(|l| l.contains("--active-only")), "no self-send confirm"); let c = confirm.expect("confirm returned"); - assert!(c.contains("NO PERCH (not delivered) → ghost"), "confirm surfaces unreachable: {c:?}"); + assert!(c.text.contains("NO PERCH (not delivered) → ghost"), "confirm surfaces unreachable: {:?}", c.text); + assert!(c.failed, "an unreachable target marks the confirm FAILED (the Stop path self-sends it)"); assert!(env.state_writes.iter().any(|(k, v)| k == "state/digest/perri.pos" && *v == end)); } @@ -4248,7 +4511,8 @@ mod tests { assert!(!lines.iter().any(|l| l == "send --from perri perri"), "no bare self peer-send: {lines:?}"); assert!(!lines.iter().any(|l| l.contains("--active-only")), "no self-send confirm: {lines:?}"); let c = confirm.expect("confirm returned"); - assert!(c.contains("self-target dropped"), "confirm notes self-drop: {c:?}"); + assert!(c.text.contains("self-target dropped"), "confirm notes self-drop: {:?}", c.text); + assert!(!c.failed, "a self-drop is not a delivery failure"); } // [unit->REQ-TAG-PEER-MESSAGING] PreToolUse INLINES the shortform confirm as mid-turn @@ -4273,8 +4537,10 @@ mod tests { assert!(out.contains("delivered → doyle"), "confirm body inlined: {out}"); } - // [unit->REQ-TAG-PEER-MESSAGING] Stop (end-of-turn) still delivers the tag to peers and advances the - // cursor, but DROPS the confirm — no self-send, nothing inlined (the turn is over; F-035 fix). + // [unit->REQ-TAG-PEER-MESSAGING] [unit->REQ-TAG-SEND-FAILURE-LOUD] Stop (end-of-turn) still + // delivers the tag to peers and advances the cursor, but DROPS a pure-SUCCESS confirm — no + // self-send, nothing inlined (the turn is over; F-035 fix). The failure asymmetry is the two + // tests below. #[test] fn stop_drops_the_end_of_turn_tag_confirm() { let transcript = asst_line("@"); @@ -4290,6 +4556,7 @@ mod tests { let lines = env.call_lines(); assert!(lines.iter().any(|l| l == "send --from perri doyle"), "peer send still fires: {lines:?}"); assert!(!lines.iter().any(|l| l.contains("--active-only")), "no self-send confirm: {lines:?}"); + assert!(!lines.iter().any(|l| l == "send --from perri perri"), "success confirm never self-sent: {lines:?}"); assert!(!env.out().contains(""), "confirm NOT inlined on Stop: {}", env.out()); assert!(lines.iter().any(|l| l.contains("state idle")), "idle marked: {lines:?}"); assert!( @@ -4299,6 +4566,54 @@ mod tests { ); } + // [unit->REQ-TAG-SEND-FAILURE-LOUD] the slammie-n→gaki-n shape (2026-07-16): an end-of-turn + // shortform send that FAILS delivery must reach the agent — the Stop path self-sends the + // failure confirm as a normal (spoolable) message instead of dropping it. + #[test] + fn stop_failure_confirm_is_self_sent() { + let transcript = asst_line("@"); + let mut env = Recorder::new(|args| match args { + ["whoami", "--json"] => Some(r#"{"self":{"id":"slammie-n","status":"live_agent","ready":true,"alive":true}}"#.into()), + _ => Some(String::new()), + }) + .with_send_responder(|args| match args { + ["send", "--from", "slammie-n", "gaki-n"] => Some("NO_PERCH:gaki-n is not listening".into()), + ["send", "--from", "slammie-n", "slammie-n"] => Some("QUEUED:slammie-n".into()), + _ => Some(String::new()), + }) + .with_file("/t.jsonl", &transcript) + .with_state("state/digest/slammie-n.pos", "0"); + handle_stop(&mut env, &json!({"session_id":"s1","transcript_path":"/t.jsonl"})); + let lines = env.call_lines(); + assert!(lines.iter().any(|l| l == "send --from slammie-n gaki-n"), "peer send attempted: {lines:?}"); + assert!( + env.stdin_for("send --from slammie-n slammie-n") + .is_some_and(|b| b.contains("NO PERCH (not delivered) → gaki-n")), + "failure confirm self-sent with the NO PERCH body: {lines:?}" + ); + assert!(env.logs.is_empty(), "self-send landed (QUEUED) → no loud fallback needed"); + } + + // [unit->REQ-TAG-SEND-FAILURE-LOUD] when even the failure-confirm self-send refuses (a dead + // own-perch), the failure lands in the hook log LOUDLY — never nowhere. + #[test] + fn stop_failure_selfsend_refusal_logs_loud() { + let transcript = asst_line("@"); + let mut env = Recorder::new(|args| match args { + ["whoami", "--json"] => Some(r#"{"self":{"id":"slammie-n","status":"live_agent","ready":true,"alive":true}}"#.into()), + _ => Some(String::new()), + }) + .with_send_responder(|_| Some("NO_PERCH:not listening".into())) // every send refuses + .with_file("/t.jsonl", &transcript) + .with_state("state/digest/slammie-n.pos", "0"); + handle_stop(&mut env, &json!({"session_id":"s1","transcript_path":"/t.jsonl"})); + assert!( + env.logs.iter().any(|l| l.contains("tag-send FAILURE") && l.contains("gaki-n")), + "loud hook-log fallback names the failure; got {:?}", + env.logs + ); + } + // [unit->REQ-COMMUNE-OUTPUT-SHORTCUT] `>>commune<<` writes the commune file (daemon ingests) and, // when the body carries !!checkpoint!!, fires the same idle + checkpoint self-send as the Write path. // The commune output is NOT tag-scanned (meta-recursion guard): no peer send. @@ -4317,8 +4632,8 @@ mod tests { let lines = env.call_lines(); assert!(lines.iter().any(|l| l.starts_with("api --adapter claude-spt state idle perri")), "{lines:?}"); assert!( - lines.iter().any(|l| l.contains("\"checkpoint\":\"v1\"") && l.contains("--force-native")), - "checkpoint self-send: {lines:?}" + lines.iter().any(|l| l.contains("\"wake_arm\":\"v1\"") && l.contains("--force-native")), + "wake-arm self-send: {lines:?}" ); assert!( !lines.iter().any(|l| l == "send --from perri perri --active-only --ephemeral"),