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, watch};
35
36use crate::claude::model_config::get_model_registry;
37use crate::daemon::client::DaemonClient;
38use crate::daemon::protocol::DaemonEnvelope;
39use crate::daemon::server;
40use crate::sessions::stream::{Direction, StreamTracker};
41
42const SERVICE: &str = "sessions";
44
45const REPORT_TIMEOUT: Duration = Duration::from_secs(2);
48
49const KEEPALIVE_INTERVAL: Duration = Duration::from_secs(30);
56
57const TEE_CAPACITY: usize = 256;
61
62const MAX_LINE_BYTES: usize = 1024 * 1024;
66
67const PUMP_BUF_BYTES: usize = 64 * 1024;
69
70const SIGNAL_EXIT_BASE: i32 = 128;
73
74const MAX_OSC_SEQUENCE_BYTES: usize = 8 * 1024;
80
81const NO_TITLE_REWRITE_ENV: &str = "OMNI_DEV_CLAUDE_WRAP_NO_TITLE_REWRITE";
86
87#[derive(Parser)]
89pub struct ClaudeWrapCommand {
90 #[arg(long, value_name = "PATH")]
92 pub socket: Option<PathBuf>,
93
94 #[arg(
96 trailing_var_arg = true,
97 allow_hyphen_values = true,
98 value_name = "CMD"
99 )]
100 pub argv: Vec<String>,
101}
102
103impl ClaudeWrapCommand {
104 pub async fn execute(self) -> Result<()> {
113 let code = self.run().await?;
114 std::process::exit(code)
115 }
116
117 async fn run(self) -> Result<i32> {
121 let Some((program, args)) = self.argv.split_first() else {
122 bail!(
123 "claude-wrap needs a command to run, e.g. \
124 `omni-dev claude-wrap -- claude --output-format stream-json`"
125 );
126 };
127
128 if std::io::stdout().is_terminal() {
133 return Err(exec_replace(program, args));
134 }
135
136 wrap(program, args, self.socket).await
137 }
138}
139
140fn exec_replace(program: &str, args: &[String]) -> anyhow::Error {
143 let error = std::process::Command::new(program).args(args).exec();
144 anyhow::Error::new(error).context(format!("failed to exec {program}"))
145}
146
147async fn wrap(program: &str, args: &[String], socket: Option<PathBuf>) -> Result<i32> {
149 wrap_io(
150 program,
151 args,
152 socket,
153 tokio::io::stdin(),
154 tokio::io::stdout(),
155 )
156 .await
157}
158
159async fn wrap_io<R, W>(
167 program: &str,
168 args: &[String],
169 socket: Option<PathBuf>,
170 input: R,
171 output: W,
172) -> Result<i32>
173where
174 R: AsyncRead + Unpin + Send + 'static,
175 W: AsyncWrite + Unpin + Send + 'static,
176{
177 let mut child = tokio::process::Command::new(program)
183 .args(args)
184 .stdin(Stdio::piped())
185 .stdout(Stdio::piped())
186 .stderr(Stdio::inherit())
187 .spawn()
188 .with_context(|| format!("failed to spawn {program}"))?;
189
190 let child_stdin = child
191 .stdin
192 .take()
193 .ok_or_else(|| anyhow!("failed to capture the wrapped process's stdin"))?;
194 let child_stdout = child
195 .stdout
196 .take()
197 .ok_or_else(|| anyhow!("failed to capture the wrapped process's stdout"))?;
198 let child_pid = child.id();
199
200 let (tee, lines) = mpsc::channel::<(Direction, String)>(TEE_CAPACITY);
201 let (title_tx, title_rx) = watch::channel::<Option<String>>(None);
202 let observer = tokio::spawn(observe(lines, socket, KEEPALIVE_INTERVAL, title_tx));
203 let signals = tokio::spawn(forward_signals(child_pid));
204
205 let from_child_title_rx = title_rewrite_enabled().then_some(title_rx);
208 let from_child = tokio::spawn(pump(
209 child_stdout,
210 output,
211 Direction::FromClaude,
212 tee.clone(),
213 from_child_title_rx,
214 ));
215 let to_child = tokio::spawn(pump(
216 input,
217 child_stdin,
218 Direction::ToClaude,
219 tee.clone(),
220 None,
221 ));
222 drop(tee);
223
224 let _ = from_child.await;
228 to_child.abort();
232 let _ = to_child.await;
233 signals.abort();
234
235 let status = child
236 .wait()
237 .await
238 .context("failed to wait for the wrapped process")?;
239 let _ = observer.await;
240 Ok(exit_code(status))
241}
242
243async fn pump<R, W>(
272 mut reader: R,
273 mut writer: W,
274 direction: Direction,
275 tee: mpsc::Sender<(Direction, String)>,
276 mut title_rx: Option<watch::Receiver<Option<String>>>,
277) where
278 R: AsyncRead + Unpin,
279 W: AsyncWrite + Unpin,
280{
281 let mut buffer = vec![0u8; PUMP_BUF_BYTES];
282 let mut line = Vec::new();
283 let mut rewriter = TitleRewriter::default();
284 loop {
285 let title_changed = async {
286 match title_rx.as_mut() {
287 Some(rx) => rx.changed().await,
288 None => std::future::pending().await,
291 }
292 };
293 tokio::select! {
294 read_result = reader.read(&mut buffer) => {
295 let read = match read_result {
296 Ok(0) | Err(_) => break,
297 Ok(read) => read,
298 };
299 let chunk = &buffer[..read];
300 let write_result = match &title_rx {
301 Some(rx) => {
302 let prefix = rx.borrow().clone();
303 writer
304 .write_all(&rewriter.rewrite(chunk, prefix.as_deref()))
305 .await
306 }
307 None => writer.write_all(chunk).await,
308 };
309 if write_result.is_err() || writer.flush().await.is_err() {
310 break;
311 }
312 tee_chunk(chunk, direction, &tee, &mut line);
313 }
314 changed = title_changed => {
315 match changed {
316 Ok(()) => {
317 let Some(rx) = &title_rx else { continue };
318 let prefix = rx.borrow().clone();
319 let Some(bytes) = rewriter.resync(prefix.as_deref()) else { continue };
320 if writer.write_all(&bytes).await.is_err()
321 || writer.flush().await.is_err()
322 {
323 break;
324 }
325 }
326 Err(_) => title_rx = None,
330 }
331 }
332 }
333 }
334 let _ = writer.shutdown().await;
337}
338
339#[derive(Debug, Default)]
369struct TitleRewriter {
370 pending: Vec<u8>,
374 title: Vec<u8>,
377 code: u8,
379 phase: Phase,
380 last_code: Option<u8>,
384 last_title: Option<Vec<u8>>,
385 last_terminator: Option<Vec<u8>>,
386}
387
388#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
391enum Phase {
392 #[default]
394 Idle,
395 Esc,
397 Open,
399 Code,
401 Title,
403 TitleEsc,
406}
407
408const ESC: u8 = 0x1B;
409const BEL: u8 = 0x07;
410
411impl TitleRewriter {
412 fn rewrite(&mut self, chunk: &[u8], prefix: Option<&str>) -> Vec<u8> {
417 let mut out = Vec::with_capacity(chunk.len());
418 for &byte in chunk {
419 match self.phase {
420 Phase::Idle => {
421 if byte == ESC {
422 self.pending.push(byte);
423 self.phase = Phase::Esc;
424 } else {
425 out.push(byte);
426 }
427 }
428 Phase::Esc => {
429 self.pending.push(byte);
430 if byte == b']' {
431 self.phase = Phase::Open;
432 } else {
433 self.flush_pending(&mut out);
434 }
435 }
436 Phase::Open => {
437 self.pending.push(byte);
438 if byte == b'0' || byte == b'2' {
439 self.code = byte;
440 self.phase = Phase::Code;
441 } else {
442 self.flush_pending(&mut out);
443 }
444 }
445 Phase::Code => {
446 self.pending.push(byte);
447 if byte == b';' {
448 self.phase = Phase::Title;
449 } else {
450 self.flush_pending(&mut out);
451 }
452 }
453 Phase::Title => {
454 self.pending.push(byte);
455 if byte == BEL {
456 self.finish(&mut out, prefix, &[BEL]);
457 } else if byte == ESC {
458 self.phase = Phase::TitleEsc;
459 } else {
460 self.title.push(byte);
461 }
462 }
463 Phase::TitleEsc => {
464 self.pending.push(byte);
465 if byte == b'\\' {
466 self.finish(&mut out, prefix, &[ESC, b'\\']);
467 } else {
468 self.flush_pending(&mut out);
471 }
472 }
473 }
474 if self.pending.len() > MAX_OSC_SEQUENCE_BYTES {
475 self.flush_pending(&mut out);
476 }
477 }
478 out
479 }
480
481 fn finish(&mut self, out: &mut Vec<u8>, prefix: Option<&str>, terminator: &[u8]) {
485 let rewritten = prefix.zip(std::str::from_utf8(&self.title).ok());
486 match rewritten {
487 Some((prefix, title)) => {
488 out.push(ESC);
489 out.push(b']');
490 out.push(self.code);
491 out.push(b';');
492 out.extend_from_slice(prefix.as_bytes());
493 out.extend_from_slice(title.as_bytes());
494 out.extend_from_slice(terminator);
495 }
496 None => out.extend_from_slice(&self.pending),
497 }
498 self.last_code = Some(self.code);
499 self.last_title = Some(self.title.clone());
500 self.last_terminator = Some(terminator.to_vec());
501 self.reset();
502 }
503
504 fn resync(&self, prefix: Option<&str>) -> Option<Vec<u8>> {
512 let prefix = prefix?;
513 let code = self.last_code?;
514 let title = std::str::from_utf8(self.last_title.as_ref()?).ok()?;
515 let terminator = self.last_terminator.as_ref()?;
516 let mut out = vec![ESC, b']', code, b';'];
517 out.extend_from_slice(prefix.as_bytes());
518 out.extend_from_slice(title.as_bytes());
519 out.extend_from_slice(terminator);
520 Some(out)
521 }
522
523 fn flush_pending(&mut self, out: &mut Vec<u8>) {
525 out.extend_from_slice(&self.pending);
526 self.reset();
527 }
528
529 fn reset(&mut self) {
530 self.pending.clear();
531 self.title.clear();
532 self.code = 0;
533 self.phase = Phase::Idle;
534 }
535}
536
537fn title_rewrite_enabled() -> bool {
540 !std::env::var(NO_TITLE_REWRITE_ENV).is_ok_and(|v| flag_is_truthy(&v))
541}
542
543fn flag_is_truthy(value: &str) -> bool {
547 matches!(
548 value.trim().to_ascii_lowercase().as_str(),
549 "1" | "true" | "yes" | "on"
550 )
551}
552
553fn title_prefix_for_model(model_id: &str) -> String {
559 let family = get_model_registry().get_model_family(model_id);
560 format!("{} {} · ", family.glyph(), family.label())
561}
562
563fn tee_chunk(
567 chunk: &[u8],
568 direction: Direction,
569 tee: &mpsc::Sender<(Direction, String)>,
570 line: &mut Vec<u8>,
571) {
572 for &byte in chunk {
573 if byte == b'\n' {
574 if line.len() < MAX_LINE_BYTES {
575 if let Ok(text) = std::str::from_utf8(line) {
576 let _ = tee.try_send((direction, text.to_string()));
577 }
578 }
579 line.clear();
580 } else if line.len() < MAX_LINE_BYTES {
581 line.push(byte);
582 }
583 }
584}
585
586async fn observe(
592 mut lines: mpsc::Receiver<(Direction, String)>,
593 socket: Option<PathBuf>,
594 every: Duration,
595 title_tx: watch::Sender<Option<String>>,
596) {
597 let Ok(socket) = server::resolve_socket(socket) else {
598 return;
599 };
600 let mut tracker = StreamTracker::new();
601 let mut last_model: Option<String> = None;
602 let mut keepalive = tokio::time::interval(every);
603 keepalive.tick().await;
606
607 loop {
608 let observed = tokio::select! {
609 line = lines.recv() => match line {
610 Some((direction, text)) => tracker.observe_line(direction, &text),
611 None => break,
612 },
613 _ = keepalive.tick() => tracker.keepalive(),
614 };
615 if tracker.model() != last_model.as_deref() {
620 last_model = tracker.model().map(str::to_string);
621 let prefix = last_model.as_deref().map(title_prefix_for_model);
622 let _ = title_tx.send(prefix);
623 }
624 if let Some(request) = observed {
625 if let Ok(payload) = serde_json::to_value(request) {
626 report(&socket, "observe", payload).await;
627 }
628 }
629 }
630
631 if let Some(session_id) = tracker.session_id() {
632 report(&socket, "end", json!({ "session_id": session_id })).await;
633 }
634}
635
636async fn report(socket: &Path, op: &str, payload: Value) {
639 let envelope = DaemonEnvelope::service(SERVICE, op, payload);
640 let _ = tokio::time::timeout(REPORT_TIMEOUT, DaemonClient::new(socket).request(envelope)).await;
641}
642
643async fn forward_signals(child_pid: Option<u32>) {
646 let Some(pid) = child_pid.and_then(|pid| i32::try_from(pid).ok()) else {
647 return;
648 };
649 let pid = nix::unistd::Pid::from_raw(pid);
650 let (Ok(mut terminate), Ok(mut interrupt)) = (
651 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()),
652 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt()),
653 ) else {
654 return;
655 };
656 loop {
657 let signal = tokio::select! {
658 _ = terminate.recv() => nix::sys::signal::Signal::SIGTERM,
659 _ = interrupt.recv() => nix::sys::signal::Signal::SIGINT,
660 };
661 let _ = nix::sys::signal::kill(pid, signal);
662 }
663}
664
665fn exit_code(status: ExitStatus) -> i32 {
668 status
669 .code()
670 .or_else(|| status.signal().map(|signal| SIGNAL_EXIT_BASE + signal))
671 .unwrap_or(1)
672}
673
674#[cfg(test)]
675#[allow(clippy::unwrap_used, clippy::expect_used)]
676mod tests {
677 use std::pin::Pin;
678 use std::sync::{Arc, Mutex};
679 use std::task::{Context as TaskContext, Poll};
680
681 use tokio::io::AsyncBufReadExt;
682 use tokio::net::UnixListener;
683
684 use super::*;
685
686 #[derive(Clone, Default)]
689 struct Sink(Arc<Mutex<Vec<u8>>>);
690
691 impl Sink {
692 fn contents(&self) -> String {
693 String::from_utf8(self.0.lock().unwrap().clone()).unwrap()
694 }
695 }
696
697 impl AsyncWrite for Sink {
698 fn poll_write(
699 self: Pin<&mut Self>,
700 _cx: &mut TaskContext<'_>,
701 buf: &[u8],
702 ) -> Poll<std::io::Result<usize>> {
703 self.0.lock().unwrap().extend_from_slice(buf);
704 Poll::Ready(Ok(buf.len()))
705 }
706
707 fn poll_flush(
708 self: Pin<&mut Self>,
709 _cx: &mut TaskContext<'_>,
710 ) -> Poll<std::io::Result<()>> {
711 Poll::Ready(Ok(()))
712 }
713
714 fn poll_shutdown(
715 self: Pin<&mut Self>,
716 _cx: &mut TaskContext<'_>,
717 ) -> Poll<std::io::Result<()>> {
718 Poll::Ready(Ok(()))
719 }
720 }
721
722 async fn wrap_script(script: &str, socket: Option<PathBuf>) -> (i32, String) {
725 let sink = Sink::default();
726 let code = wrap_io(
727 "/bin/sh",
728 &["-c".to_string(), script.to_string()],
729 socket,
730 tokio::io::empty(),
731 sink.clone(),
732 )
733 .await
734 .unwrap();
735 (code, sink.contents())
736 }
737
738 fn parse(args: &[&str]) -> ClaudeWrapCommand {
740 let mut argv = vec!["claude-wrap"];
741 argv.extend_from_slice(args);
742 ClaudeWrapCommand::try_parse_from(argv).unwrap()
743 }
744
745 fn fake_daemon() -> (
747 tempfile::TempDir,
748 PathBuf,
749 Arc<tokio::sync::Mutex<Vec<Value>>>,
750 ) {
751 let dir = tempfile::tempdir_in("/tmp").unwrap();
752 let socket = dir.path().join("d.sock");
753 let listener = UnixListener::bind(&socket).unwrap();
754 let seen = Arc::new(tokio::sync::Mutex::new(Vec::new()));
755 let recorder = Arc::clone(&seen);
756 tokio::spawn(async move {
757 loop {
758 let Ok((stream, _)) = listener.accept().await else {
759 return;
760 };
761 let recorder = Arc::clone(&recorder);
762 tokio::spawn(async move {
763 let (read, mut write) = stream.into_split();
764 let mut line = String::new();
765 if tokio::io::BufReader::new(read)
766 .read_line(&mut line)
767 .await
768 .is_ok()
769 {
770 if let Ok(value) = serde_json::from_str::<Value>(&line) {
771 recorder.lock().await.push(value);
772 }
773 }
774 let _ = write.write_all(b"{\"ok\":true,\"payload\":{}}\n").await;
775 });
776 }
777 });
778 (dir, socket, seen)
779 }
780
781 #[test]
782 fn trailing_args_are_captured_verbatim_after_a_separator() {
783 let cmd = parse(&["--", "node", "cli.js", "--output-format", "stream-json"]);
784 assert_eq!(
785 cmd.argv,
786 vec!["node", "cli.js", "--output-format", "stream-json"]
787 );
788 assert!(cmd.socket.is_none());
789 }
790
791 #[test]
792 fn the_socket_override_is_parsed_before_the_separator() {
793 let cmd = parse(&["--socket", "/tmp/d.sock", "--", "claude", "-p"]);
794 assert_eq!(cmd.socket.unwrap(), PathBuf::from("/tmp/d.sock"));
795 assert_eq!(cmd.argv, vec!["claude", "-p"]);
796 }
797
798 #[tokio::test]
799 async fn an_empty_command_is_a_usage_error() {
800 let error = parse(&[]).run().await.unwrap_err();
801 assert!(error.to_string().contains("needs a command to run"));
802 }
803
804 #[tokio::test]
818 async fn signal_forwarding_gives_up_when_there_is_no_child() {
819 forward_signals(None).await;
822 }
823
824 #[tokio::test]
825 async fn the_child_is_wrapped_losslessly_and_its_exit_code_survives() {
826 let (_dir, socket, _seen) = fake_daemon();
827 let (code, forwarded) =
830 wrap_script("printf 'hello\\nnot json\\ntrailing'; exit 7", Some(socket)).await;
831 assert_eq!(code, 7);
832 assert_eq!(forwarded, "hello\nnot json\ntrailing");
833 }
834
835 #[tokio::test]
836 async fn stream_state_transitions_reach_the_daemon() {
837 let (_dir, socket, seen) = fake_daemon();
838 let script = concat!(
840 r#"printf '{"type":"system","subtype":"init","session_id":"s-1","cwd":"/w"}\n';"#,
841 r#"printf '{"type":"assistant","session_id":"s-1"}\n';"#,
842 r#"printf '{"type":"control_request","request_id":"r1","request":{"subtype":"can_use_tool"}}\n';"#,
843 r#"printf '{"type":"result"}\n'"#,
844 );
845 let (code, _forwarded) = wrap_script(script, Some(socket)).await;
846 assert_eq!(code, 0);
847
848 let envelopes = seen.lock().await.clone();
849 let states: Vec<String> = envelopes
850 .iter()
851 .filter(|e| e["op"] == "observe")
852 .filter_map(|e| e["payload"]["event"]["stream_state"].as_str())
853 .map(ToString::to_string)
854 .collect();
855 assert_eq!(
856 states,
857 vec!["idle", "working", "waiting_for_permission", "idle"]
858 );
859 assert_eq!(envelopes[0]["service"], "sessions");
861 assert_eq!(envelopes[0]["payload"]["session_id"], "s-1");
862 assert_eq!(envelopes[0]["payload"]["cwd"], "/w");
863 let ended = envelopes.last().unwrap();
864 assert_eq!(ended["op"], "end");
865 assert_eq!(ended["payload"]["session_id"], "s-1");
866 }
867
868 #[tokio::test]
869 async fn a_missing_daemon_never_affects_the_child() {
870 let dir = tempfile::tempdir_in("/tmp").unwrap();
872 let script = concat!(
873 r#"printf '{"type":"system","subtype":"init","session_id":"s-1"}\n';"#,
874 r#"printf 'not json at all\n';"#,
875 r#"exit 3"#,
876 );
877 let (code, forwarded) = wrap_script(script, Some(dir.path().join("absent.sock"))).await;
878 assert_eq!(code, 3);
879 assert!(forwarded.ends_with("not json at all\n"));
880 }
881
882 #[tokio::test]
883 async fn a_child_killed_by_a_signal_reports_the_shell_convention() {
884 let (_dir, socket, _seen) = fake_daemon();
885 let (code, _forwarded) = wrap_script("kill -TERM $$", Some(socket)).await;
886 assert_eq!(code, SIGNAL_EXIT_BASE + 15);
887 }
888
889 #[tokio::test]
890 async fn spawning_a_missing_program_is_an_error_not_a_panic() {
891 let error = wrap_io(
892 "/nonexistent/omni-dev-test-binary",
893 &[],
894 None,
895 tokio::io::empty(),
896 Sink::default(),
897 )
898 .await
899 .unwrap_err();
900 assert!(error.to_string().contains("failed to spawn"));
901 }
902
903 #[test]
904 fn over_long_and_non_utf8_lines_are_dropped_but_still_forwarded() {
905 let (tee, mut lines) = mpsc::channel(4);
906 let mut buffer = Vec::new();
907 let mut chunk = vec![b'x'; MAX_LINE_BYTES + 1];
908 chunk.push(b'\n');
909 chunk.extend_from_slice(&[0xff, 0xfe, b'\n']);
910 chunk.extend_from_slice(b"{\"type\":\"result\"}\n");
911 tee_chunk(&chunk, Direction::FromClaude, &tee, &mut buffer);
912 let (direction, text) = lines.try_recv().unwrap();
914 assert_eq!(direction, Direction::FromClaude);
915 assert_eq!(text, r#"{"type":"result"}"#);
916 assert!(lines.try_recv().is_err());
917 }
918
919 #[tokio::test]
920 async fn the_keepalive_re_reports_a_silent_session() {
921 let (_dir, socket, seen) = fake_daemon();
924 let (tee, lines) = mpsc::channel(4);
925 let (title_tx, _title_rx) = watch::channel(None);
926 let observer = tokio::spawn(observe(
927 lines,
928 Some(socket),
929 Duration::from_millis(20),
930 title_tx,
931 ));
932 tee.send((
933 Direction::FromClaude,
934 r#"{"type":"system","subtype":"init","session_id":"ka-1"}"#.to_string(),
935 ))
936 .await
937 .unwrap();
938 tokio::time::sleep(Duration::from_millis(120)).await;
939 drop(tee);
940 observer.await.unwrap();
941
942 let envelopes = seen.lock().await.clone();
943 let observes: Vec<_> = envelopes.iter().filter(|e| e["op"] == "observe").collect();
944 assert!(observes.len() > 1, "expected keep-alives, got {observes:?}");
946 for envelope in &observes {
947 assert_eq!(envelope["payload"]["session_id"], "ka-1");
948 assert_eq!(envelope["payload"]["event"]["stream_state"], "idle");
949 }
950 assert_eq!(envelopes.last().unwrap()["op"], "end");
951 }
952
953 #[tokio::test]
954 async fn the_pump_stops_when_the_far_side_goes_away() {
955 struct Closed;
958 impl AsyncWrite for Closed {
959 fn poll_write(
960 self: Pin<&mut Self>,
961 _cx: &mut TaskContext<'_>,
962 _buf: &[u8],
963 ) -> Poll<std::io::Result<usize>> {
964 Poll::Ready(Err(std::io::Error::from(std::io::ErrorKind::BrokenPipe)))
965 }
966 fn poll_flush(
967 self: Pin<&mut Self>,
968 _cx: &mut TaskContext<'_>,
969 ) -> Poll<std::io::Result<()>> {
970 Poll::Ready(Ok(()))
971 }
972 fn poll_shutdown(
973 self: Pin<&mut Self>,
974 _cx: &mut TaskContext<'_>,
975 ) -> Poll<std::io::Result<()>> {
976 Poll::Ready(Ok(()))
977 }
978 }
979 let (tee, mut lines) = mpsc::channel(4);
980 pump(
982 &b"{\"type\":\"result\"}\n"[..],
983 Closed,
984 Direction::FromClaude,
985 tee,
986 None,
987 )
988 .await;
989 assert!(lines.try_recv().is_err());
990 }
991
992 #[tokio::test]
993 async fn pump_resyncs_the_title_from_the_watch_channel_alone_with_no_further_reads() {
994 let (tee, _lines) = mpsc::channel(4);
999 let (title_tx, title_rx) = watch::channel(None);
1000 let sink = Sink::default();
1001 let (mut writer_end, reader_end) = tokio::io::duplex(1024);
1002 writer_end.write_all(b"\x1b]0;2.1.132\x07").await.unwrap();
1003
1004 let pump_task = tokio::spawn(pump(
1005 reader_end,
1006 sink.clone(),
1007 Direction::FromClaude,
1008 tee,
1009 Some(title_rx),
1010 ));
1011
1012 tokio::time::sleep(Duration::from_millis(20)).await;
1013 assert_eq!(sink.contents(), "\x1b]0;2.1.132\x07");
1014
1015 title_tx.send(Some(PREFIX.to_string())).unwrap();
1016 tokio::time::sleep(Duration::from_millis(20)).await;
1017 assert!(
1018 sink.contents().contains("\x1b]0;🟡 Opus · 2.1.132\x07"),
1019 "expected a resynced title, got: {:?}",
1020 sink.contents()
1021 );
1022
1023 drop(writer_end);
1024 let _ = pump_task.await;
1025 }
1026
1027 #[test]
1028 fn a_full_tee_drops_lines_rather_than_blocking() {
1029 let (tee, _lines) = mpsc::channel(1);
1030 let mut buffer = Vec::new();
1031 tee_chunk(b"a\nb\nc\n", Direction::ToClaude, &tee, &mut buffer);
1032 assert_eq!(tee.capacity(), 0);
1034 }
1035
1036 const PREFIX: &str = "🟡 Opus · ";
1037
1038 #[test]
1039 fn bel_terminated_title_is_rewritten_with_the_model_prefix() {
1040 let mut rewriter = TitleRewriter::default();
1041 let out = rewriter.rewrite(b"\x1b]0;2.1.132\x07", Some(PREFIX));
1042 assert_eq!(out, b"\x1b]0;\xf0\x9f\x9f\xa1 Opus \xc2\xb7 2.1.132\x07");
1043 assert_eq!(
1044 String::from_utf8(out).unwrap(),
1045 "\x1b]0;🟡 Opus · 2.1.132\x07"
1046 );
1047 }
1048
1049 #[test]
1050 fn st_terminated_title_is_rewritten_with_the_model_prefix() {
1051 let mut rewriter = TitleRewriter::default();
1052 let out = rewriter.rewrite(b"\x1b]2;2.1.132\x1b\\", Some(PREFIX));
1053 assert_eq!(
1054 String::from_utf8(out).unwrap(),
1055 "\x1b]2;🟡 Opus · 2.1.132\x1b\\"
1056 );
1057 }
1058
1059 #[test]
1060 fn no_model_known_yet_leaves_the_title_unchanged() {
1061 let mut rewriter = TitleRewriter::default();
1062 let chunk = b"\x1b]0;2.1.132\x07";
1063 let out = rewriter.rewrite(chunk, None);
1064 assert_eq!(out, chunk);
1065 }
1066
1067 #[test]
1068 fn a_non_osc_chunk_is_untouched() {
1069 let mut rewriter = TitleRewriter::default();
1070 let chunk = b"just some ordinary assistant output, no escapes here\n";
1071 let out = rewriter.rewrite(chunk, Some(PREFIX));
1072 assert_eq!(out, chunk);
1073 }
1074
1075 #[test]
1076 fn a_sequence_split_across_two_reads_is_still_rewritten() {
1077 let mut rewriter = TitleRewriter::default();
1078 let mut out = rewriter.rewrite(b"before \x1b]0;2.1", Some(PREFIX));
1079 out.extend(rewriter.rewrite(b".132\x07 after", Some(PREFIX)));
1080 assert_eq!(
1081 String::from_utf8(out).unwrap(),
1082 "before \x1b]0;🟡 Opus · 2.1.132\x07 after"
1083 );
1084 }
1085
1086 #[test]
1087 fn an_osc_code_other_than_0_or_2_is_left_untouched() {
1088 let mut rewriter = TitleRewriter::default();
1090 let chunk = b"\x1b]8;;https://example.invalid\x07link text\x1b]8;;\x07";
1091 let out = rewriter.rewrite(chunk, Some(PREFIX));
1092 assert_eq!(out, chunk);
1093 }
1094
1095 #[test]
1096 fn an_overlong_sequence_without_a_terminator_is_flushed_unchanged() {
1097 let mut rewriter = TitleRewriter::default();
1098 let mut chunk = b"\x1b]0;".to_vec();
1099 chunk.extend(std::iter::repeat_n(b'x', MAX_OSC_SEQUENCE_BYTES + 16));
1100 let out = rewriter.rewrite(&chunk, Some(PREFIX));
1101 assert_eq!(out, chunk);
1102 }
1103
1104 #[test]
1105 fn a_malformed_st_terminator_is_flushed_unchanged() {
1106 let mut rewriter = TitleRewriter::default();
1109 let chunk = b"\x1b]0;oops\x1bnope";
1110 let out = rewriter.rewrite(chunk, Some(PREFIX));
1111 assert_eq!(out, chunk);
1112 }
1113
1114 #[test]
1115 fn resync_returns_none_before_anything_has_completed() {
1116 let rewriter = TitleRewriter::default();
1117 assert_eq!(rewriter.resync(Some(PREFIX)), None);
1118 }
1119
1120 #[test]
1121 fn resync_returns_none_without_a_prefix() {
1122 let mut rewriter = TitleRewriter::default();
1123 rewriter.rewrite(b"\x1b]0;2.1.132\x07", None);
1124 assert_eq!(rewriter.resync(None), None);
1125 }
1126
1127 #[test]
1128 fn resync_reformats_a_title_that_was_forwarded_before_the_model_was_known() {
1129 let mut rewriter = TitleRewriter::default();
1134 let forwarded = rewriter.rewrite(b"\x1b]0;2.1.132\x07", None);
1135 assert_eq!(forwarded, b"\x1b]0;2.1.132\x07");
1136 let corrected = rewriter.resync(Some(PREFIX)).unwrap();
1137 assert_eq!(
1138 String::from_utf8(corrected).unwrap(),
1139 "\x1b]0;🟡 Opus · 2.1.132\x07"
1140 );
1141 }
1142
1143 #[test]
1144 fn resync_reformats_under_a_changed_prefix_on_a_mid_session_model_switch() {
1145 let mut rewriter = TitleRewriter::default();
1146 rewriter.rewrite(b"\x1b]0;2.1.132\x07", Some(PREFIX));
1147 let resynced = rewriter.resync(Some("🟢 Sonnet · ")).unwrap();
1148 assert_eq!(
1149 String::from_utf8(resynced).unwrap(),
1150 "\x1b]0;🟢 Sonnet · 2.1.132\x07"
1151 );
1152 }
1153
1154 #[test]
1155 fn flag_is_truthy_recognises_on_and_off_values() {
1156 for value in ["1", "true", "TRUE", "yes", "on", " on "] {
1157 assert!(flag_is_truthy(value), "{value:?} should be truthy");
1158 }
1159 for value in ["0", "false", "no", "off", ""] {
1160 assert!(!flag_is_truthy(value), "{value:?} should not be truthy");
1161 }
1162 }
1163
1164 #[tokio::test]
1165 async fn the_wrapper_rewrites_claudes_title_end_to_end() {
1166 let (_dir, socket, _seen) = fake_daemon();
1167 let script = concat!(
1177 r#"printf '\033]0;2.1.132\007';"#,
1178 r#"printf '{"type":"system","subtype":"init","session_id":"s-1","model":"claude-opus-4-8"}\n';"#,
1179 r#"sleep 0.2;"#,
1180 r#"printf 'plain text after the title\n'"#,
1181 );
1182 let (_code, forwarded) = wrap_script(script, Some(socket)).await;
1183 assert!(
1187 forwarded.contains("\x1b]0;2.1.132\x07"),
1188 "the original title should still have been forwarded verbatim: {forwarded:?}"
1189 );
1190 assert!(
1193 forwarded.contains("\x1b]0;🟡 Opus · 2.1.132\x07"),
1194 "the title should have been corrected once the model became known: {forwarded:?}"
1195 );
1196 let uncorrected_at = forwarded.find("\x1b]0;2.1.132\x07").unwrap();
1197 let corrected_at = forwarded.find("\x1b]0;🟡 Opus · 2.1.132\x07").unwrap();
1198 assert!(uncorrected_at < corrected_at);
1199 assert!(forwarded.ends_with("plain text after the title\n"));
1200 }
1201
1202 }