1use std::io::{self, BufRead, Read, Seek};
34use std::path::Path;
35use std::time::Duration;
36
37use crate::claude_attach::{perform_attach, AttachRequest, UnixControlTransport};
38use crate::claude_drive::{contains_detach_sentinel, find_transcript, transcript_len, DriveError};
39use crate::claude_roster::{read_control_key, ClaudeRoster};
40
41pub const DEFAULT_ATTEMPTS: u32 = 40;
46pub const DEFAULT_INTERVAL_MS: u64 = 250;
47
48const CR_SETTLE_MS: u64 = 800;
52
53const CR_RESUBMIT_EVERY: u32 = 8;
59
60#[derive(Debug, PartialEq, Clone, Copy)]
63pub enum MailInjectProvider {
64 Claude,
65 Codex,
66}
67
68const PROVIDER_AXIS_TOMBSTONE: &str = concat!(
72 "--provider was split at the axis rename: the CLI binary is --harness/-H; ",
73 "a model vendor is only routable at spawn ",
74 "(`fno agents spawn --provider <vendor> --model <m>`).",
75);
76
77#[derive(Debug, PartialEq)]
81pub struct MailInjectArgs {
82 pub session: String,
85 pub provider: MailInjectProvider,
86 pub attempts: u32,
87 pub interval_ms: u64,
88}
89
90pub fn parse_args(rest: &[String]) -> Result<MailInjectArgs, (i32, String)> {
93 let mut session: Option<String> = None;
94 let mut provider = MailInjectProvider::Claude;
95 let mut attempts = DEFAULT_ATTEMPTS;
96 let mut interval_ms = DEFAULT_INTERVAL_MS;
97 let mut it = rest.iter();
98 while let Some(a) = it.next() {
99 match a.as_str() {
100 "--session" => {
101 session = Some(
102 it.next()
103 .ok_or((2, "mail-inject: --session needs a value".to_string()))?
104 .to_string(),
105 );
106 }
107 "--harness" | "-H" => {
108 provider = match it.next().map(String::as_str) {
109 Some("claude") => MailInjectProvider::Claude,
110 Some("codex") => MailInjectProvider::Codex,
111 _ => {
112 return Err((
113 2,
114 "mail-inject: --harness must be claude or codex".to_string(),
115 ))
116 }
117 };
118 }
119 "--provider" => return Err((2, PROVIDER_AXIS_TOMBSTONE.to_string())),
120 "--attempts" => {
121 attempts = it.next().and_then(|v| v.parse().ok()).ok_or((
122 2,
123 "mail-inject: --attempts needs a positive integer".to_string(),
124 ))?;
125 }
126 "--interval-ms" => {
127 interval_ms = it.next().and_then(|v| v.parse().ok()).ok_or((
128 2,
129 "mail-inject: --interval-ms needs a positive integer".to_string(),
130 ))?;
131 }
132 other => {
133 return Err((2, format!("mail-inject: unknown flag: {other}")));
134 }
135 }
136 }
137 let session = session.ok_or((2, "mail-inject: --session is required".to_string()))?;
138 Ok(MailInjectArgs {
139 session,
140 provider,
141 attempts,
142 interval_ms,
143 })
144}
145
146pub fn outcome_json(delivered: bool, reason: &str) -> String {
149 serde_json::json!({ "delivered": delivered, "reason": reason }).to_string()
150}
151
152pub fn outcome_exit(delivered: bool) -> i32 {
155 i32::from(!delivered)
156}
157
158fn emit(delivered: bool, reason: &str) -> i32 {
160 println!("{}", outcome_json(delivered, reason));
161 outcome_exit(delivered)
162}
163
164const PASTE_BEGIN: &str = "\x1b[200~";
171const PASTE_END: &str = "\x1b[201~";
172
173fn inject_with_submit<T: crate::claude_attach::ControlTransport>(
184 transport: &mut T,
185 text: &str,
186 settle: Duration,
187) -> Result<(), DriveError> {
188 if contains_detach_sentinel(text) {
189 return Err(DriveError::UnsafeText);
190 }
191 transport
192 .send_line(&format!("{PASTE_BEGIN}{text}{PASTE_END}"))
193 .map_err(|e| DriveError::Io(e.to_string()))?;
194 std::thread::sleep(settle);
195 transport
196 .send_line("\r")
197 .map_err(|e| DriveError::Io(e.to_string()))
198}
199
200fn confirm_with_cr_retry<T: crate::claude_attach::ControlTransport>(
208 transport: &mut T,
209 attempts: u32,
210 interval: Duration,
211 mut confirmed: impl FnMut() -> bool,
212) -> Result<(), &'static str> {
213 for i in 0..attempts.max(1) {
214 if confirmed() {
215 return Ok(());
216 }
217 std::thread::sleep(interval);
218 if (i + 1) % CR_RESUBMIT_EVERY == 0 {
219 let _ = transport.send_line("\r");
220 }
221 }
222 Err("not-confirmed")
223}
224
225fn escaped_marker(marker: &str) -> String {
230 let s = serde_json::to_string(marker).unwrap_or_default();
231 s.strip_prefix('"')
232 .and_then(|s| s.strip_suffix('"'))
233 .unwrap_or("")
234 .to_string()
235}
236
237fn confirm_content_after(path: &Path, marker: &str, since_byte: u64) -> io::Result<bool> {
244 let escaped = escaped_marker(marker);
245 if escaped.is_empty() {
246 return Ok(false);
247 }
248 let mut file = std::fs::File::open(path)?;
249 file.seek(io::SeekFrom::Start(since_byte))?;
250 for line in io::BufReader::new(file).lines() {
251 if line?.contains(&escaped) {
252 return Ok(true);
253 }
254 }
255 Ok(false)
256}
257
258pub fn deliver_via_control_sock(
272 session: &str,
273 text: &str,
274 attempts: u32,
275 interval_ms: u64,
276) -> Result<(), &'static str> {
277 let roster = ClaudeRoster::load_default().map_err(|_| "not-live")?;
280 let worker = roster.find(session).ok_or("not-live")?;
281 let sock = worker.resolve_control_sock().ok_or("not-live")?;
282 let short = worker.short_id().to_string();
283 let auth = read_control_key();
284
285 let transcript = find_transcript(&worker.session_id).ok_or("no-transcript")?;
288
289 let mut transport = UnixControlTransport::connect(&sock).map_err(|_| "io-error")?;
290 if perform_attach(
291 &mut transport,
292 &AttachRequest::for_frame_stream(short.clone(), auth.clone()),
293 )
294 .is_err()
295 {
296 return Err("attach-failed");
297 }
298 let baseline = transcript_len(&transcript);
302 let marker = text.lines().next().unwrap_or(text);
306 inject_with_submit(&mut transport, text, Duration::from_millis(CR_SETTLE_MS)).map_err(|e| {
307 match e {
308 DriveError::UnsafeText => "unsafe-text",
309 _ => "io-error",
310 }
311 })?;
312
313 confirm_with_cr_retry(
314 &mut transport,
315 attempts,
316 Duration::from_millis(interval_ms),
317 || confirm_content_after(&transcript, marker, baseline).unwrap_or(false),
318 )
319}
320
321pub async fn run_mail_inject(rest: &[String]) -> i32 {
329 let args = match parse_args(rest) {
330 Ok(a) => a,
331 Err((code, msg)) => {
332 eprintln!("{msg}");
333 return code;
334 }
335 };
336
337 let mut text = String::new();
338 if let Err(e) = std::io::stdin().read_to_string(&mut text) {
339 eprintln!("mail-inject: reading stdin: {e}");
340 return emit(false, "io-error");
341 }
342
343 let result = match args.provider {
344 MailInjectProvider::Claude => {
345 deliver_via_control_sock(&args.session, &text, args.attempts, args.interval_ms)
346 }
347 MailInjectProvider::Codex => {
348 crate::codex_inject::deliver_via_codex_daemon(&args.session, &text).await
349 }
350 };
351 match result {
352 Ok(()) => emit(true, "delivered"),
353 Err(reason) => emit(false, reason),
354 }
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360 use crate::claude_attach::ControlTransport;
361 use crate::claude_drive::DETACH_SENTINELS;
362 use std::fs::{File, OpenOptions};
363 use std::io::{self, Write};
364 use std::path::PathBuf;
365
366 struct Fake {
368 sent: Vec<String>,
369 }
370 impl ControlTransport for Fake {
371 fn send_line(&mut self, line: &str) -> io::Result<()> {
372 self.sent.push(line.to_string());
373 Ok(())
374 }
375 fn recv_line(&mut self) -> io::Result<Option<String>> {
376 Ok(None)
377 }
378 }
379
380 fn argv(parts: &[&str]) -> Vec<String> {
381 parts.iter().map(|s| s.to_string()).collect()
382 }
383
384 fn tmp_transcript(tag: &str) -> PathBuf {
385 let dir = std::env::temp_dir().join(format!("mailinj-{}-{}", tag, std::process::id()));
386 std::fs::create_dir_all(&dir).unwrap();
387 dir.join("t.jsonl")
388 }
389
390 #[test]
391 fn inject_with_submit_bracketed_pastes_then_separate_cr() {
392 let mut t = Fake { sent: Vec::new() };
393 let envelope = "<fno_mail from=\"a1b2c3d4\" node=\"x-178e\">\nhi MARKER\n</fno_mail>";
394 inject_with_submit(&mut t, envelope, Duration::ZERO).unwrap();
395 assert_eq!(
399 t.sent,
400 vec![
401 format!("{PASTE_BEGIN}{envelope}{PASTE_END}"),
402 "\r".to_string()
403 ]
404 );
405 assert!(t.sent[0].contains(envelope), "envelope pasted verbatim");
408 assert!(
409 !t.sent[0].contains("\"op\""),
410 "envelope must be raw bytes, not a JSON op"
411 );
412 assert!(
413 !t.sent[0].contains("auth"),
414 "raw paste must never carry the control auth key"
415 );
416 }
417
418 #[test]
419 fn inject_with_submit_refuses_unsafe_envelope_and_writes_nothing() {
420 let mut t = Fake { sent: Vec::new() };
421 let err = inject_with_submit(&mut t, DETACH_SENTINELS[0], Duration::ZERO);
422 assert!(matches!(err, Err(DriveError::UnsafeText)));
423 assert!(t.sent.is_empty(), "unsafe envelope must not paste or CR");
424 }
425
426 #[test]
427 fn busy_recipient_gets_raw_paste_then_retried_crs() {
428 let mut t = Fake { sent: Vec::new() };
429 inject_with_submit(&mut t, "hi MARKER", Duration::ZERO).unwrap();
430 let attempts = 2 * CR_RESUBMIT_EVERY; let r = confirm_with_cr_retry(&mut t, attempts, Duration::ZERO, || false);
434 assert_eq!(r, Err("not-confirmed"));
435 assert_eq!(t.sent.len() as u32, 2 + attempts / CR_RESUBMIT_EVERY);
437 for line in &t.sent[1..] {
439 assert_eq!(line, "\r");
440 }
441 }
442
443 #[test]
444 fn confirm_stops_on_landing_without_extra_cr() {
445 let mut t = Fake { sent: Vec::new() };
446 let mut calls = 0;
447 let r = confirm_with_cr_retry(&mut t, 40, Duration::ZERO, || {
448 calls += 1;
449 calls >= 2
450 });
451 assert_eq!(r, Ok(()));
452 assert!(
453 t.sent.is_empty(),
454 "landing before a resubmit window sends no CR"
455 );
456 }
457
458 #[test]
459 fn content_confirm_rejects_growth_and_accepts_the_landed_envelope() {
460 let path = tmp_transcript("content");
461 let mut f = File::create(&path).unwrap();
462 writeln!(
463 f,
464 r#"{{"type":"user","message":{{"role":"user","content":"older"}}}}"#
465 )
466 .unwrap();
467 let baseline = transcript_len(&path);
468 let marker = "<fno_mail from=\"a1b2c3d4\" node=\"x-178e\">";
469
470 let mut f = OpenOptions::new().append(true).open(&path).unwrap();
473 writeln!(
474 f,
475 r#"{{"type":"assistant","message":{{"role":"assistant","content":"streaming something else"}}}}"#
476 )
477 .unwrap();
478 assert!(
479 !confirm_content_after(&path, marker, baseline).unwrap(),
480 "growth without the marker must not confirm"
481 );
482
483 writeln!(
485 f,
486 r#"{{"type":"user","message":{{"role":"user","content":"{}\nhi\n</fno_mail>"}}}}"#,
487 escaped_marker(marker)
488 )
489 .unwrap();
490 assert!(
491 confirm_content_after(&path, marker, baseline).unwrap(),
492 "the landed envelope confirms delivery"
493 );
494 std::fs::remove_dir_all(path.parent().unwrap()).ok();
495 }
496
497 #[test]
498 fn parse_args_requires_session() {
499 assert_eq!(parse_args(&[]).unwrap_err().0, 2);
500 assert_eq!(
501 parse_args(&argv(&["--attempts", "5"])).unwrap_err().0,
502 2,
503 "no --session is an error even with other flags"
504 );
505 }
506
507 #[test]
508 fn parse_args_defaults_and_overrides() {
509 let a = parse_args(&argv(&["--session", "a1b2c3d4"])).unwrap();
510 assert_eq!(a.session, "a1b2c3d4");
511 assert_eq!(a.provider, MailInjectProvider::Claude);
512 assert_eq!(a.attempts, DEFAULT_ATTEMPTS);
513 assert_eq!(a.interval_ms, DEFAULT_INTERVAL_MS);
514
515 let b = parse_args(&argv(&[
516 "--session",
517 "a1b2c3d4-1111-2222-3333-444455556666",
518 "--attempts",
519 "3",
520 "--interval-ms",
521 "10",
522 ]))
523 .unwrap();
524 assert_eq!(b.session, "a1b2c3d4-1111-2222-3333-444455556666");
525 assert_eq!(b.attempts, 3);
526 assert_eq!(b.interval_ms, 10);
527 }
528
529 #[test]
530 fn parse_args_harness_defaults_claude_and_accepts_codex() {
531 let d = parse_args(&argv(&["--session", "x"])).unwrap();
532 assert_eq!(d.provider, MailInjectProvider::Claude);
533 let c = parse_args(&argv(&["--session", "x", "--harness", "codex"])).unwrap();
534 assert_eq!(c.provider, MailInjectProvider::Codex);
535 let h = parse_args(&argv(&["--session", "x", "-H", "codex"])).unwrap();
537 assert_eq!(h.provider, MailInjectProvider::Codex);
538 assert_eq!(
540 parse_args(&argv(&["--session", "x", "--harness", "gemini"]))
541 .unwrap_err()
542 .0,
543 2
544 );
545 }
546
547 #[test]
548 fn parse_args_provider_is_the_axis_rename_tombstone() {
549 let err = parse_args(&argv(&["--session", "x", "--provider", "codex"])).unwrap_err();
553 assert_eq!(err.0, 2);
554 assert!(
555 err.1.contains("--harness/-H"),
556 "tombstone points at --harness: {err:?}"
557 );
558 }
559
560 #[test]
561 fn parse_args_rejects_unknown_flag_and_missing_value() {
562 assert_eq!(parse_args(&argv(&["--nope"])).unwrap_err().0, 2);
563 assert_eq!(parse_args(&argv(&["--session"])).unwrap_err().0, 2);
564 assert_eq!(
565 parse_args(&argv(&["--session", "x", "--attempts", "notnum"]))
566 .unwrap_err()
567 .0,
568 2
569 );
570 }
571
572 #[test]
573 fn outcome_json_is_the_python_contract() {
574 let v: serde_json::Value = serde_json::from_str(&outcome_json(true, "delivered")).unwrap();
575 assert_eq!(v["delivered"], true);
576 assert_eq!(v["reason"], "delivered");
577 let w: serde_json::Value = serde_json::from_str(&outcome_json(false, "not-live")).unwrap();
578 assert_eq!(w["delivered"], false);
579 assert_eq!(w["reason"], "not-live");
580 }
581
582 #[test]
583 fn outcome_exit_maps_delivered_to_zero() {
584 assert_eq!(outcome_exit(true), 0);
585 assert_eq!(outcome_exit(false), 1);
586 }
587}