1use std::io::IsTerminal;
25use std::os::unix::process::{CommandExt, ExitStatusExt};
26use std::path::{Path, PathBuf};
27use std::process::{ExitStatus, Stdio};
28use std::time::Duration;
29
30use anyhow::{anyhow, bail, Context, Result};
31use clap::Parser;
32use serde_json::{json, Value};
33use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
34use tokio::sync::mpsc;
35
36use crate::daemon::client::DaemonClient;
37use crate::daemon::protocol::DaemonEnvelope;
38use crate::daemon::server;
39use crate::sessions::stream::{Direction, StreamTracker};
40
41const SERVICE: &str = "sessions";
43
44const REPORT_TIMEOUT: Duration = Duration::from_secs(2);
47
48const KEEPALIVE_INTERVAL: Duration = Duration::from_secs(30);
55
56const TEE_CAPACITY: usize = 256;
60
61const MAX_LINE_BYTES: usize = 1024 * 1024;
65
66const PUMP_BUF_BYTES: usize = 64 * 1024;
68
69const SIGNAL_EXIT_BASE: i32 = 128;
72
73#[derive(Parser)]
75pub struct ClaudeWrapCommand {
76 #[arg(long, value_name = "PATH")]
78 pub socket: Option<PathBuf>,
79
80 #[arg(
82 trailing_var_arg = true,
83 allow_hyphen_values = true,
84 value_name = "CMD"
85 )]
86 pub argv: Vec<String>,
87}
88
89impl ClaudeWrapCommand {
90 pub async fn execute(self) -> Result<()> {
99 let code = self.run().await?;
100 std::process::exit(code)
101 }
102
103 async fn run(self) -> Result<i32> {
107 let Some((program, args)) = self.argv.split_first() else {
108 bail!(
109 "claude-wrap needs a command to run, e.g. \
110 `omni-dev claude-wrap -- claude --output-format stream-json`"
111 );
112 };
113
114 if std::io::stdout().is_terminal() {
119 return Err(exec_replace(program, args));
120 }
121
122 wrap(program, args, self.socket).await
123 }
124}
125
126fn exec_replace(program: &str, args: &[String]) -> anyhow::Error {
129 let error = std::process::Command::new(program).args(args).exec();
130 anyhow::Error::new(error).context(format!("failed to exec {program}"))
131}
132
133async fn wrap(program: &str, args: &[String], socket: Option<PathBuf>) -> Result<i32> {
135 wrap_io(
136 program,
137 args,
138 socket,
139 tokio::io::stdin(),
140 tokio::io::stdout(),
141 )
142 .await
143}
144
145async fn wrap_io<R, W>(
153 program: &str,
154 args: &[String],
155 socket: Option<PathBuf>,
156 input: R,
157 output: W,
158) -> Result<i32>
159where
160 R: AsyncRead + Unpin + Send + 'static,
161 W: AsyncWrite + Unpin + Send + 'static,
162{
163 let mut child = tokio::process::Command::new(program)
169 .args(args)
170 .stdin(Stdio::piped())
171 .stdout(Stdio::piped())
172 .stderr(Stdio::inherit())
173 .spawn()
174 .with_context(|| format!("failed to spawn {program}"))?;
175
176 let child_stdin = child
177 .stdin
178 .take()
179 .ok_or_else(|| anyhow!("failed to capture the wrapped process's stdin"))?;
180 let child_stdout = child
181 .stdout
182 .take()
183 .ok_or_else(|| anyhow!("failed to capture the wrapped process's stdout"))?;
184 let child_pid = child.id();
185
186 let (tee, lines) = mpsc::channel::<(Direction, String)>(TEE_CAPACITY);
187 let observer = tokio::spawn(observe(lines, socket, KEEPALIVE_INTERVAL));
188 let signals = tokio::spawn(forward_signals(child_pid));
189
190 let from_child = tokio::spawn(pump(
191 child_stdout,
192 output,
193 Direction::FromClaude,
194 tee.clone(),
195 ));
196 let to_child = tokio::spawn(pump(input, child_stdin, Direction::ToClaude, tee.clone()));
197 drop(tee);
198
199 let _ = from_child.await;
203 to_child.abort();
207 let _ = to_child.await;
208 signals.abort();
209
210 let status = child
211 .wait()
212 .await
213 .context("failed to wait for the wrapped process")?;
214 let _ = observer.await;
215 Ok(exit_code(status))
216}
217
218async fn pump<R, W>(
227 mut reader: R,
228 mut writer: W,
229 direction: Direction,
230 tee: mpsc::Sender<(Direction, String)>,
231) where
232 R: AsyncRead + Unpin,
233 W: AsyncWrite + Unpin,
234{
235 let mut buffer = vec![0u8; PUMP_BUF_BYTES];
236 let mut line = Vec::new();
237 loop {
238 let read = match reader.read(&mut buffer).await {
239 Ok(0) | Err(_) => break,
240 Ok(read) => read,
241 };
242 let chunk = &buffer[..read];
243 if writer.write_all(chunk).await.is_err() || writer.flush().await.is_err() {
244 break;
245 }
246 tee_chunk(chunk, direction, &tee, &mut line);
247 }
248 let _ = writer.shutdown().await;
251}
252
253fn tee_chunk(
257 chunk: &[u8],
258 direction: Direction,
259 tee: &mpsc::Sender<(Direction, String)>,
260 line: &mut Vec<u8>,
261) {
262 for &byte in chunk {
263 if byte == b'\n' {
264 if line.len() < MAX_LINE_BYTES {
265 if let Ok(text) = std::str::from_utf8(line) {
266 let _ = tee.try_send((direction, text.to_string()));
267 }
268 }
269 line.clear();
270 } else if line.len() < MAX_LINE_BYTES {
271 line.push(byte);
272 }
273 }
274}
275
276async fn observe(
282 mut lines: mpsc::Receiver<(Direction, String)>,
283 socket: Option<PathBuf>,
284 every: Duration,
285) {
286 let Ok(socket) = server::resolve_socket(socket) else {
287 return;
288 };
289 let mut tracker = StreamTracker::new();
290 let mut keepalive = tokio::time::interval(every);
291 keepalive.tick().await;
294
295 loop {
296 let observed = tokio::select! {
297 line = lines.recv() => match line {
298 Some((direction, text)) => tracker.observe_line(direction, &text),
299 None => break,
300 },
301 _ = keepalive.tick() => tracker.keepalive(),
302 };
303 if let Some(request) = observed {
304 if let Ok(payload) = serde_json::to_value(request) {
305 report(&socket, "observe", payload).await;
306 }
307 }
308 }
309
310 if let Some(session_id) = tracker.session_id() {
311 report(&socket, "end", json!({ "session_id": session_id })).await;
312 }
313}
314
315async fn report(socket: &Path, op: &str, payload: Value) {
318 let envelope = DaemonEnvelope::service(SERVICE, op, payload);
319 let _ = tokio::time::timeout(REPORT_TIMEOUT, DaemonClient::new(socket).request(envelope)).await;
320}
321
322async fn forward_signals(child_pid: Option<u32>) {
325 let Some(pid) = child_pid.and_then(|pid| i32::try_from(pid).ok()) else {
326 return;
327 };
328 let pid = nix::unistd::Pid::from_raw(pid);
329 let (Ok(mut terminate), Ok(mut interrupt)) = (
330 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()),
331 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt()),
332 ) else {
333 return;
334 };
335 loop {
336 let signal = tokio::select! {
337 _ = terminate.recv() => nix::sys::signal::Signal::SIGTERM,
338 _ = interrupt.recv() => nix::sys::signal::Signal::SIGINT,
339 };
340 let _ = nix::sys::signal::kill(pid, signal);
341 }
342}
343
344fn exit_code(status: ExitStatus) -> i32 {
347 status
348 .code()
349 .or_else(|| status.signal().map(|signal| SIGNAL_EXIT_BASE + signal))
350 .unwrap_or(1)
351}
352
353#[cfg(test)]
354#[allow(clippy::unwrap_used, clippy::expect_used)]
355mod tests {
356 use std::pin::Pin;
357 use std::sync::{Arc, Mutex};
358 use std::task::{Context as TaskContext, Poll};
359
360 use tokio::io::AsyncBufReadExt;
361 use tokio::net::UnixListener;
362
363 use super::*;
364
365 #[derive(Clone, Default)]
368 struct Sink(Arc<Mutex<Vec<u8>>>);
369
370 impl Sink {
371 fn contents(&self) -> String {
372 String::from_utf8(self.0.lock().unwrap().clone()).unwrap()
373 }
374 }
375
376 impl AsyncWrite for Sink {
377 fn poll_write(
378 self: Pin<&mut Self>,
379 _cx: &mut TaskContext<'_>,
380 buf: &[u8],
381 ) -> Poll<std::io::Result<usize>> {
382 self.0.lock().unwrap().extend_from_slice(buf);
383 Poll::Ready(Ok(buf.len()))
384 }
385
386 fn poll_flush(
387 self: Pin<&mut Self>,
388 _cx: &mut TaskContext<'_>,
389 ) -> Poll<std::io::Result<()>> {
390 Poll::Ready(Ok(()))
391 }
392
393 fn poll_shutdown(
394 self: Pin<&mut Self>,
395 _cx: &mut TaskContext<'_>,
396 ) -> Poll<std::io::Result<()>> {
397 Poll::Ready(Ok(()))
398 }
399 }
400
401 async fn wrap_script(script: &str, socket: Option<PathBuf>) -> (i32, String) {
404 let sink = Sink::default();
405 let code = wrap_io(
406 "/bin/sh",
407 &["-c".to_string(), script.to_string()],
408 socket,
409 tokio::io::empty(),
410 sink.clone(),
411 )
412 .await
413 .unwrap();
414 (code, sink.contents())
415 }
416
417 fn parse(args: &[&str]) -> ClaudeWrapCommand {
419 let mut argv = vec!["claude-wrap"];
420 argv.extend_from_slice(args);
421 ClaudeWrapCommand::try_parse_from(argv).unwrap()
422 }
423
424 fn fake_daemon() -> (
426 tempfile::TempDir,
427 PathBuf,
428 Arc<tokio::sync::Mutex<Vec<Value>>>,
429 ) {
430 let dir = tempfile::tempdir_in("/tmp").unwrap();
431 let socket = dir.path().join("d.sock");
432 let listener = UnixListener::bind(&socket).unwrap();
433 let seen = Arc::new(tokio::sync::Mutex::new(Vec::new()));
434 let recorder = Arc::clone(&seen);
435 tokio::spawn(async move {
436 loop {
437 let Ok((stream, _)) = listener.accept().await else {
438 return;
439 };
440 let recorder = Arc::clone(&recorder);
441 tokio::spawn(async move {
442 let (read, mut write) = stream.into_split();
443 let mut line = String::new();
444 if tokio::io::BufReader::new(read)
445 .read_line(&mut line)
446 .await
447 .is_ok()
448 {
449 if let Ok(value) = serde_json::from_str::<Value>(&line) {
450 recorder.lock().await.push(value);
451 }
452 }
453 let _ = write.write_all(b"{\"ok\":true,\"payload\":{}}\n").await;
454 });
455 }
456 });
457 (dir, socket, seen)
458 }
459
460 #[test]
461 fn trailing_args_are_captured_verbatim_after_a_separator() {
462 let cmd = parse(&["--", "node", "cli.js", "--output-format", "stream-json"]);
463 assert_eq!(
464 cmd.argv,
465 vec!["node", "cli.js", "--output-format", "stream-json"]
466 );
467 assert!(cmd.socket.is_none());
468 }
469
470 #[test]
471 fn the_socket_override_is_parsed_before_the_separator() {
472 let cmd = parse(&["--socket", "/tmp/d.sock", "--", "claude", "-p"]);
473 assert_eq!(cmd.socket.unwrap(), PathBuf::from("/tmp/d.sock"));
474 assert_eq!(cmd.argv, vec!["claude", "-p"]);
475 }
476
477 #[tokio::test]
478 async fn an_empty_command_is_a_usage_error() {
479 let error = parse(&[]).run().await.unwrap_err();
480 assert!(error.to_string().contains("needs a command to run"));
481 }
482
483 #[tokio::test]
497 async fn signal_forwarding_gives_up_when_there_is_no_child() {
498 forward_signals(None).await;
501 }
502
503 #[tokio::test]
504 async fn the_child_is_wrapped_losslessly_and_its_exit_code_survives() {
505 let (_dir, socket, _seen) = fake_daemon();
506 let (code, forwarded) =
509 wrap_script("printf 'hello\\nnot json\\ntrailing'; exit 7", Some(socket)).await;
510 assert_eq!(code, 7);
511 assert_eq!(forwarded, "hello\nnot json\ntrailing");
512 }
513
514 #[tokio::test]
515 async fn stream_state_transitions_reach_the_daemon() {
516 let (_dir, socket, seen) = fake_daemon();
517 let script = concat!(
519 r#"printf '{"type":"system","subtype":"init","session_id":"s-1","cwd":"/w"}\n';"#,
520 r#"printf '{"type":"assistant","session_id":"s-1"}\n';"#,
521 r#"printf '{"type":"control_request","request_id":"r1","request":{"subtype":"can_use_tool"}}\n';"#,
522 r#"printf '{"type":"result"}\n'"#,
523 );
524 let (code, _forwarded) = wrap_script(script, Some(socket)).await;
525 assert_eq!(code, 0);
526
527 let envelopes = seen.lock().await.clone();
528 let states: Vec<String> = envelopes
529 .iter()
530 .filter(|e| e["op"] == "observe")
531 .filter_map(|e| e["payload"]["event"]["stream_state"].as_str())
532 .map(ToString::to_string)
533 .collect();
534 assert_eq!(
535 states,
536 vec!["idle", "working", "waiting_for_permission", "idle"]
537 );
538 assert_eq!(envelopes[0]["service"], "sessions");
540 assert_eq!(envelopes[0]["payload"]["session_id"], "s-1");
541 assert_eq!(envelopes[0]["payload"]["cwd"], "/w");
542 let ended = envelopes.last().unwrap();
543 assert_eq!(ended["op"], "end");
544 assert_eq!(ended["payload"]["session_id"], "s-1");
545 }
546
547 #[tokio::test]
548 async fn a_missing_daemon_never_affects_the_child() {
549 let dir = tempfile::tempdir_in("/tmp").unwrap();
551 let script = concat!(
552 r#"printf '{"type":"system","subtype":"init","session_id":"s-1"}\n';"#,
553 r#"printf 'not json at all\n';"#,
554 r#"exit 3"#,
555 );
556 let (code, forwarded) = wrap_script(script, Some(dir.path().join("absent.sock"))).await;
557 assert_eq!(code, 3);
558 assert!(forwarded.ends_with("not json at all\n"));
559 }
560
561 #[tokio::test]
562 async fn a_child_killed_by_a_signal_reports_the_shell_convention() {
563 let (_dir, socket, _seen) = fake_daemon();
564 let (code, _forwarded) = wrap_script("kill -TERM $$", Some(socket)).await;
565 assert_eq!(code, SIGNAL_EXIT_BASE + 15);
566 }
567
568 #[tokio::test]
569 async fn spawning_a_missing_program_is_an_error_not_a_panic() {
570 let error = wrap_io(
571 "/nonexistent/omni-dev-test-binary",
572 &[],
573 None,
574 tokio::io::empty(),
575 Sink::default(),
576 )
577 .await
578 .unwrap_err();
579 assert!(error.to_string().contains("failed to spawn"));
580 }
581
582 #[test]
583 fn over_long_and_non_utf8_lines_are_dropped_but_still_forwarded() {
584 let (tee, mut lines) = mpsc::channel(4);
585 let mut buffer = Vec::new();
586 let mut chunk = vec![b'x'; MAX_LINE_BYTES + 1];
587 chunk.push(b'\n');
588 chunk.extend_from_slice(&[0xff, 0xfe, b'\n']);
589 chunk.extend_from_slice(b"{\"type\":\"result\"}\n");
590 tee_chunk(&chunk, Direction::FromClaude, &tee, &mut buffer);
591 let (direction, text) = lines.try_recv().unwrap();
593 assert_eq!(direction, Direction::FromClaude);
594 assert_eq!(text, r#"{"type":"result"}"#);
595 assert!(lines.try_recv().is_err());
596 }
597
598 #[tokio::test]
599 async fn the_keepalive_re_reports_a_silent_session() {
600 let (_dir, socket, seen) = fake_daemon();
603 let (tee, lines) = mpsc::channel(4);
604 let observer = tokio::spawn(observe(lines, Some(socket), Duration::from_millis(20)));
605 tee.send((
606 Direction::FromClaude,
607 r#"{"type":"system","subtype":"init","session_id":"ka-1"}"#.to_string(),
608 ))
609 .await
610 .unwrap();
611 tokio::time::sleep(Duration::from_millis(120)).await;
612 drop(tee);
613 observer.await.unwrap();
614
615 let envelopes = seen.lock().await.clone();
616 let observes: Vec<_> = envelopes.iter().filter(|e| e["op"] == "observe").collect();
617 assert!(observes.len() > 1, "expected keep-alives, got {observes:?}");
619 for envelope in &observes {
620 assert_eq!(envelope["payload"]["session_id"], "ka-1");
621 assert_eq!(envelope["payload"]["event"]["stream_state"], "idle");
622 }
623 assert_eq!(envelopes.last().unwrap()["op"], "end");
624 }
625
626 #[tokio::test]
627 async fn the_pump_stops_when_the_far_side_goes_away() {
628 struct Closed;
631 impl AsyncWrite for Closed {
632 fn poll_write(
633 self: Pin<&mut Self>,
634 _cx: &mut TaskContext<'_>,
635 _buf: &[u8],
636 ) -> Poll<std::io::Result<usize>> {
637 Poll::Ready(Err(std::io::Error::from(std::io::ErrorKind::BrokenPipe)))
638 }
639 fn poll_flush(
640 self: Pin<&mut Self>,
641 _cx: &mut TaskContext<'_>,
642 ) -> Poll<std::io::Result<()>> {
643 Poll::Ready(Ok(()))
644 }
645 fn poll_shutdown(
646 self: Pin<&mut Self>,
647 _cx: &mut TaskContext<'_>,
648 ) -> Poll<std::io::Result<()>> {
649 Poll::Ready(Ok(()))
650 }
651 }
652 let (tee, mut lines) = mpsc::channel(4);
653 pump(
655 &b"{\"type\":\"result\"}\n"[..],
656 Closed,
657 Direction::FromClaude,
658 tee,
659 )
660 .await;
661 assert!(lines.try_recv().is_err());
662 }
663
664 #[test]
665 fn a_full_tee_drops_lines_rather_than_blocking() {
666 let (tee, _lines) = mpsc::channel(1);
667 let mut buffer = Vec::new();
668 tee_chunk(b"a\nb\nc\n", Direction::ToClaude, &tee, &mut buffer);
669 assert_eq!(tee.capacity(), 0);
671 }
672}