1use 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
27pub const ARGV_OVERFLOW_THRESHOLD: usize = 200 * 1024;
34
35pub const STDOUT_HEAD_LIMIT: usize = 200;
37
38pub const TERMINAL_STATES: [&str; 4] = ["done", "completed", "failed", "needs-input"];
42
43const LIVENESS_PROBE_TIMEOUT: Duration = Duration::from_millis(250);
45
46const SEND_SOCKET_TIMEOUT: Duration = Duration::from_secs(5);
48
49const RETRY_BACKOFF: Duration = Duration::from_millis(10);
52
53const 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum OrphanReason {
73 SocketNull,
75 NotFound,
77 LivenessFailed,
79 RosterLiveInjectFailed,
83 TruthLiveInjectFailed,
86}
87
88impl OrphanReason {
89 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
109fn 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
119const TRUTH_NOT_FOUND: &str = "not-found";
135
136fn 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#[derive(Debug)]
218pub enum AskError {
219 Parse { stdout_head: String },
221 Subprocess { exit_code: i32, stderr: String },
223 Orphan {
225 reason: OrphanReason,
226 short_id: String,
227 },
228 Socket { message: String },
230 Timeout { elapsed_sec: f64, short_id: String },
232 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
274pub 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 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
317pub 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("&"),
324 '<' => out.push_str("<"),
325 '>' => out.push_str(">"),
326 '"' => out.push_str("""),
327 '\'' => out.push_str("'"),
328 c => out.push(c),
329 }
330 }
331 out
332}
333
334pub 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#[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 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#[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#[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
434pub 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
456fn match_short_id(line: &str) -> Option<String> {
458 const PREFIX: &str = "backgrounded \u{b7} ";
459 const SEP: &str = " \u{b7} ";
460 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 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
488fn 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(); while let Some(&p) = chars.peek() {
505 if ('\u{20}'..='\u{3f}').contains(&p) {
506 chars.next();
507 } else {
508 break;
509 }
510 }
511 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
524fn head_chars(s: &str, n: usize) -> String {
526 s.chars().take(n).collect()
527}
528
529#[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 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
563pub 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 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 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
607pub fn use_stdin_for(message: &str) -> bool {
609 message.len() > ARGV_OVERFLOW_THRESHOLD
610}
611
612pub 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
643fn 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 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 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
739pub 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 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
770pub fn liveness_probe(sock_path: &str) -> bool {
776 connect_unix_timeout(sock_path, LIVENESS_PROBE_TIMEOUT).is_ok()
777}
778
779pub 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, };
825 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
849pub 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), _ => {
896 if fallback.is_none() {
897 fallback = Some(sid);
898 }
899 }
900 }
901 }
902 fallback
903}
904
905pub 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
927pub 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#[derive(Debug)]
965pub enum StateReadError {
966 NotFound,
967 Parse,
968 Io(std::io::Error),
969}
970
971pub 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 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
1019pub 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
1069pub 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
1077pub const DEFAULT_POLL_INTERVAL: Duration = Duration::from_millis(500);
1083
1084#[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
1095enum ShortIdScan {
1097 Found { short_id: String, consumed: String },
1100 NoId { consumed: String },
1103}
1104
1105fn 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 }, 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 Err(_) => return ShortIdScan::NoId { consumed },
1125 }
1126 }
1127}
1128
1129pub 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 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 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 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 });
1237 }
1238
1239 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 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 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 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 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
1315pub 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 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#[allow(clippy::too_many_arguments)]
1375fn 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
1387fn 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 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 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 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 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 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
1524use crate::paths::AgentsHome;
1530use crate::state::{load_registry, update_registry, RegistryEntry};
1531use crate::AgentStatus;
1532
1533const NAME_MAX_LEN: usize = 128;
1535const FROM_NAME_MAX_LEN: usize = 128;
1536const DEFAULT_FOLLOWUP_TIMEOUT: Duration = Duration::from_secs(600);
1538const LOCK_ACQUIRE_TIMEOUT: Duration = Duration::from_secs(30);
1540const DEFAULT_SPAWN_TIMEOUT: Duration = Duration::from_secs(120);
1550
1551const PROVABLY_LIVE_WINDOW_SECS: u64 = 3600;
1558
1559fn 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
1579fn spawn_create_timeout(explicit: Option<Duration>) -> Duration {
1584 explicit.unwrap_or(DEFAULT_SPAWN_TIMEOUT)
1585}
1586
1587#[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 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
1617fn now_iso() -> String {
1619 chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
1620}
1621
1622pub fn emit_event(events_path: &Path, kind: &str, fields: &[(&str, serde_json::Value)]) {
1634 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 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
1679static EMIT_EVENT_WRITE_WARNED: std::sync::atomic::AtomicBool =
1682 std::sync::atomic::AtomicBool::new(false);
1683
1684pub 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
1699pub 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
1759struct 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
1801fn 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#[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 _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 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 let registry = match load_registry(®istry_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 ®istry_path,
1884 name,
1885 &entry,
1886 message,
1887 from_name,
1888 timeout,
1889 ),
1890 None => {
1891 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#[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 surface_cwd: bool,
1940) -> AskOutcome {
1941 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 let registry = match load_registry(®istry_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 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 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 let inner = create(
2005 home,
2006 claude_home,
2007 &events,
2008 ®istry_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 let short_id = inner.stdout.trim_end_matches('\n').to_string();
2028 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 let safe_name = name.replace('"', "\\\"");
2040 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 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#[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 let effective = if message.is_empty() { "hello" } else { message };
2110 let use_stdin = use_stdin_for(effective);
2111
2112 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 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 flags.push_onto(&mut argv);
2135 if !use_stdin {
2136 argv.push(effective.to_string());
2137 }
2138 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 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 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 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 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 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 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 let now = now_epoch_secs();
2374 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 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 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 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 permission_mode: Option<&str>,
2540 effort: Option<&str>,
2541 flags: HarnessFlags,
2542) -> AskOutcome {
2543 let pre_stderr = String::new();
2544
2545 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 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 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 short_id: short_id.clone(),
2625 legacy_provider: String::new(),
2626 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, 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 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 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 #[test]
2747 fn spawn_create_timeout_defaults_when_unset() {
2748 assert_eq!(spawn_create_timeout(None), DEFAULT_SPAWN_TIMEOUT);
2749 }
2750
2751 #[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 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 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 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 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 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 #[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 #[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 #[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 #[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 fn tmpdir() -> PathBuf {
2879 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 #[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 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 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 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 let out = "backgrounded \u{b7} 1234567\u{e9} \u{b7} x";
2959 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 #[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 #[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 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 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 #[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 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 #[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 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 #[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&b<c>"d'e\\\""),
3159 "{}",
3160 s
3161 );
3162 }
3163
3164 #[test]
3165 fn envelope_ensure_ascii_non_ascii() {
3166 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 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 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 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 #[test]
3274 fn resolve_session_uuid_resolves_idle_bg() {
3275 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); let ch = ClaudeHome::at(&home);
3282 assert!(locate_session(&ch, "7c5dcf5d").is_none()); assert_eq!(
3284 resolve_session_uuid(&ch, "7c5dcf5d").as_deref(),
3285 Some("sess-7c5dcf5d") );
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")); write_session(&sessions, "2", "deadbeef", "bg", Some("/tmp/y")); 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 assert_eq!(
3317 resolve_session_uuid_at_spawn(&ch, "7c5dcf5d").as_deref(),
3318 Some("sess-7c5dcf5d")
3319 );
3320 assert!(resolve_session_uuid_at_spawn(&ch, "").is_none());
3322 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 #[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 #[test]
3371 fn timeline_tail_concats_terminal_text_from_offset() {
3372 let jobs = tmpdir();
3373 let tl = jobs.join("timeline.jsonl");
3374 fs::write(&tl, "{\"state\":\"completed\",\"text\":\"OLD\"}\n").unwrap();
3376 let offset = timeline_offset(&jobs);
3377 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 #[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 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 let jobs = tmpdir();
3471
3472 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 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); 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 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 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 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 #[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 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; }
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 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 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 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 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 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 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 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 assert!(truth_failure_is_routine("not-found"));
3883 assert!(truth_failure_is_routine(" not-found\n"));
3884 assert!(!truth_failure_is_routine("transcript-unreadable"));
3886 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 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 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 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 let detail = family1_truth_failure_detail(b"not json", " banner ");
3935 assert_eq!(detail, "banner");
3936 }
3937}