Skip to main content

fno_agents/
claude_ask.rs

1//! Client-side `claude --bg` ask path (ab-cc926b4e).
2//!
3//! `claude` is a self-supervised `claude --bg` shellout, not a PTY-managed
4//! agent: it runs its own background daemon, a rendezvous Unix socket, and a
5//! transcript/state dir under `~/.claude/jobs/<short-id>/`. The fno daemon
6//! cannot PTY-manage it, so the Rust **client** replicates Python's
7//! `providers/claude.py` + `providers/_claude_session_registry.py` +
8//! `dispatch.py` ask path directly, bypassing the daemon RPC.
9//!
10//! **Byte-parity is the contract.** Every observable (stdout reply, exit code,
11//! the BG8 envelope bytes on the wire, events.jsonl fields) must match the
12//! Python implementation. This module is a faithful port; divergences are
13//! bugs. The MCP-channel transport (US6) and the auto-route flip belong to the
14//! follow-up node ab-0429c6e1 (carveout cv-827faf2b) and are intentionally
15//! absent here.
16//!
17//! Scope of this file: Wave 1 primitives (registry-read + socket + pure
18//! helpers). `bg_create` / `wait_for_reply` / `ask_followup` orchestration is
19//! layered on top in later waves within this module.
20
21use std::borrow::Cow;
22use std::io::{BufRead, BufReader, Read, Write};
23use std::os::unix::net::UnixStream;
24use std::path::{Path, PathBuf};
25use std::time::{Duration, Instant};
26
27// ===========================================================================
28// Constants (verbatim from providers/claude.py + _claude_session_registry.py)
29// ===========================================================================
30
31/// `_ARGV_OVERFLOW_THRESHOLD` — messages larger than this (in UTF-8 bytes) are
32/// passed to `claude --bg` via stdin instead of as an argv token.
33pub const ARGV_OVERFLOW_THRESHOLD: usize = 200 * 1024;
34
35/// `_STDOUT_HEAD_LIMIT` — chars of stdout carried in a parse-error diagnostic.
36pub const STDOUT_HEAD_LIMIT: usize = 200;
37
38/// Terminal-or-needs-input states. The poll loop exits when `state.json`
39/// transitions to one of these; the timeline tail picks `text` from rows with
40/// these states (running rows are tool-call narration, deliberately excluded).
41pub const TERMINAL_STATES: [&str; 4] = ["done", "completed", "failed", "needs-input"];
42
43/// 250 ms liveness-probe connect timeout (`_LIVENESS_PROBE_TIMEOUT_SEC`).
44const LIVENESS_PROBE_TIMEOUT: Duration = Duration::from_millis(250);
45
46/// 5 s send-socket timeout (`_SEND_SOCKET_TIMEOUT_SEC`).
47const SEND_SOCKET_TIMEOUT: Duration = Duration::from_secs(5);
48
49/// Backoff between the two `read_state_json` attempts (`_RETRY_BACKOFF_SEC`),
50/// clearing claude's ~1 ms atomic-rename window.
51const RETRY_BACKOFF: Duration = Duration::from_millis(10);
52
53/// Bounded best-effort window for resolving the full session UUID at spawn
54/// (mirrors `providers.claude._SPAWN_UUID_RETRY_*`). The happy path resolves on
55/// the first probe (claude writes `~/.claude/sessions/<pid>.json` before
56/// `claude --bg` returns the short-id); the retry only covers the rare write-lag
57/// window. `resolve_session_uuid_at_spawn` short-circuits when the sessions dir
58/// is absent, so a fresh-HOME test never sleeps here.
59const SPAWN_UUID_RETRY_ATTEMPTS: u32 = 6;
60const SPAWN_UUID_RETRY_BACKOFF: Duration = Duration::from_millis(300);
61
62fn is_terminal_state(state: &str) -> bool {
63    TERMINAL_STATES.contains(&state)
64}
65
66// ===========================================================================
67// Errors (mirror the Python provider exception taxonomy)
68// ===========================================================================
69
70/// Why a session could not be reached (`OrphanReason`).
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum OrphanReason {
73    /// Entry exists but `messagingSocketPath` is null (suspended session).
74    SocketNull,
75    /// No `~/.claude/sessions/*.json` entry with this jobId.
76    NotFound,
77    /// Socket exists but a connect probe failed.
78    LivenessFailed,
79    /// x-2681: the session is live in the daemon roster but the control.sock
80    /// fallback inject did not confirm -- a delivery failure, NOT a dead
81    /// session, so the orchestration layer must NOT stamp it orphaned.
82    RosterLiveInjectFailed,
83    /// The delivery probe missed, but family-1 transcript truth did not confirm
84    /// a terminal session. This is a routing gap, never an orphan stamp.
85    TruthLiveInjectFailed,
86}
87
88impl OrphanReason {
89    /// The exact reason token Python uses in messages/events.
90    pub fn as_str(self) -> &'static str {
91        match self {
92            OrphanReason::SocketNull => "socket-null",
93            OrphanReason::NotFound => "not-found",
94            OrphanReason::LivenessFailed => "liveness-failed",
95            OrphanReason::RosterLiveInjectFailed => "roster-live-inject-failed",
96            OrphanReason::TruthLiveInjectFailed => "truth-live-inject-failed",
97        }
98    }
99}
100
101pub fn family1_truth_state(handle: &str) -> Option<String> {
102    let mut command = std::process::Command::new("fno");
103    command
104        .args(["agents", "truth", handle, "--json"])
105        .env("FNO_AGENTS_RUNTIME", "python");
106    family1_truth_state_with_command(command, Duration::from_secs(5), handle)
107}
108
109/// Diagnostic for a failed family-1 truth probe. truth writes its refusal
110/// JSON ({state,reason}) to stdout on a non-zero exit, so the reason is read
111/// off stdout, falling back to stderr only when stdout is not the expected JSON.
112fn family1_truth_failure_detail(stdout: &[u8], stderr: &str) -> String {
113    let reason = serde_json::from_slice::<serde_json::Value>(stdout)
114        .ok()
115        .and_then(|value| value.get("reason")?.as_str().map(str::to_owned));
116    reason.unwrap_or_else(|| stderr.trim().to_owned())
117}
118
119/// truth's answer for a handle it has no transcript for. Not a malfunction:
120/// family-1 is CLAUDE transcript truth, so an opencode/codex handle can never
121/// resolve, and a reaped claude session no longer does. The volume caller is
122/// `daemon::handle_list`, which probes EVERY registry row on every
123/// `fno agents list` with no gate at all - one dead row there produced a warn
124/// line per sweep and buried the other failures below that DO mean something is
125/// broken. A resolver crash is deliberately NOT this string
126/// (`session_truth.py` reports `resolver-error`) so it survives the filter.
127///
128/// The tradeoff this accepts: on the `resume`/`attach` paths in `client_verbs`
129/// the probe is gated on a dead socket rather than a missing session, so a
130/// not-found there can also mean the registry and the transcript store
131/// disagree. That case loses its warn line. It is diagnostics-only - the
132/// verdict is `None` either way - and the gate would have to distinguish
133/// "locate_session missed" from "found but socket dead" to say more.
134const TRUTH_NOT_FOUND: &str = "not-found";
135
136/// Whether a non-zero truth exit is worth a warning. Routine "gone" is not;
137/// anything else is a probe or transcript malfunction the operator needs.
138fn truth_failure_is_routine(detail: &str) -> bool {
139    detail.trim() == TRUTH_NOT_FOUND
140}
141
142fn family1_truth_state_with_command(
143    mut command: std::process::Command,
144    timeout: Duration,
145    handle: &str,
146) -> Option<String> {
147    command
148        .stdout(std::process::Stdio::piped())
149        .stderr(std::process::Stdio::piped());
150    let mut child = match command.spawn() {
151        Ok(child) => child,
152        Err(error) => {
153            eprintln!("WARN: family-1 truth probe for {handle} failed to start: {error}");
154            return None;
155        }
156    };
157    let deadline = Instant::now() + timeout;
158    loop {
159        match child.try_wait() {
160            Ok(Some(_)) => break,
161            Ok(None) if Instant::now() < deadline => {
162                std::thread::sleep(Duration::from_millis(20));
163            }
164            Ok(None) => {
165                let _ = child.kill();
166                let _ = child.wait();
167                eprintln!("WARN: family-1 truth probe for {handle} timed out");
168                return None;
169            }
170            Err(error) => {
171                let _ = child.kill();
172                let _ = child.wait();
173                eprintln!("WARN: family-1 truth probe for {handle} wait failed: {error}");
174                return None;
175            }
176        }
177    }
178    let output = match child.wait_with_output() {
179        Ok(output) => output,
180        Err(error) => {
181            eprintln!("WARN: family-1 truth probe for {handle} output failed: {error}");
182            return None;
183        }
184    };
185    if !output.status.success() {
186        let detail =
187            family1_truth_failure_detail(&output.stdout, &String::from_utf8_lossy(&output.stderr));
188        if !truth_failure_is_routine(&detail) {
189            eprintln!(
190                "WARN: family-1 truth probe for {handle} exited {}: {}",
191                output.status, detail
192            );
193        }
194        return None;
195    }
196    let state = serde_json::from_slice::<serde_json::Value>(&output.stdout)
197        .ok()
198        .and_then(|value| value.get("state")?.as_str().map(str::to_owned));
199    match state.as_deref() {
200        Some("done" | "watching" | "your-move" | "working" | "stalled" | "unknown") => state,
201        _ => {
202            eprintln!("WARN: family-1 truth probe for {handle} returned malformed output");
203            None
204        }
205    }
206}
207
208fn family1_orphan_reason(handle: &str, confirmed: OrphanReason) -> OrphanReason {
209    match family1_truth_state(handle).as_deref() {
210        Some("done" | "stalled") => confirmed,
211        _ => OrphanReason::TruthLiveInjectFailed,
212    }
213}
214
215/// Errors raised by the claude ask path. The orchestration layer maps each to
216/// the Python exit code (1/12/13/15) and event payload.
217#[derive(Debug)]
218pub enum AskError {
219    /// `claude --bg` stdout did not match the short-id contract.
220    Parse { stdout_head: String },
221    /// `claude --bg` exited non-zero, timed out (124), or was missing (127).
222    Subprocess { exit_code: i32, stderr: String },
223    /// Session not reachable (locate/probe failure).
224    Orphan {
225        reason: OrphanReason,
226        short_id: String,
227    },
228    /// Socket connect/write/close failure during send.
229    Socket { message: String },
230    /// No reply within the poll timeout.
231    Timeout { elapsed_sec: f64, short_id: String },
232    /// A non-transient I/O error while reading state.json during polling
233    /// (EACCES/EROFS/EISDIR). Python lets the OSError propagate rather than
234    /// masking it as a 600s timeout; we surface it as a fatal exit-1 error.
235    Io { message: String },
236}
237
238impl std::fmt::Display for AskError {
239    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
240        match self {
241            AskError::Parse { stdout_head } => write!(
242                f,
243                "unable to parse short-id from claude --bg output: first {} chars: {}",
244                stdout_head.chars().count(),
245                py_repr(stdout_head),
246            ),
247            AskError::Subprocess { exit_code, stderr } => {
248                write!(f, "claude --bg exited {}: {}", exit_code, py_repr(stderr))
249            }
250            AskError::Orphan { reason, short_id } => write!(
251                f,
252                "agent short-id {} is not reachable (reason: {})",
253                py_repr(short_id),
254                reason.as_str()
255            ),
256            AskError::Socket { message } => write!(f, "{}", message),
257            AskError::Io { message } => write!(f, "{}", message),
258            AskError::Timeout {
259                elapsed_sec,
260                short_id,
261            } => {
262                write!(f, "timed out waiting for reply after {:.1}s", elapsed_sec)?;
263                if !short_id.is_empty() {
264                    write!(f, " (short_id={})", short_id)?;
265                }
266                Ok(())
267            }
268        }
269    }
270}
271
272impl std::error::Error for AskError {}
273
274// ===========================================================================
275// Python-compatible string encoders (the byte-parity load-bearers)
276// ===========================================================================
277
278/// Mirror CPython `json.dumps` default string encoding (`ensure_ascii=True`):
279/// emit a JSON string literal (surrounding quotes included) where every
280/// non-ASCII scalar is `\uXXXX`-escaped (astral chars as a surrogate pair).
281///
282/// This is why the envelope is built from a fixed template plus this encoder
283/// rather than `serde_json`: serde sorts object keys and emits raw UTF-8, so it
284/// would not match Python byte-for-byte.
285pub fn json_string_ascii(s: &str) -> String {
286    let mut out = String::with_capacity(s.len() + 2);
287    out.push('"');
288    for ch in s.chars() {
289        match ch {
290            '"' => out.push_str("\\\""),
291            '\\' => out.push_str("\\\\"),
292            '\n' => out.push_str("\\n"),
293            '\r' => out.push_str("\\r"),
294            '\t' => out.push_str("\\t"),
295            '\u{08}' => out.push_str("\\b"),
296            '\u{0c}' => out.push_str("\\f"),
297            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
298            c if (c as u32) < 0x7f => out.push(c),
299            c => {
300                let cp = c as u32;
301                if cp <= 0xffff {
302                    out.push_str(&format!("\\u{:04x}", cp));
303                } else {
304                    // Surrogate pair, matching CPython.
305                    let v = cp - 0x10000;
306                    let hi = 0xd800 + (v >> 10);
307                    let lo = 0xdc00 + (v & 0x3ff);
308                    out.push_str(&format!("\\u{:04x}\\u{:04x}", hi, lo));
309                }
310            }
311        }
312    }
313    out.push('"');
314    out
315}
316
317/// Mirror Python `html.escape(s, quote=True)` for XML-attribute safety.
318/// Order matters: `&` first.
319pub fn html_escape_quote(s: &str) -> String {
320    let mut out = String::with_capacity(s.len());
321    for ch in s.chars() {
322        match ch {
323            '&' => out.push_str("&amp;"),
324            '<' => out.push_str("&lt;"),
325            '>' => out.push_str("&gt;"),
326            '"' => out.push_str("&quot;"),
327            '\'' => out.push_str("&#x27;"),
328            c => out.push(c),
329        }
330    }
331    out
332}
333
334/// Approximate CPython `repr()` of a `str` for diagnostic messages: single
335/// quotes (double if the string contains a single quote but no double quote),
336/// with `\n`/`\r`/`\t`/`\\` and non-printable escapes. Used only in error text,
337/// so this targets the common cases the parity tests exercise.
338///
339/// Public so the client's unresolvable-`ask` exit-2 surface (bin/client.rs) can
340/// reproduce Python's `{name!r}` in `select_provider`'s error text byte-for-byte.
341pub fn py_repr(s: &str) -> String {
342    let use_double = s.contains('\'') && !s.contains('"');
343    let quote = if use_double { '"' } else { '\'' };
344    let mut out = String::with_capacity(s.len() + 2);
345    out.push(quote);
346    for ch in s.chars() {
347        match ch {
348            '\\' => out.push_str("\\\\"),
349            '\n' => out.push_str("\\n"),
350            '\r' => out.push_str("\\r"),
351            '\t' => out.push_str("\\t"),
352            c if c == quote => {
353                out.push('\\');
354                out.push(c);
355            }
356            c if (c as u32) < 0x20 || (c as u32) == 0x7f => {
357                out.push_str(&format!("\\x{:02x}", c as u32))
358            }
359            c => out.push(c),
360        }
361    }
362    out.push(quote);
363    out
364}
365
366// ===========================================================================
367// claude home + bg-session paths (separate from fno's registry home)
368// ===========================================================================
369
370/// Resolver for claude's own session state under `$HOME/.claude`.
371/// Mirrors `_claude_session_registry._sessions_dir` / `_jobs_dir_for`.
372/// HOME is read from the environment so tests can pin it.
373#[derive(Debug, Clone)]
374pub struct ClaudeHome {
375    home: PathBuf,
376}
377
378impl ClaudeHome {
379    pub fn from_env() -> Self {
380        let home = std::env::var_os("HOME")
381            .map(PathBuf::from)
382            .unwrap_or_else(|| PathBuf::from("."));
383        Self { home }
384    }
385
386    pub fn at(home: impl Into<PathBuf>) -> Self {
387        Self { home: home.into() }
388    }
389
390    pub fn sessions_dir(&self) -> PathBuf {
391        self.home.join(".claude").join("sessions")
392    }
393
394    pub fn jobs_dir_for(&self, short_id: &str) -> PathBuf {
395        self.home.join(".claude").join("jobs").join(short_id)
396    }
397
398    /// The daemon roster path. Honors `FNO_CLAUDE_DAEMON_DIR` FIRST (a supported
399    /// alt-home / alternate-daemon override, matching `claude_roster::daemon_dir`
400    /// and the deliver path's `load_default`), else `<home>/.claude/daemon`. The
401    /// env-first order keeps the ask-lane roster pre-check reading the SAME roster
402    /// the deliver step resolves, so the fallback never skips in an alt-daemon
403    /// setup; the home-relative fallback keeps it hermetic under a test
404    /// `ClaudeHome`. Byte-parity with Python's `_daemon_dir` (env-first-else-home).
405    pub fn daemon_roster_path(&self) -> PathBuf {
406        if let Some(dir) = std::env::var_os(crate::claude_roster::DAEMON_DIR_ENV) {
407            return PathBuf::from(dir).join("roster.json");
408        }
409        self.home.join(".claude").join("daemon").join("roster.json")
410    }
411}
412
413/// Pointer into the claude session registry for one bg supervisor session
414/// (`SessionLocator`).
415#[derive(Debug, Clone, PartialEq, Eq)]
416pub struct SessionLocator {
417    pub pid: i64,
418    pub short_id: String,
419    pub messaging_socket_path: String,
420    pub jobs_dir: PathBuf,
421    pub session_id: Option<String>,
422    pub cwd: Option<String>,
423}
424
425/// Parsed `state.json` snapshot (`StateSnapshot`).
426#[derive(Debug, Clone, PartialEq, Eq, Default)]
427pub struct StateSnapshot {
428    pub state: String,
429    pub updated_at: Option<String>,
430    pub output_result: Option<String>,
431    pub intent: Option<String>,
432}
433
434// ===========================================================================
435// Pure helpers: parse_short_id, build_argv
436// ===========================================================================
437
438/// Extract the 8-hex short-id from `claude --bg`'s first stdout line.
439/// Mirrors `_SHORT_ID_PATTERN = r"^backgrounded · ([0-9a-f]{8}) · "`.
440/// The `·` is U+00B7 MIDDLE DOT.
441pub fn parse_short_id(stdout: &str) -> Result<String, AskError> {
442    if stdout.is_empty() {
443        return Err(AskError::Parse {
444            stdout_head: String::new(),
445        });
446    }
447    let first_line = stdout.split('\n').next().unwrap_or("");
448    if let Some(id) = match_short_id(first_line) {
449        return Ok(id);
450    }
451    Err(AskError::Parse {
452        stdout_head: head_chars(stdout, STDOUT_HEAD_LIMIT),
453    })
454}
455
456/// `^backgrounded · ([0-9a-f]{8}) · ` matcher without a regex dependency.
457fn match_short_id(line: &str) -> Option<String> {
458    const PREFIX: &str = "backgrounded \u{b7} ";
459    const SEP: &str = " \u{b7} ";
460    // `claude --bg` colorizes the short-id when its stdout is colorized
461    // (real bytes: `backgrounded · \x1b[36m<id>\x1b[39m · <name>`). Strip ANSI
462    // CSI escapes before the byte checks below so the hex field is contiguous;
463    // otherwise the leading `\x1b` fails the hexdigit test and the id is lost.
464    let cleaned = strip_ansi_csi(line);
465    let rest = cleaned.strip_prefix(PREFIX)?;
466    let rb = rest.as_bytes();
467    if rb.len() < 8 {
468        return None;
469    }
470    // Validate the first 8 BYTES are ASCII lowercase hex BEFORE any char-index
471    // slice. `split_at(8)` panics if byte 8 lands inside a multi-byte UTF-8
472    // scalar; once the first 8 bytes are confirmed ASCII, byte 8 is guaranteed
473    // a char boundary (Codex P2: malformed non-ASCII stdout must not crash).
474    if !rb[..8]
475        .iter()
476        .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
477    {
478        return None;
479    }
480    let (hex, after) = rest.split_at(8);
481    if after.starts_with(SEP) {
482        Some(hex.to_string())
483    } else {
484        None
485    }
486}
487
488/// Remove ANSI CSI escape sequences (`ESC '[' params/intermediates final`) from
489/// a line. Conservative: only well-formed CSI sequences are dropped; any other
490/// byte, including a lone `ESC` that does not start a CSI, is preserved. CSI
491/// control bytes are all single-byte ASCII, so iterating by `char` keeps
492/// multi-byte scalars (e.g. the `·` separator) intact. The common case (no
493/// `ESC` at all) borrows the input without allocating.
494fn strip_ansi_csi(s: &str) -> Cow<'_, str> {
495    if !s.contains('\u{1b}') {
496        return Cow::Borrowed(s);
497    }
498    let mut out = String::with_capacity(s.len());
499    let mut chars = s.chars().peekable();
500    while let Some(c) = chars.next() {
501        if c == '\u{1b}' && chars.peek() == Some(&'[') {
502            chars.next(); // consume '['
503                          // Parameter (0x30-0x3F) and intermediate (0x20-0x2F) bytes.
504            while let Some(&p) = chars.peek() {
505                if ('\u{20}'..='\u{3f}').contains(&p) {
506                    chars.next();
507                } else {
508                    break;
509                }
510            }
511            // Final byte (0x40-0x7E) terminates a well-formed CSI sequence.
512            if let Some(&f) = chars.peek() {
513                if ('\u{40}'..='\u{7e}').contains(&f) {
514                    chars.next();
515                }
516            }
517            continue;
518        }
519        out.push(c);
520    }
521    Cow::Owned(out)
522}
523
524/// First `n` chars (not bytes) of `s`, matching Python slice semantics.
525fn head_chars(s: &str, n: usize) -> String {
526    s.chars().take(n).collect()
527}
528
529/// x-b6e2: the Tier-3 harness-native passthrough flags that claude maps but the
530/// other providers largely don't. Bundled so the deep claude dispatch chain
531/// threads ONE param, not four. Each is an opaque value forwarded to claude's
532/// own flag; empty/None = the flag is omitted. codex/agy take only `add_dir`
533/// individually (their sole real cell); `agent`/`tools`/`deny_tools` fail closed
534/// for every non-claude provider at the client.rs guard, so a non-claude builder
535/// never receives them.
536#[derive(Clone, Copy, Default)]
537pub struct HarnessFlags<'a> {
538    pub add_dir: Option<&'a str>,
539    pub agent: Option<&'a str>,
540    pub allowed_tools: Option<&'a str>,
541    pub disallowed_tools: Option<&'a str>,
542}
543
544impl<'a> HarnessFlags<'a> {
545    /// Append the mapped `--flag <value>` tokens (claude's own spellings) to an
546    /// argv, skipping empty/None. Shared by the bg and headless claude builders
547    /// so their token order stays identical (parity).
548    fn push_onto(&self, argv: &mut Vec<String>) {
549        for (flag, value) in [
550            ("--add-dir", self.add_dir),
551            ("--agent", self.agent),
552            ("--allowedTools", self.allowed_tools),
553            ("--disallowedTools", self.disallowed_tools),
554        ] {
555            if let Some(v) = value.filter(|v| !v.is_empty()) {
556                argv.push(flag.to_string());
557                argv.push(v.to_string());
558            }
559        }
560    }
561}
562
563/// Render the argv for `claude --bg` (`_build_argv`). When `use_stdin`, the
564/// message is omitted from argv and fed via stdin instead. A non-empty `model`
565/// (x-571f per-node pin) appends `--model <m>` between `--name` and the
566/// message, scoping the pin to this session; empty/None means today's argv
567/// byte-for-byte (parity with Python's falsy-`model` check).
568pub fn build_argv(
569    name: &str,
570    message: &str,
571    use_stdin: bool,
572    model: Option<&str>,
573    permission_mode: Option<&str>,
574    effort: Option<&str>,
575    flags: HarnessFlags,
576) -> Vec<String> {
577    let mut argv = vec![
578        "claude".to_string(),
579        "--bg".to_string(),
580        "--name".to_string(),
581        name.to_string(),
582    ];
583    // x-dfa4: exact passthrough to claude's own --permission-mode. The caller
584    // resolves --yolo -> bypassPermissions before this point; empty/None = the
585    // claude default (unchanged argv).
586    if let Some(m) = permission_mode.filter(|m| !m.is_empty()) {
587        argv.push("--permission-mode".to_string());
588        argv.push(m.to_string());
589    }
590    if let Some(value) = effort.filter(|v| !v.is_empty()) {
591        argv.push("--effort".to_string());
592        argv.push(value.to_string());
593    }
594    // x-b6e2: Tier-3 passthrough (--add-dir/--agent/--allowedTools/
595    // --disallowedTools). Kept identical to the Python _build_argv (parity).
596    flags.push_onto(&mut argv);
597    if let Some(m) = model.filter(|m| !m.is_empty()) {
598        argv.push("--model".to_string());
599        argv.push(m.to_string());
600    }
601    if !use_stdin {
602        argv.push(message.to_string());
603    }
604    argv
605}
606
607/// True iff the message must be sent via stdin (exceeds the argv threshold).
608pub fn use_stdin_for(message: &str) -> bool {
609    message.len() > ARGV_OVERFLOW_THRESHOLD
610}
611
612// ===========================================================================
613// BG8 envelope + socket primitives
614// ===========================================================================
615
616/// Render the BG8 envelope bytes (`_build_envelope`), byte-for-byte with
617/// Python's `json.dumps(separators=(",",":"))` over the fixed dict shape, plus
618/// the trailing newline. Key order is fixed: type, message{role, content},
619/// priority. `from_name` is html-attribute-escaped; `message` is inserted raw
620/// into the wrapper then JSON-string-encoded.
621/// Wrap `message` in the cross-session-message container that marks it as a peer
622/// turn (`build_cross_session_container`). `from_name` is html-attribute-escaped;
623/// `message` is inserted raw. Shared by the BG8 envelope ([`build_envelope`]) and
624/// the x-2681 control.sock ask fallback, so both frame a peer turn identically.
625pub fn build_cross_session_container(message: &str, from_name: &str) -> String {
626    format!(
627        "<cross-session-message from-name=\"{}\">\n{}\n</cross-session-message>",
628        html_escape_quote(from_name),
629        message
630    )
631}
632
633pub fn build_envelope(message: &str, from_name: &str) -> Vec<u8> {
634    let wrapped = build_cross_session_container(message, from_name);
635    let content = json_string_ascii(&wrapped);
636    let line = format!(
637        "{{\"type\":\"user\",\"message\":{{\"role\":\"user\",\"content\":{}}},\"priority\":\"next\"}}\n",
638        content
639    );
640    line.into_bytes()
641}
642
643/// Connect to an AF_UNIX SOCK_STREAM path with a bounded timeout. std's
644/// `UnixStream::connect` has no connect-timeout knob, so a wedged listener (or
645/// a full accept backlog) can block it indefinitely — Python sets the socket
646/// timeout BEFORE connect (Codex P2). This does a nonblocking connect + `poll`
647/// for writability, then restores blocking mode, so connect can never outlast
648/// `timeout`.
649fn connect_unix_timeout(path: &str, timeout: Duration) -> std::io::Result<UnixStream> {
650    use std::os::unix::io::FromRawFd;
651    let c_path = std::ffi::CString::new(path).map_err(|_| {
652        std::io::Error::new(std::io::ErrorKind::InvalidInput, "socket path contains NUL")
653    })?;
654    // SAFETY: a standard libc socket/connect/poll sequence. The fd is closed on
655    // every error path and wrapped into a UnixStream (which owns + closes it) on
656    // success.
657    unsafe {
658        let fd = libc::socket(libc::AF_UNIX, libc::SOCK_STREAM, 0);
659        if fd < 0 {
660            return Err(std::io::Error::last_os_error());
661        }
662        let mut addr: libc::sockaddr_un = std::mem::zeroed();
663        addr.sun_family = libc::AF_UNIX as libc::sa_family_t;
664        let bytes = c_path.as_bytes();
665        if bytes.len() >= std::mem::size_of_val(&addr.sun_path) {
666            libc::close(fd);
667            return Err(std::io::Error::new(
668                std::io::ErrorKind::InvalidInput,
669                "socket path too long",
670            ));
671        }
672        std::ptr::copy_nonoverlapping(
673            bytes.as_ptr() as *const libc::c_char,
674            addr.sun_path.as_mut_ptr(),
675            bytes.len(),
676        );
677        let flags = libc::fcntl(fd, libc::F_GETFL, 0);
678        if flags < 0 || libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) < 0 {
679            let e = std::io::Error::last_os_error();
680            libc::close(fd);
681            return Err(e);
682        }
683        let addr_len = std::mem::size_of::<libc::sockaddr_un>() as libc::socklen_t;
684        let rc = libc::connect(fd, &addr as *const _ as *const libc::sockaddr, addr_len);
685        if rc != 0 {
686            let err = std::io::Error::last_os_error();
687            if err.raw_os_error() != Some(libc::EINPROGRESS) {
688                libc::close(fd);
689                return Err(err);
690            }
691            let mut pfd = libc::pollfd {
692                fd,
693                events: libc::POLLOUT,
694                revents: 0,
695            };
696            let ms = timeout.as_millis().min(i32::MAX as u128) as libc::c_int;
697            let pr = libc::poll(&mut pfd, 1, ms);
698            if pr < 0 {
699                let e = std::io::Error::last_os_error();
700                libc::close(fd);
701                return Err(e);
702            }
703            if pr == 0 {
704                libc::close(fd);
705                return Err(std::io::Error::new(
706                    std::io::ErrorKind::TimedOut,
707                    "connect timed out",
708                ));
709            }
710            let mut soerr: libc::c_int = 0;
711            let mut len = std::mem::size_of::<libc::c_int>() as libc::socklen_t;
712            if libc::getsockopt(
713                fd,
714                libc::SOL_SOCKET,
715                libc::SO_ERROR,
716                &mut soerr as *mut _ as *mut libc::c_void,
717                &mut len,
718            ) < 0
719            {
720                let e = std::io::Error::last_os_error();
721                libc::close(fd);
722                return Err(e);
723            }
724            if soerr != 0 {
725                libc::close(fd);
726                return Err(std::io::Error::from_raw_os_error(soerr));
727            }
728        }
729        // Restore blocking mode for the subsequent read/write timeouts.
730        if libc::fcntl(fd, libc::F_SETFL, flags) < 0 {
731            let e = std::io::Error::last_os_error();
732            libc::close(fd);
733            return Err(e);
734        }
735        Ok(UnixStream::from_raw_fd(fd))
736    }
737}
738
739/// Single-shot send of the BG8 envelope over the messaging socket
740/// (`send_to_session`). A close-time error after a successful write is
741/// propagated as a send failure (AF_UNIX: the only reliable "bytes didn't
742/// land" signal).
743pub fn send_to_session(sock_path: &str, content: &str, from_name: &str) -> Result<(), AskError> {
744    let payload = build_envelope(content, from_name);
745    let mut stream =
746        connect_unix_timeout(sock_path, SEND_SOCKET_TIMEOUT).map_err(|e| AskError::Socket {
747            message: e.to_string(),
748        })?;
749    let _ = stream.set_write_timeout(Some(SEND_SOCKET_TIMEOUT));
750    let _ = stream.set_read_timeout(Some(SEND_SOCKET_TIMEOUT));
751    let write_res = stream.write_all(&payload);
752    // Explicitly flush+shutdown to surface a peer-reject as a close error,
753    // mirroring Python's reliance on close() raising on AF_UNIX.
754    let close_res = stream
755        .flush()
756        .and_then(|_| stream.shutdown(std::net::Shutdown::Both));
757    if let Err(e) = write_res {
758        return Err(AskError::Socket {
759            message: e.to_string(),
760        });
761    }
762    if let Err(e) = close_res {
763        return Err(AskError::Socket {
764            message: format!("close after send failed: {}", e),
765        });
766    }
767    Ok(())
768}
769
770/// Return true iff a 250 ms connect to `sock_path` succeeds (`liveness_probe`).
771/// Connect-then-close; no read/write. Any error (including a connect that
772/// doesn't complete within the 250 ms bound) → false. Uses the timeout-bounded
773/// connect so a wedged listener can't hang the probe (Python sets the timeout
774/// before connect).
775pub fn liveness_probe(sock_path: &str) -> bool {
776    connect_unix_timeout(sock_path, LIVENESS_PROBE_TIMEOUT).is_ok()
777}
778
779// ===========================================================================
780// Registry-read primitives: locate_session, read_state_json, read_timeline_tail
781// ===========================================================================
782
783/// Find the bg session whose `jobId` matches `short_id` (`locate_session`).
784/// Requires `kind == "bg"` and a non-empty `messagingSocketPath`. Two-pass in
785/// spirit (a respawn can leave a dead pid's file with a null socket): we scan
786/// sorted entries and return the first live match; null-socket entries are
787/// skipped. Corrupt JSON files are skipped silently. Returns `None` when no
788/// live match exists or the sessions dir is absent.
789pub fn locate_session(home: &ClaudeHome, short_id: &str) -> Option<SessionLocator> {
790    let sessions = home.sessions_dir();
791    if !sessions.exists() {
792        return None;
793    }
794    let mut entries: Vec<PathBuf> = match std::fs::read_dir(&sessions) {
795        Ok(rd) => rd
796            .filter_map(|e| e.ok().map(|e| e.path()))
797            .filter(|p| p.extension().map(|x| x == "json").unwrap_or(false))
798            .collect(),
799        Err(_) => return None,
800    };
801    entries.sort();
802
803    for entry_path in entries {
804        let raw = match std::fs::read_to_string(&entry_path) {
805            Ok(t) => t,
806            Err(_) => continue,
807        };
808        let v: serde_json::Value = match serde_json::from_str(&raw) {
809            Ok(v) => v,
810            Err(_) => continue,
811        };
812        if !v.is_object() {
813            continue;
814        }
815        if v.get("jobId").and_then(|x| x.as_str()) != Some(short_id) {
816            continue;
817        }
818        if v.get("kind").and_then(|x| x.as_str()) != Some("bg") {
819            continue;
820        }
821        let sock = match v.get("messagingSocketPath").and_then(|x| x.as_str()) {
822            Some(s) if !s.is_empty() => s.to_string(),
823            _ => continue, // null/empty socket: respawn artifact, keep scanning
824        };
825        // pid is the file stem (`<pid>.json`).
826        let pid = match entry_path
827            .file_stem()
828            .and_then(|s| s.to_str())
829            .and_then(|s| s.parse::<i64>().ok())
830        {
831            Some(p) => p,
832            None => continue,
833        };
834        return Some(SessionLocator {
835            pid,
836            short_id: short_id.to_string(),
837            messaging_socket_path: sock,
838            jobs_dir: home.jobs_dir_for(short_id),
839            session_id: v
840                .get("sessionId")
841                .and_then(|x| x.as_str())
842                .map(String::from),
843            cwd: v.get("cwd").and_then(|x| x.as_str()).map(String::from),
844        });
845    }
846    None
847}
848
849/// Resolve the FULL session UUID for a bg session by its 8-hex `jobId`
850/// (`resolve_session_uuid`). The stream-json `--resume` lane keys on the full
851/// `sessionId`; the jobId is only a 32-bit prefix. Unlike `locate_session`,
852/// this does NOT require a live `messagingSocketPath` (an idle bg session is
853/// exactly the resume target): it prefers a supervisor whose socket is live but
854/// falls back to any `kind == "bg"` match carrying a non-empty `sessionId`.
855/// Returns `None` when no such match exists or the sessions dir is absent.
856pub fn resolve_session_uuid(home: &ClaudeHome, short_id: &str) -> Option<String> {
857    let sessions = home.sessions_dir();
858    if !sessions.exists() {
859        return None;
860    }
861    let mut entries: Vec<PathBuf> = match std::fs::read_dir(&sessions) {
862        Ok(rd) => rd
863            .filter_map(|e| e.ok().map(|e| e.path()))
864            .filter(|p| p.extension().map(|x| x == "json").unwrap_or(false))
865            .collect(),
866        Err(_) => return None,
867    };
868    entries.sort();
869
870    let mut fallback: Option<String> = None;
871    for entry_path in entries {
872        let raw = match std::fs::read_to_string(&entry_path) {
873            Ok(t) => t,
874            Err(_) => continue,
875        };
876        let v: serde_json::Value = match serde_json::from_str(&raw) {
877            Ok(v) => v,
878            Err(_) => continue,
879        };
880        if !v.is_object() {
881            continue;
882        }
883        if v.get("jobId").and_then(|x| x.as_str()) != Some(short_id) {
884            continue;
885        }
886        if v.get("kind").and_then(|x| x.as_str()) != Some("bg") {
887            continue;
888        }
889        let sid = match v.get("sessionId").and_then(|x| x.as_str()) {
890            Some(s) if !s.is_empty() => s.to_string(),
891            _ => continue,
892        };
893        match v.get("messagingSocketPath").and_then(|x| x.as_str()) {
894            Some(s) if !s.is_empty() => return Some(sid), // live supervisor wins
895            _ => {
896                if fallback.is_none() {
897                    fallback = Some(sid);
898                }
899            }
900        }
901    }
902    fallback
903}
904
905/// Best-effort full session-UUID resolution at spawn
906/// (`resolve_session_uuid_at_spawn`). Returns the full `sessionId` for
907/// `short_id`, or `None` within the bounded retry window. NEVER blocks the
908/// short-id report past that window: an unresolved UUID is a tolerated miss (the
909/// live `chat` lane then opens a fresh pipe rather than adopting a guessed
910/// UUID). Short-circuits when the sessions dir is absent (claude never wrote
911/// one), so there is no point retrying — and a fresh-HOME test never sleeps.
912pub fn resolve_session_uuid_at_spawn(home: &ClaudeHome, short_id: &str) -> Option<String> {
913    if short_id.is_empty() || !home.sessions_dir().exists() {
914        return None;
915    }
916    for attempt in 0..SPAWN_UUID_RETRY_ATTEMPTS {
917        if let Some(uuid) = resolve_session_uuid(home, short_id) {
918            return Some(uuid);
919        }
920        if attempt + 1 < SPAWN_UUID_RETRY_ATTEMPTS {
921            std::thread::sleep(SPAWN_UUID_RETRY_BACKOFF);
922        }
923    }
924    None
925}
926
927/// Classify why a `locate_session` miss occurred (`_classify_orphan_reason`):
928/// re-walk the sessions dir; if a bg entry with this jobId exists but its
929/// socket is null → `SocketNull`, otherwise `NotFound`.
930pub fn classify_orphan_reason(home: &ClaudeHome, short_id: &str) -> OrphanReason {
931    let sessions = home.sessions_dir();
932    if let Ok(rd) = std::fs::read_dir(&sessions) {
933        for entry in rd.filter_map(|e| e.ok()) {
934            let p = entry.path();
935            if p.extension().map(|x| x != "json").unwrap_or(true) {
936                continue;
937            }
938            let raw = match std::fs::read_to_string(&p) {
939                Ok(t) => t,
940                Err(_) => continue,
941            };
942            let v: serde_json::Value = match serde_json::from_str(&raw) {
943                Ok(v) => v,
944                Err(_) => continue,
945            };
946            if v.get("jobId").and_then(|x| x.as_str()) == Some(short_id)
947                && v.get("kind").and_then(|x| x.as_str()) == Some("bg")
948            {
949                let sock = v.get("messagingSocketPath").and_then(|x| x.as_str());
950                if sock.map(|s| s.is_empty()).unwrap_or(true) {
951                    return OrphanReason::SocketNull;
952                }
953            }
954        }
955    }
956    OrphanReason::NotFound
957}
958
959/// Error returned by `read_state_json`. `NotFound` (absent) and `Parse`
960/// (present-but-unreadable JSON / empty / atomic-rename window) are transient:
961/// the poll loop retries. `Io` is a non-transient filesystem fault
962/// (EACCES/EROFS/EISDIR) that Python lets propagate rather than mask as a
963/// timeout — the poll loop surfaces it as a fatal error.
964#[derive(Debug)]
965pub enum StateReadError {
966    NotFound,
967    Parse,
968    Io(std::io::Error),
969}
970
971/// Parse `<jobs_dir>/state.json` into a `StateSnapshot` (`read_state_json`),
972/// retrying once on a parse error to absorb claude's atomic-rename window. A
973/// non-transient I/O fault (`Io`) is returned immediately, not retried.
974pub fn read_state_json(jobs_dir: &Path) -> Result<StateSnapshot, StateReadError> {
975    let state_path = jobs_dir.join("state.json");
976    match parse_state(&state_path) {
977        Ok(s) => Ok(s),
978        Err(StateReadError::Parse) => {
979            std::thread::sleep(RETRY_BACKOFF);
980            parse_state(&state_path)
981        }
982        Err(e) => Err(e),
983    }
984}
985
986fn parse_state(state_path: &Path) -> Result<StateSnapshot, StateReadError> {
987    let raw_text = match std::fs::read_to_string(state_path) {
988        Ok(t) => t,
989        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(StateReadError::NotFound),
990        // EACCES/EROFS/EISDIR etc. are NOT retryable — polling 600s would mask
991        // the cause as a timeout. Surface them (Python lets the OSError fly).
992        Err(e) => return Err(StateReadError::Io(e)),
993    };
994    if raw_text.trim().is_empty() {
995        return Err(StateReadError::Parse);
996    }
997    let v: serde_json::Value =
998        serde_json::from_str(&raw_text).map_err(|_| StateReadError::Parse)?;
999    let output = v.get("output");
1000    let output_result = output
1001        .and_then(|o| if o.is_object() { o.get("result") } else { None })
1002        .and_then(|r| r.as_str())
1003        .map(String::from);
1004    Ok(StateSnapshot {
1005        state: v
1006            .get("state")
1007            .and_then(|x| x.as_str())
1008            .unwrap_or("")
1009            .to_string(),
1010        updated_at: v
1011            .get("updatedAt")
1012            .and_then(|x| x.as_str())
1013            .map(String::from),
1014        output_result,
1015        intent: v.get("intent").and_then(|x| x.as_str()).map(String::from),
1016    })
1017}
1018
1019/// Read `<jobs_dir>/timeline.jsonl` from `offset` and concatenate `text` fields
1020/// from terminal-or-needs-input rows (`read_timeline_tail`). Missing file,
1021/// read error, or non-UTF-8 tail → empty string. Unparseable lines skipped.
1022pub fn read_timeline_tail(jobs_dir: &Path, offset: u64) -> String {
1023    use std::io::{Seek, SeekFrom};
1024    let timeline = jobs_dir.join("timeline.jsonl");
1025    if !timeline.exists() {
1026        return String::new();
1027    }
1028    let mut file = match std::fs::File::open(&timeline) {
1029        Ok(f) => f,
1030        Err(_) => return String::new(),
1031    };
1032    if file.seek(SeekFrom::Start(offset)).is_err() {
1033        return String::new();
1034    }
1035    let mut tail = Vec::new();
1036    if file.read_to_end(&mut tail).is_err() {
1037        return String::new();
1038    }
1039    let text = match String::from_utf8(tail) {
1040        Ok(t) => t,
1041        Err(_) => return String::new(),
1042    };
1043    let mut chunks = String::new();
1044    for line in text.lines() {
1045        let line = line.trim();
1046        if line.is_empty() {
1047            continue;
1048        }
1049        let row: serde_json::Value = match serde_json::from_str(line) {
1050            Ok(r) => r,
1051            Err(_) => continue,
1052        };
1053        if !row.is_object() {
1054            continue;
1055        }
1056        let state = row.get("state").and_then(|x| x.as_str()).unwrap_or("");
1057        if !is_terminal_state(state) {
1058            continue;
1059        }
1060        if let Some(piece) = row.get("text").and_then(|x| x.as_str()) {
1061            if !piece.is_empty() {
1062                chunks.push_str(piece);
1063            }
1064        }
1065    }
1066    chunks
1067}
1068
1069/// Current byte size of `<jobs_dir>/timeline.jsonl`, or 0 if absent/unreadable.
1070/// Captured as the baseline offset before a send.
1071pub fn timeline_offset(jobs_dir: &Path) -> u64 {
1072    std::fs::metadata(jobs_dir.join("timeline.jsonl"))
1073        .map(|m| m.len())
1074        .unwrap_or(0)
1075}
1076
1077// ===========================================================================
1078// Wave 2: bg_create (subprocess), wait_for_reply (poll), ask_followup
1079// ===========================================================================
1080
1081/// Default poll interval for `wait_for_reply` (`poll_interval=0.5` in Python).
1082pub const DEFAULT_POLL_INTERVAL: Duration = Duration::from_millis(500);
1083
1084/// Result of a successful `claude --bg` create: the parsed short-id plus the
1085/// captured streams (mirrors the parts of Python's `ProviderResult` the
1086/// orchestration layer reads).
1087#[derive(Debug, Clone)]
1088pub struct CreateResult {
1089    pub short_id: String,
1090    pub stdout: String,
1091    pub stderr: String,
1092    pub duration_ms: u128,
1093}
1094
1095/// Outcome of scanning `claude --bg` stdout for its launch-confirmation line.
1096enum ShortIdScan {
1097    /// The `backgrounded · <id> · <name>` line was seen; scanning STOPS here (we
1098    /// never read to EOF). `consumed` is the stdout read up to and including it.
1099    Found { short_id: String, consumed: String },
1100    /// stdout reached EOF (claude exited) with no confirmation line -- a launch
1101    /// failure. `consumed` is the full stdout, for the parse-error diagnostic.
1102    NoId { consumed: String },
1103}
1104
1105/// Read `claude --bg` stdout line by line and return `Found` the instant the
1106/// launch-confirmation line yields a short-id, WITHOUT consuming the rest of the
1107/// stream; return `NoId` only at EOF. Pure over any `BufRead` so the early-return
1108/// contract is unit-testable without spawning `claude` (PR #544 / codex P1).
1109fn scan_stdout_for_short_id<R: BufRead>(mut reader: R) -> ShortIdScan {
1110    let mut consumed = String::new();
1111    let mut line = String::new();
1112    loop {
1113        line.clear();
1114        match reader.read_line(&mut line) {
1115            Ok(0) => return ShortIdScan::NoId { consumed }, // EOF: no confirmation
1116            Ok(_) => {
1117                consumed.push_str(&line);
1118                if let Some(short_id) = match_short_id(line.trim_end()) {
1119                    return ShortIdScan::Found { short_id, consumed };
1120                }
1121            }
1122            // A mid-stream read fault is treated as no-confirmation (the caller
1123            // reaps the exit code for a precise error), never a panic.
1124            Err(_) => return ShortIdScan::NoId { consumed },
1125        }
1126    }
1127}
1128
1129/// Invoke `claude --bg` for a brand-new supervisor session (`bg_create`).
1130/// `extra_env` carries the `FNO_AGENT_*` attribution vars the caller
1131/// injects. On argv overflow the message is fed via stdin. Failure modes map
1132/// to `AskError::Subprocess` with the Python exit codes (subprocess non-zero,
1133/// 124 timeout, 127 missing binary).
1134pub fn bg_create(
1135    name: &str,
1136    message: &str,
1137    cwd: &Path,
1138    timeout: Option<Duration>,
1139    extra_env: &[(&str, &str)],
1140    model: Option<&str>,
1141    permission_mode: Option<&str>,
1142    effort: Option<&str>,
1143    flags: HarnessFlags,
1144) -> Result<CreateResult, AskError> {
1145    use std::process::{Command, Stdio};
1146
1147    let use_stdin = use_stdin_for(message);
1148    let argv = build_argv(
1149        name,
1150        message,
1151        use_stdin,
1152        model,
1153        permission_mode,
1154        effort,
1155        flags,
1156    );
1157
1158    let mut cmd = Command::new(&argv[0]);
1159    cmd.args(&argv[1..]);
1160    cmd.current_dir(cwd);
1161    cmd.env("FNO_AGENT_SELF", name);
1162    cmd.env("FNO_AGENT_PROVIDER", "claude");
1163    for (k, v) in extra_env {
1164        cmd.env(k, v);
1165    }
1166    cmd.stdout(Stdio::piped());
1167    cmd.stderr(Stdio::piped());
1168    cmd.stdin(if use_stdin {
1169        Stdio::piped()
1170    } else {
1171        Stdio::null()
1172    });
1173
1174    let start = std::time::Instant::now();
1175    let mut child = match cmd.spawn() {
1176        Ok(c) => c,
1177        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1178            return Err(AskError::Subprocess {
1179                exit_code: 127,
1180                stderr: format!("claude CLI not found: {}", e),
1181            });
1182        }
1183        Err(e) => {
1184            return Err(AskError::Subprocess {
1185                exit_code: 127,
1186                stderr: e.to_string(),
1187            });
1188        }
1189    };
1190
1191    // Own each pipe directly. The scan thread reads stdout line by line and
1192    // returns the moment `claude --bg` prints its launch-confirmation line -- we
1193    // never read stdout to EOF. That is the fix for codex P1 on PR #544: the
1194    // detached agent claude forks inherits the stdout pipe and can hold it open
1195    // forever, so a read-to-EOF wait (`wait_with_output`) would hang; and a wait
1196    // that timed out AFTER the agent had launched would SIGKILL only the launcher,
1197    // orphaning the agent while the node became re-dispatchable. Returning on the
1198    // confirmation line means a launched worker is always captured (never
1199    // orphaned), and a timeout can only fire BEFORE the confirmation -- i.e.
1200    // before any agent forked -- so its SIGKILL has nothing to orphan.
1201    let stdin_handle = if use_stdin { child.stdin.take() } else { None };
1202    let stdout_handle = match child.stdout.take() {
1203        Some(h) => h,
1204        None => {
1205            return Err(AskError::Subprocess {
1206                exit_code: 1,
1207                stderr: "claude --bg exposed no stdout pipe".to_string(),
1208            })
1209        }
1210    };
1211    let stderr_handle = child.stderr.take();
1212    let pid = child.id();
1213
1214    // Drain stderr on its own thread (a chatty claude mustn't deadlock on a full
1215    // stderr pipe). Its JoinHandle yields the captured stderr: the failure path
1216    // joins it (the launcher is exiting there, so it's bounded) for an accurate
1217    // message; the success path never needs stderr and never joins (it could
1218    // block on a stderr pipe the detached agent holds open).
1219    let stderr_join: Option<std::thread::JoinHandle<String>> = stderr_handle.map(|mut eh| {
1220        std::thread::spawn(move || {
1221            let mut s = String::new();
1222            let _ = eh.read_to_string(&mut s);
1223            s
1224        })
1225    });
1226
1227    // Write the (possibly >200KB) message to stdin on its OWN detached thread. By
1228    // the time claude prints the confirmation it has consumed stdin, so this
1229    // write has completed on the success path; on the timeout path the SIGKILL
1230    // below unblocks it with a broken pipe.
1231    if let Some(mut sin) = stdin_handle {
1232        let msg = message.to_string();
1233        std::thread::spawn(move || {
1234            let _ = sin.write_all(msg.as_bytes());
1235            // drop closes stdin so claude sees EOF
1236        });
1237    }
1238
1239    // Scan stdout for the confirmation line on its own thread; the main thread
1240    // bounds the wait with the timeout.
1241    let (tx, rx) = std::sync::mpsc::channel();
1242    std::thread::spawn(move || {
1243        let scan = scan_stdout_for_short_id(BufReader::new(stdout_handle));
1244        let _ = tx.send(scan);
1245    });
1246
1247    let scan = match timeout {
1248        Some(d) => match rx.recv_timeout(d) {
1249            Ok(s) => s,
1250            Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
1251                // No confirmation within the deadline. claude prints the short-id
1252                // AFTER it backgrounds the agent, so no agent has launched yet --
1253                // SIGKILL the stalled launcher with nothing to orphan.
1254                unsafe {
1255                    libc::kill(pid as libc::pid_t, libc::SIGKILL);
1256                }
1257                let secs = d.as_secs_f64();
1258                let secs_str = if secs.fract() == 0.0 {
1259                    format!("{}", secs as u64)
1260                } else {
1261                    format!("{}", secs)
1262                };
1263                return Err(AskError::Subprocess {
1264                    exit_code: 124,
1265                    stderr: format!("claude --bg timed out after {}s", secs_str),
1266                });
1267            }
1268            Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
1269                return Err(AskError::Subprocess {
1270                    exit_code: 1,
1271                    stderr: "claude --bg stdout scan thread disconnected".to_string(),
1272                });
1273            }
1274        },
1275        // No deadline: block until the scan resolves. Safe from the original hang
1276        // because the scan returns on the confirmation line, not at EOF.
1277        None => rx.recv().unwrap_or(ShortIdScan::NoId {
1278            consumed: String::new(),
1279        }),
1280    };
1281
1282    let duration_ms = start.elapsed().as_millis();
1283
1284    match scan {
1285        // A confirmed launch wins regardless of what the launcher does next: the
1286        // agent is backgrounded and registered by its short-id, so a later
1287        // nonzero exit of the parent (or a never-closing inherited pipe) is moot.
1288        // We do NOT wait for the exit code -- that is what makes the wait
1289        // unhangable. stderr is unused on success, so we never join its drain.
1290        ShortIdScan::Found { short_id, consumed } => Ok(CreateResult {
1291            short_id,
1292            stdout: consumed,
1293            stderr: String::new(),
1294            duration_ms,
1295        }),
1296        ShortIdScan::NoId { consumed } => {
1297            // stdout closed before any confirmation: a genuine launch failure.
1298            // stdout EOF means the launcher is terminating, so wait() and the
1299            // stderr join both return promptly for a precise code + message.
1300            let exit_code = child.wait().ok().and_then(|s| s.code()).unwrap_or(1);
1301            let stderr = stderr_join
1302                .map(|h| h.join().unwrap_or_default())
1303                .unwrap_or_default();
1304            if exit_code != 0 {
1305                Err(AskError::Subprocess { exit_code, stderr })
1306            } else {
1307                Err(AskError::Parse {
1308                    stdout_head: head_chars(&consumed, STDOUT_HEAD_LIMIT),
1309                })
1310            }
1311        }
1312    }
1313}
1314
1315/// Poll `<jobs_dir>/state.json` until a fresh terminal state appears, then
1316/// return the reply (`wait_for_reply`). Exit condition: `state ∈ TERMINAL_STATES`
1317/// AND (`baseline` is None OR `updated_at` lexicographically `> baseline`).
1318/// Reply preference: non-empty `output.result`, else the timeline tail from
1319/// `timeline_offset`. `ProviderTimeoutError`-equivalent on deadline.
1320pub fn wait_for_reply(
1321    jobs_dir: &Path,
1322    baseline_updated_at: Option<&str>,
1323    timeline_offset: u64,
1324    timeout: Duration,
1325    poll_interval: Duration,
1326    short_id: &str,
1327) -> Result<String, AskError> {
1328    let deadline = std::time::Instant::now() + timeout;
1329    let final_snap = loop {
1330        // NotFound/Parse are transient (recipient hasn't written yet, or an
1331        // atomic-rename window) -> poll again. A non-transient Io fault is
1332        // fatal: surface it rather than spin to the timeout (Python parity).
1333        let snap = match read_state_json(jobs_dir) {
1334            Ok(s) => Some(s),
1335            Err(StateReadError::NotFound) | Err(StateReadError::Parse) => None,
1336            Err(StateReadError::Io(e)) => {
1337                return Err(AskError::Io {
1338                    message: e.to_string(),
1339                });
1340            }
1341        };
1342        if let Some(ref s) = snap {
1343            if is_terminal_state(&s.state) {
1344                let advanced = match (baseline_updated_at, &s.updated_at) {
1345                    (None, _) => true,
1346                    (Some(base), Some(cur)) => cur.as_str() > base,
1347                    (Some(_), None) => false,
1348                };
1349                if advanced {
1350                    break snap.unwrap();
1351                }
1352            }
1353        }
1354        if std::time::Instant::now() >= deadline {
1355            return Err(AskError::Timeout {
1356                elapsed_sec: timeout.as_secs_f64(),
1357                short_id: short_id.to_string(),
1358            });
1359        }
1360        std::thread::sleep(poll_interval);
1361    };
1362
1363    match final_snap.output_result {
1364        Some(ref r) if !r.is_empty() => Ok(r.clone()),
1365        _ => Ok(read_timeline_tail(jobs_dir, timeline_offset)),
1366    }
1367}
1368
1369/// Orchestrate locate → probe → baseline → send → wait_for_reply for one
1370/// follow-up (`ask_followup`). `home` is injected for testability;
1371/// `jobs_dir_override` lets the caller pin the poll dir (tests). Returns the
1372/// reply text (`""` when the recipient produced none). The baseline is captured
1373/// BEFORE the send so a stale `output.result` cannot impersonate the reply.
1374#[allow(clippy::too_many_arguments)]
1375/// x-2681: true iff `short_id` is present in the daemon roster under `home`.
1376/// Lenient -- a missing/torn/type-drifted roster yields false, never an error. A
1377/// cheap pre-check for the control.sock ask fallback; the deliver step's own
1378/// connect is the authoritative liveness gate, so roster PRESENCE (not
1379/// pid-liveness) is enough. Mirrors `_claude_session_registry.roster_live`.
1380fn roster_live(home: &ClaudeHome, short_id: &str) -> bool {
1381    match crate::claude_roster::ClaudeRoster::load(&home.daemon_roster_path()) {
1382        Ok(roster) => roster.find(short_id).is_some(),
1383        Err(_) => false,
1384    }
1385}
1386
1387/// x-2681 ask-lane fallback: deliver `message` to a roster-live but socket-null
1388/// session over the daemon `control.sock` (the single wire vehicle,
1389/// [`crate::mail_inject::deliver_via_control_sock`]), then collect the reply from
1390/// the bg jobs-dir. Mirrors Python's `_ask_via_control_sock`:
1391///   - inject not confirmed -> `Orphan(RosterLiveInjectFailed)` (a delivery
1392///     failure on a LIVE session; the caller must not stamp it orphaned).
1393///   - delivered, reply from the jobs-dir tail -> the reply text.
1394///   - delivered, no jobs-dir (operator session) -> `Timeout` (delivered, no
1395///     reply surface; never fabricate an empty reply -- Open Questions 1/3).
1396fn ask_via_control_sock(
1397    short_id: &str,
1398    message: &str,
1399    from_name: &str,
1400    timeout: Duration,
1401    poll_interval: Duration,
1402    target_jobs_dir: &Path,
1403) -> Result<String, AskError> {
1404    // Baseline the reply surface BEFORE inject so a pre-existing terminal state
1405    // cannot impersonate this turn's reply.
1406    let baseline_updated_at = read_state_json(target_jobs_dir)
1407        .ok()
1408        .and_then(|s| s.updated_at);
1409    let offset = timeline_offset(target_jobs_dir);
1410
1411    let wrapped = build_cross_session_container(message, from_name);
1412    if crate::mail_inject::deliver_via_control_sock(
1413        short_id,
1414        &wrapped,
1415        crate::mail_inject::DEFAULT_ATTEMPTS,
1416        crate::mail_inject::DEFAULT_INTERVAL_MS,
1417    )
1418    .is_err()
1419    {
1420        return Err(AskError::Orphan {
1421            reason: OrphanReason::RosterLiveInjectFailed,
1422            short_id: short_id.to_string(),
1423        });
1424    }
1425
1426    // Delivered. No jobs-dir (operator session) -> nothing to poll: report
1427    // delivered-no-reply rather than spin the full timeout or fabricate a reply.
1428    if !target_jobs_dir.exists() {
1429        return Err(AskError::Timeout {
1430            elapsed_sec: 0.0,
1431            short_id: short_id.to_string(),
1432        });
1433    }
1434
1435    wait_for_reply(
1436        target_jobs_dir,
1437        baseline_updated_at.as_deref(),
1438        offset,
1439        timeout,
1440        poll_interval,
1441        short_id,
1442    )
1443}
1444
1445pub fn ask_followup(
1446    home: &ClaudeHome,
1447    claude_short_id: &str,
1448    message: &str,
1449    from_name: &str,
1450    timeout: Duration,
1451    poll_interval: Duration,
1452    jobs_dir_override: Option<&Path>,
1453) -> Result<String, AskError> {
1454    let locator = match locate_session(home, claude_short_id) {
1455        Some(l) => l,
1456        None => {
1457            let reason = classify_orphan_reason(home, claude_short_id);
1458            // x-2681: a socket-null session that is live in the daemon roster is
1459            // reachable over the daemon control.sock. Fall back before orphaning.
1460            // A miss falls through to family-1 transcript truth before orphaning.
1461            if reason == OrphanReason::SocketNull && roster_live(home, claude_short_id) {
1462                let jd = jobs_dir_override
1463                    .map(|p| p.to_path_buf())
1464                    .unwrap_or_else(|| home.jobs_dir_for(claude_short_id));
1465                return ask_via_control_sock(
1466                    claude_short_id,
1467                    message,
1468                    from_name,
1469                    timeout,
1470                    poll_interval,
1471                    &jd,
1472                );
1473            }
1474            return Err(AskError::Orphan {
1475                reason: family1_orphan_reason(claude_short_id, reason),
1476                short_id: claude_short_id.to_string(),
1477            });
1478        }
1479    };
1480
1481    if !liveness_probe(&locator.messaging_socket_path) {
1482        // Socket exists but is dead. Same control.sock fallback when roster-live.
1483        if roster_live(home, claude_short_id) {
1484            let jd = jobs_dir_override
1485                .map(|p| p.to_path_buf())
1486                .unwrap_or_else(|| locator.jobs_dir.clone());
1487            return ask_via_control_sock(
1488                claude_short_id,
1489                message,
1490                from_name,
1491                timeout,
1492                poll_interval,
1493                &jd,
1494            );
1495        }
1496        return Err(AskError::Orphan {
1497            reason: family1_orphan_reason(claude_short_id, OrphanReason::LivenessFailed),
1498            short_id: claude_short_id.to_string(),
1499        });
1500    }
1501
1502    let target_jobs_dir: PathBuf = jobs_dir_override
1503        .map(|p| p.to_path_buf())
1504        .unwrap_or_else(|| locator.jobs_dir.clone());
1505
1506    // Baseline BEFORE send (AC2-EDGE invariant).
1507    let baseline_updated_at = read_state_json(&target_jobs_dir)
1508        .ok()
1509        .and_then(|s| s.updated_at);
1510    let offset = timeline_offset(&target_jobs_dir);
1511
1512    send_to_session(&locator.messaging_socket_path, message, from_name)?;
1513
1514    wait_for_reply(
1515        &target_jobs_dir,
1516        baseline_updated_at.as_deref(),
1517        offset,
1518        timeout,
1519        poll_interval,
1520        claude_short_id,
1521    )
1522}
1523
1524// ===========================================================================
1525// Wave 3: orchestration (dispatch_claude_ask) — validation, flock,
1526// create-vs-followup, registry stamping, exit codes, events.
1527// ===========================================================================
1528
1529use crate::paths::AgentsHome;
1530use crate::state::{load_registry, update_registry, RegistryEntry};
1531use crate::AgentStatus;
1532
1533/// `_NAME_MAX_LEN` / `_FROM_NAME_MAX_LEN`.
1534const NAME_MAX_LEN: usize = 128;
1535const FROM_NAME_MAX_LEN: usize = 128;
1536/// `_DEFAULT_FOLLOWUP_TIMEOUT_SEC`.
1537const DEFAULT_FOLLOWUP_TIMEOUT: Duration = Duration::from_secs(600);
1538/// Lock-acquisition ceiling (Python's `lock_timeout`); contention here is rare.
1539const LOCK_ACQUIRE_TIMEOUT: Duration = Duration::from_secs(30);
1540/// Bound the `claude --bg` *launch* wait for a spawn (create). A spawn only
1541/// waits for claude to print its short-id and exit, but `bg_create`'s
1542/// `wait_with_output` reads stdout/stderr to EOF -- and the detached agent
1543/// claude forks inherits those pipe fds, so the read can block on EOF
1544/// indefinitely if that agent holds them open. Spawn callers (spawn.sh,
1545/// dispatch-node.sh) pass no --timeout, so without a default the create wait
1546/// falls into bg_create's unbounded `rx.recv()` arm and hangs the caller
1547/// forever. 120s is far beyond a normal sub-5s launch; on overrun the child is
1548/// SIGKILLed and the caller sees exit 124 instead of a wedged process.
1549const DEFAULT_SPAWN_TIMEOUT: Duration = Duration::from_secs(120);
1550
1551/// How recent an inside-leg report must be for a worker to count as "provably
1552/// live" when a follow-up fails to route (x-c393). A bg `/target` worker reports
1553/// at least per turn, but a long turn can leave a multi-minute gap, so the
1554/// window is generous; `fno agents reconcile` (the `claude logs` probe) is the
1555/// eventual authority that orphans a genuinely dead worker. ponytail: a fixed
1556/// ceiling, not config -- reconcile is the backstop.
1557const PROVABLY_LIVE_WINDOW_SECS: u64 = 3600;
1558
1559/// Whether a row is "provably live": it carries an inside-leg report recent
1560/// enough that a follow-up routing miss is a gap, not a death (x-c393). Checked
1561/// against the CURRENT row under the registry lock at stamp time -- not a
1562/// pre-ask snapshot -- so a report that landed during a long ask is not missed
1563/// (codex P2). A live row must NOT be stamped orphaned; that would mislead
1564/// `fno agents list`. reconcile's `claude logs` probe orphans a truly dead one.
1565fn is_provably_live_report(
1566    inside_leg: Option<&crate::state::InsideLegReport>,
1567    now_secs: u64,
1568) -> bool {
1569    inside_leg.is_some_and(|r| r.received_within(now_secs, PROVABLY_LIVE_WINDOW_SECS))
1570}
1571
1572fn now_epoch_secs() -> u64 {
1573    std::time::SystemTime::now()
1574        .duration_since(std::time::UNIX_EPOCH)
1575        .map(|d| d.as_secs())
1576        .unwrap_or(0)
1577}
1578
1579/// The create-wait timeout for a spawn: an explicit `--timeout` wins, else the
1580/// bounded default. Never returns `None`, so a spawn launch can never fall into
1581/// `bg_create`'s unbounded wait arm. Pulled out as a pure fn so the defaulting
1582/// is unit-testable without spawning `claude`.
1583fn spawn_create_timeout(explicit: Option<Duration>) -> Duration {
1584    explicit.unwrap_or(DEFAULT_SPAWN_TIMEOUT)
1585}
1586
1587/// Outcome of a claude ask: what to print to stdout/stderr and the process exit
1588/// code. `stdout` already carries any trailing newline (create) or none
1589/// (followup reply), matching Python's `sys.stdout.write`.
1590#[derive(Debug, Clone, PartialEq, Eq)]
1591pub struct AskOutcome {
1592    pub stdout: String,
1593    pub stderr: String,
1594    pub exit_code: i32,
1595}
1596
1597impl AskOutcome {
1598    fn ok_stdout(s: String) -> Self {
1599        Self {
1600            stdout: s,
1601            stderr: String::new(),
1602            exit_code: 0,
1603        }
1604    }
1605    /// Errors mirror Python's `print(str(exc), file=sys.stderr)`: the message
1606    /// plus a trailing newline. `stderr` holds the exact bytes the client
1607    /// writes verbatim (no added newline at the print site).
1608    fn err(msg: impl Into<String>, code: i32) -> Self {
1609        Self {
1610            stdout: String::new(),
1611            stderr: format!("{}\n", msg.into()),
1612            exit_code: code,
1613        }
1614    }
1615}
1616
1617/// UTC `_utc_now_iso()` second-precision timestamp.
1618fn now_iso() -> String {
1619    chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
1620}
1621
1622/// Append one `events.jsonl` line in the Python-agents envelope
1623/// (`{...fields, ts, kind}`, compact). Free function (not `.emit(`) so the
1624/// crate's daemon-emit-kind scanner ignores these Python-side audit kinds,
1625/// matching `client_verbs::append_agents_event`.
1626///
1627/// SCOPE NOTE (cv-022d74f9): success events here carry their explicit fields
1628/// only. Python's `emit_with_context` also flattens a 13-field `EventContext`
1629/// (from_*/to_*/caller_kind/transport/request_id/target_session_id) onto
1630/// success events; porting `build_context` is deferred to the observability
1631/// surface (ab-85119580 / the ab-0429c6e1 cutover). Failure events already
1632/// match Python's plain `events.emit` (no context).
1633pub fn emit_event(events_path: &Path, kind: &str, fields: &[(&str, serde_json::Value)]) {
1634    // ensure_ascii parity with Python's json.dumps: encode every string scalar
1635    // (keys + string values) via json_string_ascii so non-ASCII field content
1636    // (e.g. an accented agent name) escapes to \uXXXX identically. Non-string
1637    // values (numbers/bools/null) are ASCII already.
1638    fn enc_value(v: &serde_json::Value) -> String {
1639        match v {
1640            serde_json::Value::String(s) => json_string_ascii(s),
1641            other => serde_json::to_string(other).unwrap_or_default(),
1642        }
1643    }
1644    let ts = now_iso();
1645    let mut parts: Vec<String> = fields
1646        .iter()
1647        .map(|(k, v)| format!("{}:{}", json_string_ascii(k), enc_value(v)))
1648        .collect();
1649    parts.push(format!("\"ts\":{}", json_string_ascii(&ts)));
1650    parts.push(format!("\"kind\":{}", json_string_ascii(kind)));
1651    let line = format!("{{{}}}\n", parts.join(","));
1652    let res = (|| -> std::io::Result<()> {
1653        if let Some(parent) = events_path.parent() {
1654            std::fs::create_dir_all(parent)?;
1655        }
1656        let mut fh = std::fs::OpenOptions::new()
1657            .create(true)
1658            .append(true)
1659            .open(events_path)?;
1660        fh.write_all(line.as_bytes())
1661    })();
1662    if let Err(e) = res {
1663        // cv-b3f6c5a1: a failed events.jsonl write stays best-effort (it never
1664        // fails the ask -- parity with Python's best-effort agents emit), but is
1665        // surfaced ONCE per process instead of fully swallowed, so a broken /
1666        // unwritable events dir is observable. Mirrors the output.jsonl tee
1667        // warn-once in codex_ask.rs. Shared by claude and codex ask via this fn.
1668        if !EMIT_EVENT_WRITE_WARNED.swap(true, std::sync::atomic::Ordering::Relaxed) {
1669            eprintln!(
1670                "fno-agents: failed to append {} event to {}: {} (further event-write failures this run suppressed)",
1671                kind,
1672                events_path.display(),
1673                e
1674            );
1675        }
1676    }
1677}
1678
1679/// Set the first time an `events.jsonl` append fails, so the warn in
1680/// `emit_event` fires once per process rather than once per dropped event.
1681static EMIT_EVENT_WRITE_WARNED: std::sync::atomic::AtomicBool =
1682    std::sync::atomic::AtomicBool::new(false);
1683
1684/// Validate name/message/from_name (`_validate_inputs` + `_validate_from_name`).
1685/// Returns the exit-2 error message on failure.
1686///
1687/// Public so other provider ports (codex_ask, future gemini_ask) share the
1688/// same checks rather than carrying weaker duplicates. The function mirrors
1689/// Python's `dispatch.py::_validate_inputs` + `_validate_from_name` and is
1690/// the canonical pre-flight gate for any `ask` dispatch.
1691pub fn validate_inputs(name: &str, message: &str, from_name: &str) -> Result<(), String> {
1692    validate_spawn_inputs(name, from_name)?;
1693    if message.is_empty() || message.trim().is_empty() {
1694        return Err("message must be non-empty".into());
1695    }
1696    Ok(())
1697}
1698
1699/// Validate name + from_name WITHOUT the message check. `spawn` allows an
1700/// empty initial message (Python `dispatch_spawn` parity: it validates name
1701/// and from_name inline but never rejects an empty message; the once paths
1702/// default an empty message to "hello" instead).
1703pub fn validate_spawn_inputs(name: &str, from_name: &str) -> Result<(), String> {
1704    if name.is_empty() {
1705        return Err("agent name must not be empty".into());
1706    }
1707    if name.contains('/') || name.contains('\\') || name.contains("..") {
1708        return Err(format!(
1709            "agent name must not contain path separators or '..': {}",
1710            py_repr(name)
1711        ));
1712    }
1713    if name.chars().count() > NAME_MAX_LEN {
1714        return Err(format!(
1715            "name must be <={} chars (got {})",
1716            NAME_MAX_LEN,
1717            name.chars().count()
1718        ));
1719    }
1720    if name.len() == 8
1721        && name
1722            .bytes()
1723            .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
1724    {
1725        return Err(format!(
1726            "agent name {} must not match short-id shape ^[0-9a-f]{{8}}$ (prevents name/id collision)",
1727            py_repr(name)
1728        ));
1729    }
1730    if let Some(bad) = ['\u{0}', '\n', '\r', '=']
1731        .into_iter()
1732        .find(|c| name.contains(*c))
1733    {
1734        return Err(format!(
1735            "agent name {} contains a forbidden character ({} would corrupt subprocess env injection)",
1736            py_repr(name),
1737            py_repr(&bad.to_string())
1738        ));
1739    }
1740    if from_name.is_empty() {
1741        return Err("from-name must not be empty".into());
1742    }
1743    if from_name.chars().count() > FROM_NAME_MAX_LEN {
1744        return Err(format!(
1745            "from-name must be <={} chars (got {})",
1746            FROM_NAME_MAX_LEN,
1747            from_name.chars().count()
1748        ));
1749    }
1750    if from_name
1751        .chars()
1752        .any(|c| matches!(c, '"' | '<' | '>' | '&'))
1753    {
1754        return Err("from-name must not contain XML-unsafe characters (\", <, >, &)".into());
1755    }
1756    Ok(())
1757}
1758
1759/// RAII per-agent flock at `<registry-dir>/locks/<name>.lock`, byte-compatible
1760/// with Python's `_agent_lock_path` (`fcntl.flock` ⇄ `fs2`). Held for the
1761/// duration of one ask so concurrent same-agent asks serialize (AC10).
1762struct AgentLock {
1763    _file: std::fs::File,
1764}
1765
1766impl AgentLock {
1767    fn acquire(home: &AgentsHome, name: &str, timeout: Duration) -> Result<Self, ()> {
1768        let locks_dir = home.root().join("locks");
1769        let _ = std::fs::create_dir_all(&locks_dir);
1770        let path = locks_dir.join(format!("{}.lock", name));
1771        let file = match std::fs::OpenOptions::new()
1772            .create(true)
1773            .truncate(false)
1774            .write(true)
1775            .open(&path)
1776        {
1777            Ok(f) => f,
1778            Err(_) => return Err(()),
1779        };
1780        let deadline = std::time::Instant::now() + timeout;
1781        loop {
1782            match file.try_lock() {
1783                Ok(()) => return Ok(Self { _file: file }),
1784                Err(_) => {
1785                    if std::time::Instant::now() >= deadline {
1786                        return Err(());
1787                    }
1788                    std::thread::sleep(Duration::from_millis(25));
1789                }
1790            }
1791        }
1792    }
1793}
1794
1795impl Drop for AgentLock {
1796    fn drop(&mut self) {
1797        let _ = self._file.unlock();
1798    }
1799}
1800
1801/// Stable fno-side log path for `fno agents logs <name>` (`_derive_log_path`).
1802fn derive_log_path(home: &AgentsHome, name: &str) -> PathBuf {
1803    home.root()
1804        .join("agents")
1805        .join("logs")
1806        .join(format!("{}.log", name))
1807}
1808
1809/// Orchestrate one claude `ask`: validate, lock, decide create-vs-followup,
1810/// stamp the registry, emit events, and return what to print plus the exit
1811/// code. `extra_env` is forwarded to `bg_create` (production passes `&[]` to
1812/// inherit the real environment; tests inject `PATH`/`FAKE_CLAUDE_*`).
1813#[allow(clippy::too_many_arguments)]
1814pub fn dispatch_claude_ask(
1815    home: &AgentsHome,
1816    claude_home: &ClaudeHome,
1817    name: &str,
1818    message: &str,
1819    from_name: &str,
1820    // Create-only inputs, retained for API stability after Task 1.3a removed
1821    // the create branch from `ask` (callers still pass them; `spawn` owns
1822    // creation now via `dispatch_claude_spawn`).
1823    _cwd: &Path,
1824    _yolo: bool,
1825    timeout: Option<Duration>,
1826    _extra_env: &[(&str, &str)],
1827) -> AskOutcome {
1828    if let Err(msg) = validate_inputs(name, message, from_name) {
1829        return AskOutcome::err(msg, 2);
1830    }
1831
1832    let events = home.events_jsonl();
1833    let registry_path = home.registry_json();
1834
1835    let _lock = match AgentLock::acquire(home, name, LOCK_ACQUIRE_TIMEOUT) {
1836        Ok(l) => l,
1837        Err(()) => {
1838            emit_event(
1839                &events,
1840                "agent_ask_failed",
1841                &[("stage", "lock-timeout".into()), ("name", name.into())],
1842            );
1843            return AskOutcome::err(
1844                // Python: f"lock timeout for agent {name!r} after {timeout}s"
1845                // with timeout=30.0 (float) -> "...after 30.0s".
1846                format!(
1847                    "lock timeout for agent {} after {:.1}s",
1848                    py_repr(name),
1849                    LOCK_ACQUIRE_TIMEOUT.as_secs_f64()
1850                ),
1851                11,
1852            );
1853        }
1854    };
1855
1856    // A registry READ error (corrupt / schema-mismatched file) must fail BEFORE
1857    // any provider side effect: treating it as empty would route a known agent
1858    // down the create path and spawn an orphaned `claude --bg` supervisor that
1859    // only fails later on the write. Python's dispatcher exits 12 here (Codex
1860    // P2). A missing file is NOT an error (load_registry returns the default).
1861    let registry = match load_registry(&registry_path) {
1862        Ok(r) => r,
1863        Err(e) => {
1864            emit_event(
1865                &events,
1866                "agent_ask_failed",
1867                &[
1868                    ("stage", "registry-read".into()),
1869                    ("name", name.into()),
1870                    ("error", e.to_string().into()),
1871                ],
1872            );
1873            return AskOutcome::err(format!("registry read failed: {}", e), 12);
1874        }
1875    };
1876    let existing = registry.find(name).cloned();
1877
1878    match existing {
1879        Some(entry) => followup(
1880            home,
1881            claude_home,
1882            &events,
1883            &registry_path,
1884            name,
1885            &entry,
1886            message,
1887            from_name,
1888            timeout,
1889        ),
1890        None => {
1891            // ask never creates (Task 1.3a): unknown-name -> exit 16, byte-parity
1892            // with Python's dispatch_ask after Task 1.1.
1893            emit_event(
1894                &events,
1895                "agent_ask_failed",
1896                &[
1897                    ("stage", "unknown-name".into()),
1898                    ("name", name.into()),
1899                    ("provider", "claude".into()),
1900                ],
1901            );
1902            AskOutcome::err(
1903                format!(
1904                    "unknown agent {}; spawn it first: fno agents spawn {} --harness <harness>",
1905                    py_repr(name),
1906                    name
1907                ),
1908                16,
1909            )
1910        }
1911    }
1912}
1913
1914/// Orchestrate one claude `spawn`: validate, lock, collision-check, create,
1915/// and return a compact JSON receipt.  The `create` helper machinery is
1916/// reused directly; only the output shape differs from `dispatch_claude_ask`.
1917///
1918/// Receipt (byte-parity with Python `cmd_spawn`):
1919/// `{"name": "<name>", "short_id": "<8hex>", "provider": "claude", "status": "live"}\n`
1920#[allow(clippy::too_many_arguments)]
1921pub fn dispatch_claude_spawn(
1922    home: &AgentsHome,
1923    claude_home: &ClaudeHome,
1924    name: &str,
1925    message: &str,
1926    from_name: &str,
1927    cwd: &Path,
1928    yolo: bool,
1929    timeout: Option<Duration>,
1930    extra_env: &[(&str, &str)],
1931    model: Option<&str>,
1932    permission_mode: Option<&str>,
1933    effort: Option<&str>,
1934    flags: HarnessFlags,
1935    // x-85fe: append the effective `cwd` to the receipt (LAST key). True only on
1936    // the DEFAULT canonical move (no explicit --cwd, cwd differs from caller),
1937    // coupled with the redirect note the caller already emitted. False keeps the
1938    // receipt byte-identical (explicit --cwd, --here, or a stay-put spawn).
1939    surface_cwd: bool,
1940) -> AskOutcome {
1941    // spawn allows an empty initial message (Python dispatch_spawn parity).
1942    if let Err(msg) = validate_spawn_inputs(name, from_name) {
1943        return AskOutcome::err(msg, 2);
1944    }
1945
1946    let events = home.events_jsonl();
1947    let registry_path = home.registry_json();
1948
1949    let _lock = match AgentLock::acquire(home, name, LOCK_ACQUIRE_TIMEOUT) {
1950        Ok(l) => l,
1951        Err(()) => {
1952            emit_event(
1953                &events,
1954                "agent_ask_failed",
1955                &[
1956                    ("stage", "lock-timeout".into()),
1957                    ("name", name.into()),
1958                    ("provider", "claude".into()),
1959                ],
1960            );
1961            return AskOutcome::err(
1962                format!(
1963                    "lock timeout for agent {} after {:.1}s",
1964                    py_repr(name),
1965                    LOCK_ACQUIRE_TIMEOUT.as_secs_f64()
1966                ),
1967                11,
1968            );
1969        }
1970    };
1971
1972    // Collision check INSIDE the lock (mirrors Python dispatch_spawn 4a).
1973    let registry = match load_registry(&registry_path) {
1974        Ok(r) => r,
1975        Err(e) => {
1976            return AskOutcome::err(format!("registry read failed: {}", e), 12);
1977        }
1978    };
1979    if registry.find(name).is_some() {
1980        // Python: f"agent {name!r} already exists; ..." -> py_repr, not {:?}.
1981        return AskOutcome::err(
1982            format!(
1983                "agent {} already exists; use 'fno agents rm {}' first or pick another name",
1984                py_repr(name),
1985                name
1986            ),
1987            2,
1988        );
1989    }
1990
1991    // x-dfa4: --yolo now maps to bypassPermissions for claude (was a no-op); an
1992    // explicit --permission-mode wins (the two are mutually exclusive upstream).
1993    // Resolved once here so the receipt below can name the applied mode.
1994    let effective_mode: Option<&str> = match permission_mode {
1995        Some(m) => Some(m),
1996        None if yolo => Some("bypassPermissions"),
1997        None => None,
1998    };
1999
2000    // Delegate to the retained create machinery. A spawn always bounds the
2001    // launch wait (DEFAULT_SPAWN_TIMEOUT when the caller passed no --timeout) so
2002    // a `claude --bg` that never EOFs its inherited stdout/stderr can't hang the
2003    // dispatcher forever; on overrun create() returns exit 124.
2004    let inner = create(
2005        home,
2006        claude_home,
2007        &events,
2008        &registry_path,
2009        name,
2010        message,
2011        from_name,
2012        cwd,
2013        yolo,
2014        Some(spawn_create_timeout(timeout)),
2015        extra_env,
2016        model,
2017        effective_mode,
2018        effort,
2019        flags,
2020    );
2021    if inner.exit_code != 0 {
2022        return inner;
2023    }
2024
2025    // On success, `create` returns the 8-hex short_id in stdout (with trailing newline).
2026    // Build the JSON receipt expected by the CLI and parity tests.
2027    let short_id = inner.stdout.trim_end_matches('\n').to_string();
2028    // Create-path output contract trip-wire (sigma-review type-design
2029    // finding): the receipt format does not itself validate the id shape.
2030    // assert! (not debug_assert!) so release builds keep the guard - the
2031    // check is one 8-byte scan per spawn and a loud panic beats a malformed
2032    // receipt propagating to jq consumers (gemini review, PR #457).
2033    assert!(
2034        short_id.len() == 8 && short_id.bytes().all(|b| b.is_ascii_hexdigit()),
2035        "create path produced non-8hex short_id: {short_id:?}"
2036    );
2037    // Escape `"` in the name so the receipt stays valid JSON for jq consumers
2038    // (name validation blocks backslash already; Python cmd_spawn parity).
2039    let safe_name = name.replace('"', "\\\"");
2040    // Locked Decision 5: name the applied mode (flag or yolo-derived) so an audit
2041    // of "why did this worker have edit rights" has a durable answer. Only when
2042    // set, so the unset receipt is byte-identical (AC7). Values are exact
2043    // passthrough, so escape `"` defensively.
2044    let perm_field = match effective_mode.filter(|m| !m.is_empty()) {
2045        Some(m) => format!(r#", "permission_mode": "{}""#, m.replace('"', "\\\"")),
2046        None => String::new(),
2047    };
2048    // x-85fe: append the effective launch dir on the default canonical move
2049    // (surface_cwd, decided by the caller alongside the redirect note), so the
2050    // move is legible in the receipt too. LAST key, so an unmoved / explicit-cwd
2051    // receipt is byte-identical (Python cmd_spawn parity, AC1-EDGE). Full
2052    // JSON-string encode (not just `"`-escape): a repo path may carry a backslash
2053    // or control char that would otherwise produce invalid JSON receipt consumers
2054    // fail to parse (review); json_string_ascii matches Python's json.dumps.
2055    let cwd_field = if surface_cwd {
2056        format!(
2057            ", \"cwd\": {}",
2058            json_string_ascii(&cwd.display().to_string())
2059        )
2060    } else {
2061        String::new()
2062    };
2063    AskOutcome {
2064        stdout: format!(
2065            r#"{{"name": "{safe_name}", "short_id": "{short_id}", "provider": "claude", "status": "live"{perm_field}{cwd_field}}}"#
2066        ) + "\n",
2067        stderr: inner.stderr,
2068        exit_code: 0,
2069    }
2070}
2071
2072/// Dispatch a `claude -p` truly-headless one-shot (x-2c27 `headless` substrate).
2073///
2074/// Unlike [`dispatch_claude_spawn`] (the detached `--bg` thread, which returns a
2075/// short-id receipt), this runs `claude -p` SYNCHRONOUSLY to completion, prints
2076/// the model's reply to stdout, and exits - no registry row, no short-id, no
2077/// driveable pane. `claude` never *defaults* to `-p`; this is the one lane that
2078/// shells it (Locked Decision 4). `ask` and the relay claude hop keep `--bg`.
2079///
2080/// A headless run cannot answer permission prompts, so it always passes
2081/// `--dangerously-skip-permissions` (mirrors the agy/codex once lanes); `yolo`
2082/// is therefore a no-op, accepted only for signature parity. `claude_home` is
2083/// unused (no session registry for an ephemeral one-shot) and kept for parity
2084/// with the bg path's signature.
2085#[allow(clippy::too_many_arguments)]
2086pub fn dispatch_claude_headless(
2087    _claude_home: &ClaudeHome,
2088    name: &str,
2089    message: &str,
2090    from_name: &str,
2091    cwd: &Path,
2092    _yolo: bool,
2093    timeout: Option<Duration>,
2094    model: Option<&str>,
2095    permission_mode: Option<&str>,
2096    effort: Option<&str>,
2097    flags: HarnessFlags,
2098) -> AskOutcome {
2099    use std::io::Write;
2100    use std::process::{Command, Stdio};
2101    use std::time::Instant;
2102
2103    if let Err(msg) = validate_spawn_inputs(name, from_name) {
2104        return AskOutcome::err(msg, 2);
2105    }
2106
2107    // Python-truthiness parity with the once lanes: only an EMPTY message
2108    // becomes "hello"; a whitespace-only prompt passes through unchanged.
2109    let effective = if message.is_empty() { "hello" } else { message };
2110    let use_stdin = use_stdin_for(effective);
2111
2112    // x-dfa4: an explicit --permission-mode replaces the hardcoded
2113    // --dangerously-skip-permissions for the headless lane; unset keeps the
2114    // skip (a headless one-shot cannot answer permission prompts).
2115    let mut argv: Vec<String> = vec!["claude".into(), "-p".into()];
2116    match permission_mode.filter(|m| !m.is_empty()) {
2117        Some(m) => {
2118            argv.push("--permission-mode".into());
2119            argv.push(m.into());
2120        }
2121        None => argv.push("--dangerously-skip-permissions".into()),
2122    }
2123    // x-c772: an explicit --model is forwarded to `claude -p --model <m>`
2124    // (empty/None = claude default). Exact passthrough, no fuzzy resolution.
2125    if let Some(m) = model.filter(|m| !m.is_empty()) {
2126        argv.push("--model".to_string());
2127        argv.push(m.to_string());
2128    }
2129    if let Some(value) = effort.filter(|v| !v.is_empty()) {
2130        argv.push("--effort".to_string());
2131        argv.push(value.to_string());
2132    }
2133    // x-b6e2: Tier-3 passthrough, same token order as the Python headless_create.
2134    flags.push_onto(&mut argv);
2135    if !use_stdin {
2136        argv.push(effective.to_string());
2137    }
2138    // QoS (x-c5cc): a headless one-shot is an fno-spawned child — exec-wrap it
2139    // at background priority (worker_qos=utility) so it never starves the
2140    // foreground. Identity when worker_qos=off.
2141    let argv = crate::spawn_gate::qos_wrap(cwd, argv);
2142
2143    let mut cmd = Command::new(&argv[0]);
2144    cmd.args(&argv[1..]);
2145    cmd.current_dir(cwd);
2146    cmd.env("FNO_AGENT_SELF", name);
2147    cmd.env("FNO_AGENT_PROVIDER", "claude");
2148    cmd.env("FNO_AGENT_FROM", from_name);
2149    cmd.stdout(Stdio::piped());
2150    cmd.stderr(Stdio::piped());
2151    cmd.stdin(if use_stdin {
2152        Stdio::piped()
2153    } else {
2154        Stdio::null()
2155    });
2156
2157    let mut child = match cmd.spawn() {
2158        Ok(c) => c,
2159        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
2160            return AskOutcome::err(format!("claude CLI not found: {}", e), 127);
2161        }
2162        Err(e) => return AskOutcome::err(e.to_string(), 127),
2163    };
2164
2165    // Drain stdout/stderr on their own threads so a filling pipe can never
2166    // deadlock the bounded wait below (we can't use wait_with_output: it blocks
2167    // with no timeout knob). The common argv path still uses these.
2168    let stdout_join = child.stdout.take().map(|mut p| {
2169        std::thread::spawn(move || {
2170            let mut s = Vec::new();
2171            let _ = p.read_to_end(&mut s);
2172            s
2173        })
2174    });
2175    let stderr_join = child.stderr.take().map(|mut p| {
2176        std::thread::spawn(move || {
2177            let mut s = Vec::new();
2178            let _ = p.read_to_end(&mut s);
2179            s
2180        })
2181    });
2182
2183    // Large (>200KB) prompts go via stdin on a detached writer thread so a
2184    // filling stdout pipe can't deadlock a synchronous write. The common path
2185    // (argv) skips this entirely.
2186    let stdin_writer = if use_stdin {
2187        child.stdin.take().map(|mut s| {
2188            let data = effective.to_string();
2189            std::thread::spawn(move || {
2190                let _ = s.write_all(data.as_bytes());
2191            })
2192        })
2193    } else {
2194        None
2195    };
2196
2197    // Bounded wait: a `-p` one-shot has no detached fork to orphan, but a hung
2198    // `claude -p` (startup/network stall) would otherwise wedge the caller
2199    // forever even when --timeout was supplied (codex P2). When `timeout` is
2200    // set, SIGKILL the child past the deadline and report exit 124 (parity with
2201    // the --bg launch timeout); with no --timeout the wait is unbounded, as the
2202    // user asked for no bound. The reader threads above keep the pipes drained.
2203    // `wait_result` is Some(exit_code) on a real exit, None on a timeout kill.
2204    let wait_result: Result<Option<i32>, String> = if let Some(limit) = timeout {
2205        let deadline = Instant::now() + limit;
2206        loop {
2207            match child.try_wait() {
2208                Ok(Some(st)) => break Ok(Some(st.code().unwrap_or(1))),
2209                Ok(None) => {
2210                    if Instant::now() >= deadline {
2211                        let _ = child.kill();
2212                        let _ = child.wait();
2213                        break Ok(None);
2214                    }
2215                    std::thread::sleep(Duration::from_millis(50));
2216                }
2217                Err(e) => break Err(format!("claude -p wait failed: {}", e)),
2218            }
2219        }
2220    } else {
2221        child
2222            .wait()
2223            .map(|st| Some(st.code().unwrap_or(1)))
2224            .map_err(|e| format!("claude -p wait failed: {}", e))
2225    };
2226
2227    // Collect output by JOINING the reader/writer threads ONLY on a clean exit.
2228    // On a timeout-kill we must NOT join: SIGKILL'ing `claude` does not reap a
2229    // grandchild it spawned (e.g. a shell's `sleep`), which keeps the inherited
2230    // stdout/stderr write end open, so `read_to_end` would block until THAT
2231    // grandchild exits - defeating the very timeout we just enforced. Abandon
2232    // the threads (they die when the OS finally closes the fds); the timed-out
2233    // one-shot has no useful deliverable anyway. (Same orphan-pipe hazard the
2234    // --bg launch path documents.)
2235    match wait_result {
2236        Err(msg) => AskOutcome::err(msg, 1),
2237        Ok(None) => AskOutcome::err(
2238            format!(
2239                "claude -p timed out after {:.1}s",
2240                timeout
2241                    .expect("timeout Some on the bounded path")
2242                    .as_secs_f64()
2243            ),
2244            124,
2245        ),
2246        Ok(Some(exit_code)) => {
2247            let stdout = stdout_join
2248                .map(|h| h.join().unwrap_or_default())
2249                .unwrap_or_default();
2250            let stderr = stderr_join
2251                .map(|h| h.join().unwrap_or_default())
2252                .unwrap_or_default();
2253            if let Some(h) = stdin_writer {
2254                let _ = h.join();
2255            }
2256            AskOutcome {
2257                stdout: String::from_utf8_lossy(&stdout).into_owned(),
2258                stderr: String::from_utf8_lossy(&stderr).into_owned(),
2259                exit_code,
2260            }
2261        }
2262    }
2263}
2264
2265#[allow(clippy::too_many_arguments)]
2266fn followup(
2267    _home: &AgentsHome,
2268    claude_home: &ClaudeHome,
2269    events: &Path,
2270    registry_path: &Path,
2271    name: &str,
2272    entry: &RegistryEntry,
2273    message: &str,
2274    from_name: &str,
2275    timeout: Option<Duration>,
2276) -> AskOutcome {
2277    // An INTERACTIVE stream-json claude row carries the daemon worker/socket id
2278    // in short_id, NOT a claude --bg jobId (v9, x-1b1e). `ask` followup's
2279    // locate_session expects a jobId, so never route a worker id as one: treat an
2280    // interactive row as having no jobId and refuse (pre-v9 these had
2281    // claude_short_id=None and refused identically). Only a claude shellout
2282    // (--bg/ask, host_mode exec) or adopted row's short_id is the jobId.
2283    let job_id = if entry.host_mode_or_default() == crate::state::HOST_MODE_INTERACTIVE {
2284        None
2285    } else {
2286        entry.transport_short()
2287    };
2288    let short_id = match job_id {
2289        Some(s) => s.to_string(),
2290        _ => {
2291            return AskOutcome::err(
2292                format!(
2293                    "registry entry {} has no short id on file; cannot follow up. Remove with 'fno agents rm {}' and recreate.",
2294                    py_repr(name), name
2295                ),
2296                12,
2297            );
2298        }
2299    };
2300
2301    emit_event(
2302        events,
2303        "agent_followup_started",
2304        &[
2305            ("name", name.into()),
2306            ("provider", entry.harness_name().to_string().into()),
2307            ("short_id", short_id.clone().into()),
2308        ],
2309    );
2310
2311    let wait = timeout.unwrap_or(DEFAULT_FOLLOWUP_TIMEOUT);
2312    match ask_followup(
2313        claude_home,
2314        &short_id,
2315        message,
2316        from_name,
2317        wait,
2318        DEFAULT_POLL_INTERVAL,
2319        None,
2320    ) {
2321        Ok(reply) => {
2322            // Stamp status=live + last_message_at under the registry flock.
2323            // A write failure here is FATAL (Python dispatch.py:537-556 parity):
2324            // the message was already delivered but the registry can't record
2325            // it, so withhold the reply from stdout and exit 12 to prevent a
2326            // double-send on retry.
2327            if let Err(e) = update_registry(registry_path, |reg| {
2328                if let Some(en) = reg.find_mut(name) {
2329                    en.status = AgentStatus::Live;
2330                    en.last_message_at = Some(now_iso());
2331                }
2332            }) {
2333                emit_event(
2334                    events,
2335                    "agent_followup_failed",
2336                    &[
2337                        ("stage", "registry-write".into()),
2338                        ("name", name.into()),
2339                        ("short_id", short_id.clone().into()),
2340                        ("error", e.to_string().into()),
2341                    ],
2342                );
2343                return AskOutcome::err(
2344                    format!(
2345                        "registry write failed: {}. NOTE: message was already delivered; do not retry.",
2346                        e
2347                    ),
2348                    12,
2349                );
2350            }
2351            emit_event(
2352                events,
2353                "agent_followup_done",
2354                &[
2355                    ("stage", "followup".into()),
2356                    ("name", name.into()),
2357                    ("provider", entry.harness_name().to_string().into()),
2358                    ("short_id", short_id.clone().into()),
2359                    ("reply_chars", (reply.chars().count() as u64).into()),
2360                    ("backend", "socket".into()),
2361                ],
2362            );
2363            AskOutcome::ok_stdout(reply)
2364        }
2365        Err(AskError::Orphan { reason, .. }) => {
2366            // Decide orphan-vs-routing-gap against the CURRENT row under the same
2367            // registry lock that stamps it, so an inside-leg report that landed
2368            // during a long ask is not missed (x-c393; codex P2). A recent report
2369            // => routing gap (status untouched, `fno agents list` still shows the
2370            // live worker); else stamp orphaned. Best-effort stamp: a write
2371            // failure stays OBSERVABLE (stderr warning + agent_status_stamp_failed
2372            // event) like Python's, not a silent swallow.
2373            let now = now_epoch_secs();
2374            // x-2681: "roster-live-inject-failed" means the control.sock fallback
2375            // delivery failed on a session that IS live in the daemon roster --
2376            // a routing gap, never a death, so it takes the same no-stamp branch
2377            // as a recent inside-leg report (a roster-live session is never
2378            // stamped orphaned).
2379            let routing_gap = matches!(
2380                reason,
2381                OrphanReason::RosterLiveInjectFailed | OrphanReason::TruthLiveInjectFailed
2382            );
2383            let mut provably_live = false;
2384            let mut stamp_warning = String::new();
2385            if let Err(e) = update_registry(registry_path, |reg| {
2386                if let Some(en) = reg.find_mut(name) {
2387                    if routing_gap || is_provably_live_report(en.inside_leg.as_ref(), now) {
2388                        provably_live = true;
2389                    } else {
2390                        en.status = AgentStatus::Orphaned;
2391                    }
2392                }
2393            }) {
2394                stamp_warning = format!(
2395                    "fno agents: warning: failed to mark {} as orphaned: {}\n",
2396                    py_repr(name),
2397                    e
2398                );
2399                emit_event(
2400                    events,
2401                    "agent_status_stamp_failed",
2402                    &[
2403                        ("name", name.into()),
2404                        ("short_id", short_id.clone().into()),
2405                        ("target_status", "orphaned".into()),
2406                        ("error", e.to_string().into()),
2407                    ],
2408                );
2409            }
2410            if provably_live {
2411                emit_event(
2412                    events,
2413                    "agent_followup_failed",
2414                    &[
2415                        ("stage", "routing-gap".into()),
2416                        ("name", name.into()),
2417                        ("short_id", short_id.clone().into()),
2418                        ("reason", reason.as_str().into()),
2419                    ],
2420                );
2421                return AskOutcome {
2422                    stdout: String::new(),
2423                    stderr: format!(
2424                        "agent {} is live but not currently routable (reason: {}); message not delivered. Try 'claude attach {}'\n",
2425                        py_repr(name),
2426                        reason.as_str(),
2427                        short_id
2428                    ),
2429                    exit_code: 13,
2430                };
2431            }
2432            emit_event(
2433                events,
2434                "agent_followup_failed",
2435                &[
2436                    ("stage", "orphan".into()),
2437                    ("name", name.into()),
2438                    ("short_id", short_id.clone().into()),
2439                    ("reason", reason.as_str().into()),
2440                ],
2441            );
2442            let hint = match reason {
2443                OrphanReason::SocketNull => format!(
2444                    ". Run 'claude attach {}' to wake the session, or 'fno agents rm {}' to remove",
2445                    short_id, name
2446                ),
2447                OrphanReason::NotFound => format!(". Run 'fno agents rm {}' to clear the stale entry", name),
2448                OrphanReason::LivenessFailed => format!(
2449                    ". Socket exists but is unresponsive; try 'claude attach {}' or 'fno agents rm {}'",
2450                    short_id, name
2451                ),
2452                // Unreachable here: RosterLiveInjectFailed always routes to the
2453                // no-stamp routing-gap branch above. Kept for exhaustiveness with
2454                // the same defensive inspect hint Python's dispatch.py `else` uses.
2455                OrphanReason::RosterLiveInjectFailed => format!(
2456                    ". Inspect with 'fno agents logs {}' or remove via 'fno agents rm {}'",
2457                    name, name
2458                ),
2459                OrphanReason::TruthLiveInjectFailed => format!(
2460                    ". Inspect with 'fno agents logs {}' before removing",
2461                    name
2462                ),
2463            };
2464            let suspended = if reason == OrphanReason::SocketNull {
2465                "; session is suspended"
2466            } else {
2467                ""
2468            };
2469            // Warning (if any) precedes the orphan error on stderr, matching
2470            // Python's two separate print() calls.
2471            AskOutcome {
2472                stdout: String::new(),
2473                stderr: format!(
2474                    "{}agent {} is not running (reason: {}{}){}\n",
2475                    stamp_warning,
2476                    py_repr(name),
2477                    reason.as_str(),
2478                    suspended,
2479                    hint
2480                ),
2481                exit_code: 13,
2482            }
2483        }
2484        Err(AskError::Socket { message }) => {
2485            emit_event(
2486                events,
2487                "agent_followup_failed",
2488                &[
2489                    ("stage", "send".into()),
2490                    ("name", name.into()),
2491                    ("short_id", short_id.clone().into()),
2492                    ("reason", "socket-error".into()),
2493                ],
2494            );
2495            AskOutcome::err(message, 1)
2496        }
2497        Err(AskError::Timeout { elapsed_sec, .. }) => {
2498            emit_event(
2499                events,
2500                "agent_followup_failed",
2501                &[
2502                    ("stage", "poll-timeout".into()),
2503                    ("name", name.into()),
2504                    ("short_id", short_id.clone().into()),
2505                    ("elapsed_sec", (elapsed_sec as u64).into()),
2506                ],
2507            );
2508            AskOutcome::err(
2509                format!(
2510                    "message sent but no reply within {}s. Try 'fno agents logs {}' to read the transcript.",
2511                    elapsed_sec as u64, name
2512                ),
2513                15,
2514            )
2515        }
2516        // ask_followup raises Orphan/Socket/Timeout, plus Io for a fatal
2517        // state.json read fault (EACCES/EROFS). Io and any other variant map
2518        // to exit 1 (Python's uncaught-OSError path also exits 1).
2519        Err(other) => AskOutcome::err(other.to_string(), 1),
2520    }
2521}
2522
2523#[allow(clippy::too_many_arguments)]
2524fn create(
2525    home: &AgentsHome,
2526    claude_home: &ClaudeHome,
2527    events: &Path,
2528    registry_path: &Path,
2529    name: &str,
2530    message: &str,
2531    _from_name: &str,
2532    cwd: &Path,
2533    yolo: bool,
2534    timeout: Option<Duration>,
2535    extra_env: &[(&str, &str)],
2536    model: Option<&str>,
2537    // x-dfa4: the already-resolved permission mode (dispatch_claude_spawn folds
2538    // --yolo -> bypassPermissions before calling); None = the claude default.
2539    permission_mode: Option<&str>,
2540    effort: Option<&str>,
2541    flags: HarnessFlags,
2542) -> AskOutcome {
2543    let pre_stderr = String::new();
2544
2545    // Python passes the raw CLI timeout (None when --timeout unset) to
2546    // bg_create, so an unset timeout means NO SIGKILL deadline on the
2547    // claude --bg create. Pass it through unchanged (don't default to 600s).
2548    let result = match bg_create(
2549        name,
2550        message,
2551        cwd,
2552        timeout,
2553        extra_env,
2554        model,
2555        permission_mode,
2556        effort,
2557        flags,
2558    ) {
2559        Ok(r) => r,
2560        Err(AskError::Subprocess { exit_code, stderr }) => {
2561            emit_event(
2562                events,
2563                "agent_ask_failed",
2564                &[
2565                    ("stage", "subprocess".into()),
2566                    ("name", name.into()),
2567                    ("provider", "claude".into()),
2568                    ("returncode", exit_code.into()),
2569                ],
2570            );
2571            // A 127 from bg_create means the `claude` binary was not on PATH
2572            // (spawn NotFound). Python checks provider availability before spawn
2573            // and exits 14 for that config error, distinct from exit 1 for a
2574            // `claude --bg` process that ran and failed (Codex P2). All other
2575            // subprocess failures collapse to exit 1, surfacing stderr.
2576            let code = if exit_code == 127 { 14 } else { 1 };
2577            return AskOutcome {
2578                stdout: String::new(),
2579                stderr: format!("{}{}\n", pre_stderr, stderr),
2580                exit_code: code,
2581            };
2582        }
2583        Err(AskError::Parse { stdout_head }) => {
2584            emit_event(
2585                events,
2586                "agent_ask_failed",
2587                &[
2588                    ("stage", "parse".into()),
2589                    ("name", name.into()),
2590                    ("provider", "claude".into()),
2591                    ("short_id_raw", stdout_head.clone().into()),
2592                ],
2593            );
2594            return AskOutcome {
2595                stdout: String::new(),
2596                stderr: format!(
2597                    "{}unable to parse short-id from claude --bg output: {}\n",
2598                    pre_stderr, stdout_head
2599                ),
2600                exit_code: 1,
2601            };
2602        }
2603        Err(other) => {
2604            return AskOutcome {
2605                stdout: String::new(),
2606                stderr: format!("{}{}\n", pre_stderr, other),
2607                exit_code: 1,
2608            };
2609        }
2610    };
2611
2612    let short_id = result.short_id.clone();
2613    // Best-effort full session-UUID capture (ab-f1b0ccd1, AC1-HP): persist the
2614    // stream-json `--resume` target alongside the 8-hex short-id so the worker
2615    // is adoptable by the live `chat` lane. Runs after the receipt is captured;
2616    // a miss leaves the field None and never gates the launch. This is the Rust
2617    // (default installed) path's parity with providers/claude.py's resolution.
2618    let session_uuid = resolve_session_uuid_at_spawn(claude_home, &short_id);
2619    let log_path = derive_log_path(home, name);
2620    let new_entry = RegistryEntry {
2621        name: name.to_string(),
2622        // v9: the claude jobId is the unified transport key (was claude_short_id);
2623        // follow-up/logs read it via `transport_short()`.
2624        short_id: short_id.clone(),
2625        legacy_provider: String::new(),
2626        // Canonical identity at birth (x-ec59); harness_session_id mirrors the
2627        // (possibly None-on-race) resolved uuid, healed later like the legacy field.
2628        harness: Some("claude".to_string()),
2629        harness_session_id: session_uuid.clone(),
2630        cwd: cwd.to_string_lossy().to_string(),
2631        project_root: String::new(),
2632        session_id: None,
2633        claude_session_uuid: session_uuid,
2634        messaging_socket_path: None,
2635        codex_session_id: None,
2636        gemini_session_id: None,
2637        mcp_channel_id: None,
2638        host_mode: None, // claude ask = exec/shellout (not an interactive host)
2639        cc_session_id: None,
2640        status: AgentStatus::Live,
2641        last_message_at: None,
2642        created_at: now_iso(),
2643        pid: None,
2644        pid_start_time: None,
2645        log_path: Some(log_path.to_string_lossy().to_string()),
2646        last_reconciled_at: None,
2647        inside_leg: None,
2648        exited_at: None,
2649        mux: None,
2650        screen_state: None,
2651        crown_level: None,
2652        crown_scope: None,
2653        crown_grantor: None,
2654        legacy_claude_short_id: None,
2655    };
2656
2657    // Re-check the name UNDER the registry lock before appending. The per-agent
2658    // flock serializes two Rust-client creates, but a concurrent daemon-PTY
2659    // create (forced-Rust run) uses a different lock domain and could insert the
2660    // same name while `claude --bg` was running. The daemon's spawn re-checks
2661    // under the registry lock; this closure must too, or we leave duplicate
2662    // rows that make later find(name) ambiguous (Codex P2). Returns Ok(true) on
2663    // append, Ok(false) on a collision.
2664    match update_registry(registry_path, |reg| {
2665        if reg.find(name).is_some() {
2666            false
2667        } else {
2668            reg.entries.push(new_entry.clone());
2669            true
2670        }
2671    }) {
2672        Ok(true) => {}
2673        Ok(false) => {
2674            emit_event(
2675                events,
2676                "agent_ask_failed",
2677                &[
2678                    ("stage", "name-collision".into()),
2679                    ("name", name.into()),
2680                    ("provider", "claude".into()),
2681                    ("short_id", short_id.clone().into()),
2682                ],
2683            );
2684            return AskOutcome {
2685                stdout: String::new(),
2686                stderr: format!(
2687                    "{}agent {} already exists (registered concurrently); orphaned supervisor session: claude rm {} (registry not updated)\n",
2688                    pre_stderr,
2689                    py_repr(name),
2690                    short_id
2691                ),
2692                exit_code: 12,
2693            };
2694        }
2695        Err(_) => {
2696            emit_event(
2697                events,
2698                "agent_ask_failed",
2699                &[
2700                    ("stage", "registry-write".into()),
2701                    ("name", name.into()),
2702                    ("provider", "claude".into()),
2703                    ("short_id", short_id.clone().into()),
2704                ],
2705            );
2706            return AskOutcome {
2707                stdout: String::new(),
2708                stderr: format!(
2709                    "{}registry write failed. orphaned supervisor session: claude rm {} (registry not updated)\n",
2710                    pre_stderr, short_id
2711                ),
2712                exit_code: 12,
2713            };
2714        }
2715    }
2716
2717    emit_event(
2718        events,
2719        "agent_ask_done",
2720        &[
2721            ("stage", "dispatch".into()),
2722            ("name", name.into()),
2723            ("provider", "claude".into()),
2724            ("short_id", short_id.clone().into()),
2725            ("duration_ms", (result.duration_ms as u64).into()),
2726            ("yolo", yolo.into()),
2727        ],
2728    );
2729    // AC1-UI: stdout is exactly `<short_id>\n`.
2730    AskOutcome {
2731        stdout: format!("{}\n", short_id),
2732        stderr: pre_stderr,
2733        exit_code: 0,
2734    }
2735}
2736
2737#[cfg(test)]
2738mod tests {
2739    use super::*;
2740    use std::fs;
2741
2742    // A spawn must NEVER hand bg_create a `None` timeout: the create wait then
2743    // falls into the unbounded `rx.recv()` arm and a `claude --bg` that holds
2744    // its inherited stdout/stderr pipe fds open hangs the dispatcher forever
2745    // (the motivating incident). spawn_create_timeout guarantees a bound.
2746    #[test]
2747    fn spawn_create_timeout_defaults_when_unset() {
2748        assert_eq!(spawn_create_timeout(None), DEFAULT_SPAWN_TIMEOUT);
2749    }
2750
2751    // An explicit --timeout still wins over the default.
2752    #[test]
2753    fn spawn_create_timeout_honors_explicit() {
2754        let explicit = Duration::from_secs(5);
2755        assert_eq!(spawn_create_timeout(Some(explicit)), explicit);
2756    }
2757
2758    // --- is_provably_live_report (x-c393) ----------------------------------
2759
2760    fn report_at(stamp: &str) -> crate::state::InsideLegReport {
2761        crate::state::InsideLegReport {
2762            state: crate::state::InsideLegState::Working,
2763            seq: 1,
2764            reason: None,
2765            received_at: stamp.into(),
2766            ttl_ms: None,
2767        }
2768    }
2769
2770    #[test]
2771    fn provably_live_true_for_recent_inside_leg_report() {
2772        // AC2-HP: a live worker (recent report) is not orphaned on a routing miss.
2773        let stamp = "2026-07-06T20:00:00Z";
2774        let now = crate::state::rfc3339_like_to_secs(stamp).unwrap() + 30;
2775        assert!(is_provably_live_report(Some(&report_at(stamp)), now));
2776    }
2777
2778    #[test]
2779    fn not_provably_live_without_inside_leg_report() {
2780        // No liveness signal -> a routing failure is a real orphan (AC2-ERR side).
2781        assert!(!is_provably_live_report(None, 9_999_999_999));
2782    }
2783
2784    #[test]
2785    fn not_provably_live_when_inside_leg_is_stale() {
2786        // A report older than the window is not a liveness signal.
2787        let stamp = "2026-07-06T20:00:00Z";
2788        let now =
2789            crate::state::rfc3339_like_to_secs(stamp).unwrap() + PROVABLY_LIVE_WINDOW_SECS + 60;
2790        assert!(!is_provably_live_report(Some(&report_at(stamp)), now));
2791    }
2792
2793    #[test]
2794    fn not_provably_live_for_future_stamp() {
2795        // codex P3: a future/corrupt stamp must not count as recent.
2796        let stamp = "2026-07-06T20:00:00Z";
2797        let now = crate::state::rfc3339_like_to_secs(stamp).unwrap() - 60;
2798        assert!(!is_provably_live_report(Some(&report_at(stamp)), now));
2799    }
2800
2801    // PR #544 deeper fix (codex P1): the create wait returns on the launch
2802    // confirmation line, not at stdout EOF. A confirmation line yields the id.
2803    #[test]
2804    fn scan_returns_short_id_on_confirmation_line() {
2805        let input = "backgrounded \u{b7} abcd1234 \u{b7} my-worker\n";
2806        match scan_stdout_for_short_id(std::io::Cursor::new(input)) {
2807            ShortIdScan::Found { short_id, .. } => assert_eq!(short_id, "abcd1234"),
2808            ShortIdScan::NoId { .. } => panic!("expected Found"),
2809        }
2810    }
2811
2812    // The scan must STOP at the confirmation line -- lines after it are never
2813    // read (in production they never arrive; the detached agent holds the pipe
2814    // open). Proven by the post-confirmation sentinel being absent from consumed.
2815    #[test]
2816    fn scan_stops_at_confirmation_does_not_drain_to_eof() {
2817        let input = "warming up\nbackgrounded \u{b7} 0011aabb \u{b7} w\nSENTINEL_AFTER_ID\n";
2818        match scan_stdout_for_short_id(std::io::Cursor::new(input)) {
2819            ShortIdScan::Found { short_id, consumed } => {
2820                assert_eq!(short_id, "0011aabb");
2821                assert!(consumed.contains("warming up"));
2822                assert!(
2823                    !consumed.contains("SENTINEL_AFTER_ID"),
2824                    "scan read past the confirmation line: {consumed:?}"
2825                );
2826            }
2827            ShortIdScan::NoId { .. } => panic!("expected Found"),
2828        }
2829    }
2830
2831    // No confirmation anywhere -> NoId at EOF; the caller then reaps the exit code
2832    // and surfaces a precise failure rather than a fabricated success.
2833    #[test]
2834    fn scan_no_confirmation_is_noid_at_eof() {
2835        let input = "error: could not start\ngiving up\n";
2836        match scan_stdout_for_short_id(std::io::Cursor::new(input)) {
2837            ShortIdScan::NoId { consumed } => assert!(consumed.contains("giving up")),
2838            ShortIdScan::Found { .. } => panic!("expected NoId"),
2839        }
2840    }
2841
2842    // A colorized confirmation line (claude wraps the id in ANSI when stdout is
2843    // colorized) still matches -- the scan reuses match_short_id's ANSI strip.
2844    #[test]
2845    fn scan_matches_colorized_confirmation_line() {
2846        let input = "backgrounded \u{b7} \u{1b}[36mdeadbeef\u{1b}[39m \u{b7} w\n";
2847        match scan_stdout_for_short_id(std::io::Cursor::new(input)) {
2848            ShortIdScan::Found { short_id, .. } => assert_eq!(short_id, "deadbeef"),
2849            ShortIdScan::NoId { .. } => panic!("expected Found on colorized line"),
2850        }
2851    }
2852
2853    // READINESS HANDSHAKE (read before adding a socket/timing test here)
2854    // ------------------------------------------------------------------
2855    // These tests stand up a real AF_UNIX listener and a polled state.json to
2856    // drive ask_followup / wait_for_reply without a live `claude --bg` daemon.
2857    // They run multi-threaded under the default `cargo test` harness and have
2858    // to stay green even while a concurrent `cargo build` saturates every core.
2859    //
2860    // The rule: NEVER use a fixed `std::thread::sleep(...)` as a "the other side
2861    // is ready now" barrier. A sleep sized for an idle box (e.g. 60ms) becomes a
2862    // coin flip under load because wall-clock stretches relative to scheduled
2863    // work, so the awaited write/accept/join can miss the budget. That is the
2864    // exact flake this module was de-flaked to remove.
2865    //
2866    // Use an OBSERVED readiness signal instead, in order of preference:
2867    //  1. Restructure so the awaited state is already on disk before the call
2868    //     (wait_for_reply checks state before it sleeps, so a pre-written
2869    //     terminal state returns on the first poll iteration). Deterministic.
2870    //  2. Drive the state transition off RECEIVED bytes (the socket accept
2871    //     thread reacts to the send, not a timer) and join the thread to
2872    //     synchronize. Deterministic on the send side.
2873    //  3. Only as a last resort, widen a TEST-LOCAL poll/deadline budget as
2874    //     defense-in-depth -- generous enough to absorb scheduling latency,
2875    //     bounded enough that a real hang still fails in seconds (never
2876    //     minutes). Never widen production constants.
2877
2878    fn tmpdir() -> PathBuf {
2879        // Unique per call BY CONSTRUCTION. The pid is identical for every test
2880        // in a single test binary, and `as_nanos()` is only as fine-grained as
2881        // the OS clock -- on a coarse clock under parallel load two tests can
2882        // read the SAME nanos and collide on this path, mixing their session
2883        // files into one dir. That collision was an observed load-flake: a
2884        // locate_session test would read another test's socket path. The
2885        // process-wide atomic sequence makes the path collision-proof regardless
2886        // of clock resolution or scheduling, so every socket/timing test gets an
2887        // isolated tree even under a saturating concurrent build.
2888        use std::sync::atomic::{AtomicU64, Ordering};
2889        static SEQ: AtomicU64 = AtomicU64::new(0);
2890        let seq = SEQ.fetch_add(1, Ordering::Relaxed);
2891        let p = std::env::temp_dir().join(format!(
2892            "fno-claude-ask-{}-{}-{}",
2893            std::process::id(),
2894            std::time::SystemTime::now()
2895                .duration_since(std::time::UNIX_EPOCH)
2896                .unwrap()
2897                .as_nanos(),
2898            seq
2899        ));
2900        fs::create_dir_all(&p).unwrap();
2901        p
2902    }
2903
2904    // --- parse_short_id ---
2905
2906    #[test]
2907    fn parse_short_id_happy() {
2908        let out = "backgrounded \u{b7} 7c5dcf5d \u{b7} alice\n";
2909        assert_eq!(parse_short_id(out).unwrap(), "7c5dcf5d");
2910    }
2911
2912    #[test]
2913    fn parse_short_id_only_first_line() {
2914        let out = "backgrounded \u{b7} 7c5dcf5d \u{b7} alice\nextra garbage\n";
2915        assert_eq!(parse_short_id(out).unwrap(), "7c5dcf5d");
2916    }
2917
2918    #[test]
2919    fn parse_short_id_empty_is_error() {
2920        assert!(matches!(parse_short_id(""), Err(AskError::Parse { .. })));
2921    }
2922
2923    #[test]
2924    fn parse_short_id_strips_ansi_color() {
2925        // Regression: the installed `claude --bg` wraps the short-id in SGR
2926        // color codes (`backgrounded · \x1b[36m<id>\x1b[39m · <name>`), which
2927        // the matcher used to reject (leading ESC is not a hexdigit). The id
2928        // must survive the colorization.
2929        let out = "backgrounded \u{b7} \u{1b}[36m441064a2\u{1b}[39m \u{b7} fnogates\n";
2930        assert_eq!(parse_short_id(out).unwrap(), "441064a2");
2931    }
2932
2933    #[test]
2934    fn parse_short_id_strips_truecolor_sgr() {
2935        // Truecolor SGR uses `;`-separated params (0x3B); the CSI stripper must
2936        // consume the whole parameter run, not just a single byte.
2937        let out = "backgrounded \u{b7} \u{1b}[38;2;215;119;87m7c5dcf5d\u{1b}[0m \u{b7} alice";
2938        assert_eq!(parse_short_id(out).unwrap(), "7c5dcf5d");
2939    }
2940
2941    #[test]
2942    fn strip_ansi_csi_borrows_when_no_escape() {
2943        // Common path (no ESC) is zero-copy (gemini PR #403 review); a line
2944        // carrying CSI codes allocates and drops the escapes.
2945        assert!(matches!(
2946            strip_ansi_csi("backgrounded \u{b7} 7c5dcf5d \u{b7} alice"),
2947            std::borrow::Cow::Borrowed(_)
2948        ));
2949        let owned = strip_ansi_csi("a\u{1b}[31mb\u{1b}[0mc");
2950        assert!(matches!(owned, std::borrow::Cow::Owned(_)));
2951        assert_eq!(owned.as_ref(), "abc");
2952    }
2953
2954    #[test]
2955    fn parse_short_id_no_panic_on_non_ascii_at_byte_8() {
2956        // Codex P2: a multibyte char straddling byte 8 used to panic split_at(8).
2957        // 'é' (2 bytes) at rest-bytes 7-8 makes byte 8 a non-char-boundary.
2958        let out = "backgrounded \u{b7} 1234567\u{e9} \u{b7} x";
2959        // Must return Err, not panic.
2960        assert!(parse_short_id(out).is_err());
2961    }
2962
2963    #[test]
2964    fn parse_short_id_rejects_non_hex_and_uppercase() {
2965        assert!(parse_short_id("backgrounded \u{b7} 7C5DCF5D \u{b7} a").is_err());
2966        assert!(parse_short_id("backgrounded \u{b7} zzzzzzzz \u{b7} a").is_err());
2967        assert!(parse_short_id("nope \u{b7} 7c5dcf5d \u{b7} a").is_err());
2968        assert!(parse_short_id("backgrounded \u{b7} 7c5dcf5d done").is_err());
2969    }
2970
2971    // --- build_argv / use_stdin_for ---
2972
2973    #[test]
2974    fn build_argv_inline_vs_stdin() {
2975        assert_eq!(
2976            build_argv("a", "hi", false, None, None, None, HarnessFlags::default()),
2977            vec!["claude", "--bg", "--name", "a", "hi"]
2978        );
2979        assert_eq!(
2980            build_argv("a", "hi", true, None, None, None, HarnessFlags::default()),
2981            vec!["claude", "--bg", "--name", "a"]
2982        );
2983    }
2984
2985    // x-dfa4: an explicit --permission-mode rides between --name and --model as
2986    // an exact passthrough; empty/None is byte-identical to today (AC1-HP/AC7).
2987    #[test]
2988    fn build_argv_appends_permission_mode() {
2989        assert_eq!(
2990            build_argv(
2991                "a",
2992                "hi",
2993                false,
2994                None,
2995                Some("acceptEdits"),
2996                None,
2997                HarnessFlags::default()
2998            ),
2999            vec![
3000                "claude",
3001                "--bg",
3002                "--name",
3003                "a",
3004                "--permission-mode",
3005                "acceptEdits",
3006                "hi"
3007            ]
3008        );
3009        // Empty mode == unset: no flag, byte-identical to the None case (AC7).
3010        assert_eq!(
3011            build_argv(
3012                "a",
3013                "hi",
3014                false,
3015                None,
3016                Some(""),
3017                None,
3018                HarnessFlags::default()
3019            ),
3020            build_argv("a", "hi", false, None, None, None, HarnessFlags::default())
3021        );
3022        // Does NOT stack with --dangerously-skip-permissions (bg never had it).
3023        assert!(!build_argv(
3024            "a",
3025            "hi",
3026            false,
3027            None,
3028            Some("acceptEdits"),
3029            None,
3030            HarnessFlags::default()
3031        )
3032        .iter()
3033        .any(|t| t == "--dangerously-skip-permissions"));
3034    }
3035
3036    // x-571f: a per-node model pin appends `--model <m>` between --name and the
3037    // message; an empty/None pin is byte-identical to today (AC1-EDGE), and the
3038    // argv must match Python's `_build_argv` (AC2-FR parity).
3039    #[test]
3040    fn build_argv_appends_model_pin() {
3041        assert_eq!(
3042            build_argv(
3043                "a",
3044                "hi",
3045                false,
3046                Some("fable"),
3047                None,
3048                None,
3049                HarnessFlags::default()
3050            ),
3051            vec!["claude", "--bg", "--name", "a", "--model", "fable", "hi"]
3052        );
3053        assert_eq!(
3054            build_argv(
3055                "a",
3056                "hi",
3057                true,
3058                Some("fable"),
3059                None,
3060                None,
3061                HarnessFlags::default()
3062            ),
3063            vec!["claude", "--bg", "--name", "a", "--model", "fable"]
3064        );
3065        // Empty pin == unset: no flag, byte-identical to the None case.
3066        assert_eq!(
3067            build_argv(
3068                "a",
3069                "hi",
3070                false,
3071                Some(""),
3072                None,
3073                None,
3074                HarnessFlags::default()
3075            ),
3076            build_argv("a", "hi", false, None, None, None, HarnessFlags::default())
3077        );
3078    }
3079
3080    #[test]
3081    fn build_argv_appends_effort() {
3082        assert_eq!(
3083            build_argv(
3084                "a",
3085                "hi",
3086                false,
3087                None,
3088                None,
3089                Some("high"),
3090                HarnessFlags::default()
3091            ),
3092            vec!["claude", "--bg", "--name", "a", "--effort", "high", "hi"]
3093        );
3094    }
3095
3096    // x-b6e2: the Tier-3 passthrough bundle maps to claude's own spellings, in a
3097    // fixed order (--add-dir, --agent, --allowedTools, --disallowedTools), riding
3098    // after --effort and before the message. Empty/None fields are omitted. This
3099    // token order must match the Python _build_argv (AC2-EDGE parity).
3100    #[test]
3101    fn build_argv_appends_harness_flags() {
3102        let flags = HarnessFlags {
3103            add_dir: Some("/work"),
3104            agent: Some("reviewer"),
3105            allowed_tools: Some("Read,Edit"),
3106            disallowed_tools: Some("Bash"),
3107        };
3108        assert_eq!(
3109            build_argv("a", "hi", false, None, None, None, flags),
3110            vec![
3111                "claude",
3112                "--bg",
3113                "--name",
3114                "a",
3115                "--add-dir",
3116                "/work",
3117                "--agent",
3118                "reviewer",
3119                "--allowedTools",
3120                "Read,Edit",
3121                "--disallowedTools",
3122                "Bash",
3123                "hi"
3124            ]
3125        );
3126        // A partially-filled bundle emits only the set fields (empty == unset).
3127        let only_dir = HarnessFlags {
3128            add_dir: Some("/work"),
3129            allowed_tools: Some(""),
3130            ..Default::default()
3131        };
3132        assert_eq!(
3133            build_argv("a", "hi", false, None, None, None, only_dir),
3134            vec!["claude", "--bg", "--name", "a", "--add-dir", "/work", "hi"]
3135        );
3136    }
3137
3138    #[test]
3139    fn use_stdin_threshold() {
3140        assert!(!use_stdin_for(&"x".repeat(ARGV_OVERFLOW_THRESHOLD)));
3141        assert!(use_stdin_for(&"x".repeat(ARGV_OVERFLOW_THRESHOLD + 1)));
3142    }
3143
3144    // --- envelope byte-parity ---
3145
3146    #[test]
3147    fn envelope_exact_bytes_ascii() {
3148        let env = build_envelope("hello", "bob");
3149        let expected = "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"<cross-session-message from-name=\\\"bob\\\">\\nhello\\n</cross-session-message>\"},\"priority\":\"next\"}\n";
3150        assert_eq!(String::from_utf8(env).unwrap(), expected);
3151    }
3152
3153    #[test]
3154    fn envelope_escapes_from_name_html() {
3155        let env = build_envelope("hi", "a&b<c>\"d'e");
3156        let s = String::from_utf8(env).unwrap();
3157        assert!(
3158            s.contains("from-name=\\\"a&amp;b&lt;c&gt;&quot;d&#x27;e\\\""),
3159            "{}",
3160            s
3161        );
3162    }
3163
3164    #[test]
3165    fn envelope_ensure_ascii_non_ascii() {
3166        // café -> café in the JSON string, matching Python ensure_ascii.
3167        let env = build_envelope("caf\u{e9}", "x");
3168        let s = String::from_utf8(env).unwrap();
3169        assert!(s.contains("caf\\u00e9"), "{}", s);
3170        assert!(!s.contains('\u{e9}'), "raw non-ascii leaked: {}", s);
3171    }
3172
3173    #[test]
3174    fn envelope_astral_surrogate_pair() {
3175        // U+1F600 grinning face
3176        let env = build_envelope("\u{1F600}", "x");
3177        let s = String::from_utf8(env).unwrap();
3178        assert!(s.contains("\\ud83d\\ude00"), "{}", s);
3179    }
3180
3181    #[test]
3182    fn json_string_escapes_control_chars() {
3183        assert_eq!(json_string_ascii("a\nb\tc"), "\"a\\nb\\tc\"");
3184        assert_eq!(json_string_ascii("\u{01}"), "\"\\u0001\"");
3185        assert_eq!(json_string_ascii("a\"b\\c"), "\"a\\\"b\\\\c\"");
3186    }
3187
3188    // --- locate_session ---
3189
3190    fn write_session(dir: &Path, pid: &str, job: &str, kind: &str, sock: Option<&str>) {
3191        let sock_field = match sock {
3192            Some(s) => format!("\"{}\"", s),
3193            None => "null".to_string(),
3194        };
3195        let body = format!(
3196            "{{\"jobId\":\"{}\",\"kind\":\"{}\",\"messagingSocketPath\":{},\"sessionId\":\"sess-{}\",\"cwd\":\"/tmp\"}}",
3197            job, kind, sock_field, job
3198        );
3199        fs::write(dir.join(format!("{}.json", pid)), body).unwrap();
3200    }
3201
3202    #[test]
3203    fn locate_session_happy() {
3204        let home = tmpdir();
3205        let sessions = home.join(".claude").join("sessions");
3206        fs::create_dir_all(&sessions).unwrap();
3207        write_session(&sessions, "111", "7c5dcf5d", "bg", Some("/tmp/sock1"));
3208        let ch = ClaudeHome::at(&home);
3209        let loc = locate_session(&ch, "7c5dcf5d").unwrap();
3210        assert_eq!(loc.pid, 111);
3211        assert_eq!(loc.messaging_socket_path, "/tmp/sock1");
3212        assert_eq!(loc.session_id.as_deref(), Some("sess-7c5dcf5d"));
3213        assert_eq!(
3214            loc.jobs_dir,
3215            home.join(".claude").join("jobs").join("7c5dcf5d")
3216        );
3217    }
3218
3219    #[test]
3220    fn locate_session_skips_null_socket_prefers_live() {
3221        let home = tmpdir();
3222        let sessions = home.join(".claude").join("sessions");
3223        fs::create_dir_all(&sessions).unwrap();
3224        // dead pid with null socket sorts before the live one
3225        write_session(&sessions, "100", "abcd1234", "bg", None);
3226        write_session(&sessions, "200", "abcd1234", "bg", Some("/tmp/live"));
3227        let ch = ClaudeHome::at(&home);
3228        let loc = locate_session(&ch, "abcd1234").unwrap();
3229        assert_eq!(loc.messaging_socket_path, "/tmp/live");
3230        assert_eq!(loc.pid, 200);
3231    }
3232
3233    #[test]
3234    fn locate_session_not_found_and_classify() {
3235        let home = tmpdir();
3236        let sessions = home.join(".claude").join("sessions");
3237        fs::create_dir_all(&sessions).unwrap();
3238        write_session(&sessions, "100", "abcd1234", "bg", None);
3239        let ch = ClaudeHome::at(&home);
3240        assert!(locate_session(&ch, "abcd1234").is_none());
3241        assert_eq!(
3242            classify_orphan_reason(&ch, "abcd1234"),
3243            OrphanReason::SocketNull
3244        );
3245        assert_eq!(
3246            classify_orphan_reason(&ch, "ffffffff"),
3247            OrphanReason::NotFound
3248        );
3249    }
3250
3251    #[test]
3252    fn locate_session_skips_corrupt_and_non_bg() {
3253        let home = tmpdir();
3254        let sessions = home.join(".claude").join("sessions");
3255        fs::create_dir_all(&sessions).unwrap();
3256        fs::write(sessions.join("1.json"), "{not json").unwrap();
3257        write_session(&sessions, "2", "abcd1234", "interactive", Some("/tmp/x"));
3258        write_session(&sessions, "3", "abcd1234", "bg", Some("/tmp/good"));
3259        let ch = ClaudeHome::at(&home);
3260        let loc = locate_session(&ch, "abcd1234").unwrap();
3261        assert_eq!(loc.messaging_socket_path, "/tmp/good");
3262    }
3263
3264    #[test]
3265    fn locate_session_missing_dir_is_none() {
3266        let home = tmpdir();
3267        let ch = ClaudeHome::at(&home);
3268        assert!(locate_session(&ch, "abcd1234").is_none());
3269    }
3270
3271    // --- resolve_session_uuid / resolve_session_uuid_at_spawn (ab-f1b0ccd1) ---
3272
3273    #[test]
3274    fn resolve_session_uuid_resolves_idle_bg() {
3275        // Unlike locate_session, resolution does NOT require a live socket: an
3276        // idle (socket-null) bg session is exactly the resume target.
3277        let home = tmpdir();
3278        let sessions = home.join(".claude").join("sessions");
3279        fs::create_dir_all(&sessions).unwrap();
3280        write_session(&sessions, "111", "7c5dcf5d", "bg", None); // null socket
3281        let ch = ClaudeHome::at(&home);
3282        assert!(locate_session(&ch, "7c5dcf5d").is_none()); // socket-null: locate misses
3283        assert_eq!(
3284            resolve_session_uuid(&ch, "7c5dcf5d").as_deref(),
3285            Some("sess-7c5dcf5d") // ... but resolve still returns the sessionId
3286        );
3287    }
3288
3289    #[test]
3290    fn resolve_session_uuid_skips_non_bg_and_unmatched() {
3291        let home = tmpdir();
3292        let sessions = home.join(".claude").join("sessions");
3293        fs::create_dir_all(&sessions).unwrap();
3294        write_session(&sessions, "1", "7c5dcf5d", "interactive", Some("/tmp/x")); // wrong kind
3295        write_session(&sessions, "2", "deadbeef", "bg", Some("/tmp/y")); // wrong jobId
3296        let ch = ClaudeHome::at(&home);
3297        assert!(resolve_session_uuid(&ch, "7c5dcf5d").is_none());
3298        assert!(resolve_session_uuid(&ch, "ffffffff").is_none());
3299    }
3300
3301    #[test]
3302    fn resolve_session_uuid_missing_dir_is_none() {
3303        let home = tmpdir();
3304        let ch = ClaudeHome::at(&home);
3305        assert!(resolve_session_uuid(&ch, "7c5dcf5d").is_none());
3306    }
3307
3308    #[test]
3309    fn resolve_at_spawn_happy_empty_and_missing() {
3310        let home = tmpdir();
3311        let sessions = home.join(".claude").join("sessions");
3312        fs::create_dir_all(&sessions).unwrap();
3313        write_session(&sessions, "111", "7c5dcf5d", "bg", Some("/tmp/sock"));
3314        let ch = ClaudeHome::at(&home);
3315        // happy: first probe hits, no sleep
3316        assert_eq!(
3317            resolve_session_uuid_at_spawn(&ch, "7c5dcf5d").as_deref(),
3318            Some("sess-7c5dcf5d")
3319        );
3320        // empty short-id short-circuits (no probe)
3321        assert!(resolve_session_uuid_at_spawn(&ch, "").is_none());
3322        // absent sessions dir short-circuits without retrying (no sleep)
3323        let empty = tmpdir();
3324        let empty_ch = ClaudeHome::at(&empty);
3325        assert!(resolve_session_uuid_at_spawn(&empty_ch, "7c5dcf5d").is_none());
3326    }
3327
3328    // --- read_state_json ---
3329
3330    #[test]
3331    fn read_state_json_parses_fields() {
3332        let jobs = tmpdir();
3333        fs::write(
3334            jobs.join("state.json"),
3335            r#"{"state":"completed","updatedAt":"2026-05-27T10:00:00Z","output":{"result":"PONG"},"intent":"reply"}"#,
3336        )
3337        .unwrap();
3338        let snap = read_state_json(&jobs).unwrap();
3339        assert_eq!(snap.state, "completed");
3340        assert_eq!(snap.updated_at.as_deref(), Some("2026-05-27T10:00:00Z"));
3341        assert_eq!(snap.output_result.as_deref(), Some("PONG"));
3342    }
3343
3344    #[test]
3345    fn read_state_json_missing_is_notfound() {
3346        let jobs = tmpdir();
3347        assert!(matches!(
3348            read_state_json(&jobs),
3349            Err(StateReadError::NotFound)
3350        ));
3351    }
3352
3353    #[test]
3354    fn read_state_json_empty_is_parse_err() {
3355        let jobs = tmpdir();
3356        fs::write(jobs.join("state.json"), "   ").unwrap();
3357        assert!(matches!(read_state_json(&jobs), Err(StateReadError::Parse)));
3358    }
3359
3360    #[test]
3361    fn read_state_json_output_not_dict() {
3362        let jobs = tmpdir();
3363        fs::write(jobs.join("state.json"), r#"{"state":"done","output":null}"#).unwrap();
3364        let snap = read_state_json(&jobs).unwrap();
3365        assert_eq!(snap.output_result, None);
3366    }
3367
3368    // --- read_timeline_tail ---
3369
3370    #[test]
3371    fn timeline_tail_concats_terminal_text_from_offset() {
3372        let jobs = tmpdir();
3373        let tl = jobs.join("timeline.jsonl");
3374        // pre-baseline content that must be ignored
3375        fs::write(&tl, "{\"state\":\"completed\",\"text\":\"OLD\"}\n").unwrap();
3376        let offset = timeline_offset(&jobs);
3377        // appended after baseline
3378        let mut f = fs::OpenOptions::new().append(true).open(&tl).unwrap();
3379        writeln!(f, "{{\"state\":\"running\",\"text\":\"tool call\"}}").unwrap();
3380        writeln!(f, "{{\"state\":\"completed\",\"text\":\"AB\"}}").unwrap();
3381        writeln!(f, "{{\"state\":\"done\",\"text\":\"CD\"}}").unwrap();
3382        writeln!(f, "not json").unwrap();
3383        assert_eq!(read_timeline_tail(&jobs, offset), "ABCD");
3384    }
3385
3386    #[test]
3387    fn timeline_tail_missing_is_empty() {
3388        let jobs = tmpdir();
3389        assert_eq!(read_timeline_tail(&jobs, 0), "");
3390    }
3391
3392    // --- socket round-trip ---
3393
3394    #[test]
3395    fn send_to_session_delivers_envelope_bytes() {
3396        use std::os::unix::net::UnixListener;
3397        let dir = tmpdir();
3398        let sock = dir.join("s.sock");
3399        let listener = UnixListener::bind(&sock).unwrap();
3400        let sock_str = sock.to_str().unwrap().to_string();
3401        let handle = std::thread::spawn(move || {
3402            let (mut conn, _) = listener.accept().unwrap();
3403            let mut buf = Vec::new();
3404            conn.read_to_end(&mut buf).unwrap();
3405            buf
3406        });
3407        send_to_session(&sock_str, "ping", "tester").unwrap();
3408        let got = handle.join().unwrap();
3409        assert_eq!(got, build_envelope("ping", "tester"));
3410    }
3411
3412    #[test]
3413    fn liveness_probe_true_when_listening_false_when_absent() {
3414        use std::os::unix::net::UnixListener;
3415        let dir = tmpdir();
3416        let sock = dir.join("live.sock");
3417        let _listener = UnixListener::bind(&sock).unwrap();
3418        assert!(liveness_probe(sock.to_str().unwrap()));
3419        assert!(!liveness_probe(dir.join("absent.sock").to_str().unwrap()));
3420    }
3421
3422    #[test]
3423    fn send_to_session_errors_on_missing_socket() {
3424        let dir = tmpdir();
3425        let res = send_to_session(dir.join("nope.sock").to_str().unwrap(), "x", "y");
3426        assert!(matches!(res, Err(AskError::Socket { .. })));
3427    }
3428
3429    // --- wait_for_reply ---
3430
3431    fn write_state(jobs: &Path, state: &str, updated: &str, result: Option<&str>) {
3432        let res = match result {
3433            Some(r) => format!(",\"output\":{{\"result\":{}}}", json_string_ascii(r)),
3434            None => String::new(),
3435        };
3436        fs::write(
3437            jobs.join("state.json"),
3438            format!(
3439                "{{\"state\":\"{}\",\"updatedAt\":\"{}\"{}}}",
3440                state, updated, res
3441            ),
3442        )
3443        .unwrap();
3444    }
3445
3446    #[test]
3447    fn wait_for_reply_prefers_output_result() {
3448        let jobs = tmpdir();
3449        write_state(&jobs, "completed", "2026-05-27T10:00:01Z", Some("PONG"));
3450        let r = wait_for_reply(
3451            &jobs,
3452            Some("2026-05-27T10:00:00Z"),
3453            0,
3454            Duration::from_secs(2),
3455            Duration::from_millis(10),
3456            "sid",
3457        )
3458        .unwrap();
3459        assert_eq!(r, "PONG");
3460    }
3461
3462    #[test]
3463    fn wait_for_reply_baseline_invariant_then_advance() {
3464        // Deterministic by construction: no fixed-sleep barrier, no writer
3465        // thread racing a poll deadline (see READINESS HANDSHAKE note at the top
3466        // of this module). The old version spawned a thread that slept 60ms then
3467        // wrote the advance; under CPU saturation that 60ms-then-scheduled write
3468        // could miss the budget. We instead assert the two properties the test
3469        // name promises in sequence:
3470        let jobs = tmpdir();
3471
3472        // (1) baseline invariant: a terminal state whose updatedAt EQUALS the
3473        // baseline has NOT advanced, so wait_for_reply must not return it. With
3474        // nothing ever advancing it, the call deterministically times out within
3475        // a short bounded budget (the outcome is the error *type*, independent of
3476        // wall-clock under load).
3477        write_state(&jobs, "completed", "2026-05-27T10:00:00Z", Some("STALE"));
3478        let r = wait_for_reply(
3479            &jobs,
3480            Some("2026-05-27T10:00:00Z"),
3481            0,
3482            Duration::from_millis(200),
3483            Duration::from_millis(10),
3484            "sid",
3485        );
3486        assert!(
3487            matches!(r, Err(AskError::Timeout { .. })),
3488            "stale state equal to baseline must not satisfy wait_for_reply; got {:?}",
3489            r
3490        );
3491
3492        // (2) then advance: once updatedAt moves past the baseline, the state is
3493        // already terminal+advanced on disk before the call, so wait_for_reply
3494        // returns it on the FIRST poll iteration without sleeping a poll_interval
3495        // (the loop checks state before it sleeps). This is the zero-delay reply
3496        // path (AC1-EDGE) and is load-proof: the value is present before we poll.
3497        write_state(&jobs, "completed", "2026-05-27T10:00:05Z", Some("FRESH"));
3498        let r = wait_for_reply(
3499            &jobs,
3500            Some("2026-05-27T10:00:00Z"),
3501            0,
3502            Duration::from_secs(2),
3503            Duration::from_millis(10),
3504            "sid",
3505        )
3506        .unwrap();
3507        assert_eq!(r, "FRESH");
3508    }
3509
3510    #[test]
3511    fn wait_for_reply_falls_back_to_timeline_when_result_empty() {
3512        let jobs = tmpdir();
3513        let offset = timeline_offset(&jobs); // 0, no file yet
3514        write_state(&jobs, "done", "2026-05-27T10:00:01Z", None);
3515        fs::write(
3516            jobs.join("timeline.jsonl"),
3517            "{\"state\":\"done\",\"text\":\"TAIL\"}\n",
3518        )
3519        .unwrap();
3520        let r = wait_for_reply(
3521            &jobs,
3522            None,
3523            offset,
3524            Duration::from_secs(2),
3525            Duration::from_millis(10),
3526            "sid",
3527        )
3528        .unwrap();
3529        assert_eq!(r, "TAIL");
3530    }
3531
3532    #[test]
3533    fn read_state_json_eacces_is_fatal_io_not_transient() {
3534        // EACCES must surface as Io (fatal), not be masked as a transient Parse
3535        // that the poll loop spins on (Python lets the OSError propagate).
3536        // Skip as root (root bypasses permission bits).
3537        if unsafe { libc::geteuid() } == 0 {
3538            eprintln!("SKIP: running as root; permission bits not enforced");
3539            return;
3540        }
3541        use std::os::unix::fs::PermissionsExt;
3542        let jobs = tmpdir();
3543        let sp = jobs.join("state.json");
3544        fs::write(&sp, r#"{"state":"done","updatedAt":"t"}"#).unwrap();
3545        fs::set_permissions(&sp, fs::Permissions::from_mode(0o000)).unwrap();
3546        let got = read_state_json(&jobs);
3547        // restore so tmpdir cleanup is unhindered
3548        let _ = fs::set_permissions(&sp, fs::Permissions::from_mode(0o644));
3549        assert!(
3550            matches!(got, Err(StateReadError::Io(_))),
3551            "expected Io, got {:?}",
3552            got
3553        );
3554
3555        // and wait_for_reply turns it into a fatal AskError::Io, not a 600s spin
3556        fs::set_permissions(&sp, fs::Permissions::from_mode(0o000)).unwrap();
3557        let r = wait_for_reply(
3558            &jobs,
3559            None,
3560            0,
3561            Duration::from_secs(30),
3562            Duration::from_millis(10),
3563            "sid",
3564        );
3565        let _ = fs::set_permissions(&sp, fs::Permissions::from_mode(0o644));
3566        assert!(
3567            matches!(r, Err(AskError::Io { .. })),
3568            "expected fatal Io, got {:?}",
3569            r
3570        );
3571    }
3572
3573    #[test]
3574    fn wait_for_reply_times_out() {
3575        let jobs = tmpdir();
3576        write_state(&jobs, "running", "2026-05-27T10:00:00Z", None);
3577        let r = wait_for_reply(
3578            &jobs,
3579            None,
3580            0,
3581            Duration::from_millis(40),
3582            Duration::from_millis(10),
3583            "sid",
3584        );
3585        assert!(matches!(r, Err(AskError::Timeout { .. })));
3586    }
3587
3588    // --- ask_followup (live socket + state.json) ---
3589
3590    #[test]
3591    fn ask_followup_socket_to_reply() {
3592        use std::os::unix::net::UnixListener;
3593        let home = tmpdir();
3594        let sessions = home.join(".claude").join("sessions");
3595        let jobs = home.join(".claude").join("jobs").join("abcd1234");
3596        fs::create_dir_all(&sessions).unwrap();
3597        fs::create_dir_all(&jobs).unwrap();
3598        let sock = home.join("msg.sock");
3599        let listener = UnixListener::bind(&sock).unwrap();
3600        write_session(
3601            &sessions,
3602            "999",
3603            "abcd1234",
3604            "bg",
3605            Some(sock.to_str().unwrap()),
3606        );
3607
3608        let jobs_for_thread = jobs.clone();
3609        let handle = std::thread::spawn(move || {
3610            // ask_followup connects twice: a liveness probe (no bytes) then the
3611            // send. Accept until we get the connection carrying the envelope.
3612            loop {
3613                let (mut conn, _) = listener.accept().unwrap();
3614                let mut buf = Vec::new();
3615                let _ = conn.read_to_end(&mut buf);
3616                if buf.is_empty() {
3617                    continue; // liveness probe; wait for the real send
3618                }
3619                write_state(
3620                    &jobs_for_thread,
3621                    "completed",
3622                    "2026-05-27T10:00:09Z",
3623                    Some("REPLY!"),
3624                );
3625                break buf;
3626            }
3627        });
3628
3629        let ch = ClaudeHome::at(&home);
3630        // Readiness model (no fixed-sleep barrier; see the note at the top of
3631        // this module):
3632        //  - The listener is bound BEFORE ask_followup runs, so the client's
3633        //    liveness probe and send connect into a ready accept backlog; a
3634        //    transient refused connect is not possible here (AC1-ERR holds by
3635        //    construction, not by a retry sleep).
3636        //  - The accept thread writes the terminal state.json in reaction to the
3637        //    RECEIVED send bytes (a real handshake), not a timer.
3638        //  - The only cross-thread observation is wait_for_reply polling for that
3639        //    state write. We give it a generous-but-bounded budget (10s) so a
3640        //    late-scheduled accept thread under a saturating `cargo build` is
3641        //    still observed, while a genuine hang still fails in seconds, not
3642        //    minutes (AC3-ERR). This budget is test-local; production constants
3643        //    are unchanged.
3644        let reply = ask_followup(
3645            &ch,
3646            "abcd1234",
3647            "ping",
3648            "tester",
3649            Duration::from_secs(10),
3650            Duration::from_millis(10),
3651            None,
3652        )
3653        .unwrap();
3654        let envelope = handle.join().unwrap();
3655        assert_eq!(reply, "REPLY!");
3656        assert_eq!(envelope, build_envelope("ping", "tester"));
3657    }
3658
3659    #[test]
3660    fn ask_followup_orphan_socket_null() {
3661        let home = tmpdir();
3662        let sessions = home.join(".claude").join("sessions");
3663        fs::create_dir_all(&sessions).unwrap();
3664        write_session(&sessions, "1", "abcd1234", "bg", None);
3665        let ch = ClaudeHome::at(&home);
3666        let err = ask_followup(
3667            &ch,
3668            "abcd1234",
3669            "x",
3670            "y",
3671            Duration::from_secs(1),
3672            Duration::from_millis(10),
3673            None,
3674        )
3675        .unwrap_err();
3676        match err {
3677            AskError::Orphan { reason, .. } => {
3678                assert_eq!(reason, OrphanReason::TruthLiveInjectFailed)
3679            }
3680            other => panic!("expected orphan, got {:?}", other),
3681        }
3682    }
3683
3684    #[test]
3685    fn ask_followup_orphan_not_found() {
3686        let home = tmpdir();
3687        fs::create_dir_all(home.join(".claude").join("sessions")).unwrap();
3688        let ch = ClaudeHome::at(&home);
3689        let err = ask_followup(
3690            &ch,
3691            "ffffffff",
3692            "x",
3693            "y",
3694            Duration::from_secs(1),
3695            Duration::from_millis(10),
3696            None,
3697        )
3698        .unwrap_err();
3699        assert!(matches!(
3700            err,
3701            AskError::Orphan {
3702                reason: OrphanReason::TruthLiveInjectFailed,
3703                ..
3704            }
3705        ));
3706    }
3707
3708    #[test]
3709    fn ask_followup_liveness_failed_when_socket_dead() {
3710        let home = tmpdir();
3711        let sessions = home.join(".claude").join("sessions");
3712        fs::create_dir_all(&sessions).unwrap();
3713        // points at a socket path that has no listener
3714        write_session(
3715            &sessions,
3716            "1",
3717            "abcd1234",
3718            "bg",
3719            Some(home.join("dead.sock").to_str().unwrap()),
3720        );
3721        let ch = ClaudeHome::at(&home);
3722        let err = ask_followup(
3723            &ch,
3724            "abcd1234",
3725            "x",
3726            "y",
3727            Duration::from_secs(1),
3728            Duration::from_millis(10),
3729            None,
3730        )
3731        .unwrap_err();
3732        assert!(matches!(
3733            err,
3734            AskError::Orphan {
3735                reason: OrphanReason::TruthLiveInjectFailed,
3736                ..
3737            }
3738        ));
3739    }
3740
3741    // --- x-2681 ask-lane control.sock fallback ---
3742
3743    // daemon_roster_path reads FNO_CLAUDE_DAEMON_DIR, a process-global. Serialize
3744    // the env-touching tests below (cargo runs tests in parallel threads; no
3745    // serial_test dep in this crate) so they never observe each other's mutation.
3746    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
3747
3748    fn write_roster(home: &Path, session_uuid: &str) {
3749        let daemon = home.join(".claude").join("daemon");
3750        fs::create_dir_all(&daemon).unwrap();
3751        let body = format!(
3752            "{{\"workers\":{{\"w\":{{\"sessionId\":\"{}\",\"pid\":5}}}}}}",
3753            session_uuid
3754        );
3755        fs::write(daemon.join("roster.json"), body).unwrap();
3756    }
3757
3758    #[test]
3759    fn build_cross_session_container_wraps_peer_turn() {
3760        // Byte-parity with Python's build_cross_session_container.
3761        assert_eq!(
3762            build_cross_session_container("hello", "fno"),
3763            "<cross-session-message from-name=\"fno\">\nhello\n</cross-session-message>"
3764        );
3765    }
3766
3767    #[test]
3768    fn orphan_reason_roster_live_inject_failed_token() {
3769        assert_eq!(
3770            OrphanReason::RosterLiveInjectFailed.as_str(),
3771            "roster-live-inject-failed"
3772        );
3773    }
3774
3775    #[test]
3776    fn daemon_roster_path_honors_env_override_first() {
3777        // x-2681 / codex P2: the roster pre-check must honor FNO_CLAUDE_DAEMON_DIR
3778        // (a supported alt-daemon override) the SAME way the deliver path does, or
3779        // the fallback silently skips in an alt-daemon setup.
3780        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3781        let home = tmpdir();
3782        let ch = ClaudeHome::at(&home);
3783        std::env::remove_var(crate::claude_roster::DAEMON_DIR_ENV);
3784        assert_eq!(
3785            ch.daemon_roster_path(),
3786            home.join(".claude").join("daemon").join("roster.json")
3787        );
3788        let alt = tmpdir();
3789        std::env::set_var(crate::claude_roster::DAEMON_DIR_ENV, &alt);
3790        assert_eq!(ch.daemon_roster_path(), alt.join("roster.json"));
3791        std::env::remove_var(crate::claude_roster::DAEMON_DIR_ENV);
3792    }
3793
3794    #[test]
3795    fn ask_followup_socket_null_roster_live_falls_back_to_control_sock() {
3796        // A socket-null session that is present in the daemon roster takes the
3797        // control.sock fallback. With no real control.sock the deliver fails and
3798        // surfaces the DISTINCT reason (not socket-null) -- which the dispatch
3799        // layer routes to the no-stamp branch (AC6-FR: never orphan a live row).
3800        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3801        std::env::remove_var(crate::claude_roster::DAEMON_DIR_ENV);
3802        let home = tmpdir();
3803        let sessions = home.join(".claude").join("sessions");
3804        fs::create_dir_all(&sessions).unwrap();
3805        write_session(&sessions, "1", "abcd1234", "bg", None);
3806        write_roster(&home, "abcd1234-1111-2222-3333-444455556666");
3807        let ch = ClaudeHome::at(&home);
3808        let err = ask_followup(
3809            &ch,
3810            "abcd1234",
3811            "x",
3812            "y",
3813            Duration::from_millis(200),
3814            Duration::from_millis(10),
3815            None,
3816        )
3817        .unwrap_err();
3818        match err {
3819            AskError::Orphan { reason, .. } => {
3820                assert_eq!(reason, OrphanReason::RosterLiveInjectFailed)
3821            }
3822            other => panic!("expected roster-live-inject-failed orphan, got {:?}", other),
3823        }
3824    }
3825
3826    #[test]
3827    fn ask_followup_not_found_never_falls_back_even_if_rostered() {
3828        // No session file and no transcript verdict is inconclusive, even when
3829        // a same-short roster entry exists.
3830        let home = tmpdir();
3831        fs::create_dir_all(home.join(".claude").join("sessions")).unwrap();
3832        write_roster(&home, "abcd1234-1111-2222-3333-444455556666");
3833        let ch = ClaudeHome::at(&home);
3834        let err = ask_followup(
3835            &ch,
3836            "abcd1234",
3837            "x",
3838            "y",
3839            Duration::from_secs(1),
3840            Duration::from_millis(10),
3841            None,
3842        )
3843        .unwrap_err();
3844        match err {
3845            AskError::Orphan { reason, .. } => {
3846                assert_eq!(reason, OrphanReason::TruthLiveInjectFailed)
3847            }
3848            other => panic!("expected routing gap, got {:?}", other),
3849        }
3850    }
3851
3852    #[test]
3853    fn family1_truth_subprocess_is_bounded_and_validated() {
3854        let mut valid = std::process::Command::new("sh");
3855        valid.args(["-c", "printf '{\"state\":\"watching\"}'"]);
3856        assert_eq!(
3857            family1_truth_state_with_command(valid, Duration::from_secs(1), "h1").as_deref(),
3858            Some("watching")
3859        );
3860
3861        let mut invalid = std::process::Command::new("sh");
3862        invalid.args(["-c", "printf '{\"state\":\"invented\"}'"]);
3863        assert_eq!(
3864            family1_truth_state_with_command(invalid, Duration::from_secs(1), "h1"),
3865            None
3866        );
3867
3868        let mut hung = std::process::Command::new("sh");
3869        hung.args(["-c", "sleep 5"]);
3870        let started = Instant::now();
3871        assert_eq!(
3872            family1_truth_state_with_command(hung, Duration::from_millis(50), "h1"),
3873            None
3874        );
3875        assert!(started.elapsed() < Duration::from_secs(1));
3876    }
3877
3878    #[test]
3879    fn only_a_routine_not_found_is_silenced() {
3880        // The quiet/loud split, pinned directly rather than inferred from a
3881        // return value both branches share.
3882        assert!(truth_failure_is_routine("not-found"));
3883        assert!(truth_failure_is_routine("  not-found\n"));
3884        // Everything else still warns: these mean something is actually broken.
3885        assert!(!truth_failure_is_routine("transcript-unreadable"));
3886        // A crashing resolver reports its own reason precisely so this
3887        // suppression cannot swallow it (cli/src/fno/agents/session_truth.py).
3888        assert!(!truth_failure_is_routine("resolver-error"));
3889        assert!(!truth_failure_is_routine("ambiguous"));
3890        assert!(!truth_failure_is_routine(""));
3891        assert!(!truth_failure_is_routine("not-found-ish"));
3892    }
3893
3894    #[test]
3895    fn family1_truth_nonzero_exit_is_unresolved_either_way() {
3896        // Both a routine not-found and a genuine refusal fail to resolve, so
3897        // silencing one never changes the verdict. This pins only that; the
3898        // quiet/loud split itself is pinned by the predicate test above, which
3899        // is the seam that actually fails when the behavior is reverted.
3900        let mut not_found = std::process::Command::new("sh");
3901        not_found.args([
3902            "-c",
3903            "printf '{\"state\":\"unknown\",\"reason\":\"not-found\"}'; exit 13",
3904        ]);
3905        assert_eq!(
3906            family1_truth_state_with_command(not_found, Duration::from_secs(1), "ses_1d9e"),
3907            None
3908        );
3909
3910        // A genuine refusal on the same exit code still surfaces.
3911        let mut broken = std::process::Command::new("sh");
3912        broken.args([
3913            "-c",
3914            "printf '{\"state\":\"unknown\",\"reason\":\"transcript-unreadable\"}'; exit 13",
3915        ]);
3916        assert_eq!(
3917            family1_truth_state_with_command(broken, Duration::from_secs(1), "abcd1234"),
3918            None
3919        );
3920    }
3921
3922    #[test]
3923    fn family1_truth_failure_detail_prefers_stdout_reason() {
3924        // truth writes {state,reason} to stdout on a refusal (exit 13); stderr
3925        // holds the verify banner and is empty, so the reason is read off stdout.
3926        let detail =
3927            family1_truth_failure_detail(br#"{"state":"unknown","reason":"not-found"}"#, "");
3928        assert_eq!(detail, "not-found");
3929    }
3930
3931    #[test]
3932    fn family1_truth_failure_detail_falls_back_to_stderr() {
3933        // A non-JSON stdout (e.g. a crashed probe) falls back to the stderr tail.
3934        let detail = family1_truth_failure_detail(b"not json", "  banner  ");
3935        assert_eq!(detail, "banner");
3936    }
3937}