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(
203 lines,
204 socket,
205 KEEPALIVE_INTERVAL,
206 title_tx,
207 child_pid,
208 ));
209 let signals = tokio::spawn(forward_signals(child_pid));
210
211 let from_child_title_rx = title_rewrite_enabled().then_some(title_rx);
214 let from_child = tokio::spawn(pump(
215 child_stdout,
216 output,
217 Direction::FromClaude,
218 tee.clone(),
219 from_child_title_rx,
220 ));
221 let to_child = tokio::spawn(pump(
222 input,
223 child_stdin,
224 Direction::ToClaude,
225 tee.clone(),
226 None,
227 ));
228 drop(tee);
229
230 let _ = from_child.await;
234 to_child.abort();
238 let _ = to_child.await;
239 signals.abort();
240
241 let status = child
242 .wait()
243 .await
244 .context("failed to wait for the wrapped process")?;
245 let _ = observer.await;
246 Ok(exit_code(status))
247}
248
249async fn pump<R, W>(
278 mut reader: R,
279 mut writer: W,
280 direction: Direction,
281 tee: mpsc::Sender<(Direction, String)>,
282 mut title_rx: Option<watch::Receiver<Option<String>>>,
283) where
284 R: AsyncRead + Unpin,
285 W: AsyncWrite + Unpin,
286{
287 let mut buffer = vec![0u8; PUMP_BUF_BYTES];
288 let mut line = Vec::new();
289 let mut rewriter = TitleRewriter::default();
290 loop {
291 let title_changed = async {
292 match title_rx.as_mut() {
293 Some(rx) => rx.changed().await,
294 None => std::future::pending().await,
297 }
298 };
299 tokio::select! {
300 read_result = reader.read(&mut buffer) => {
301 let read = match read_result {
302 Ok(0) | Err(_) => break,
303 Ok(read) => read,
304 };
305 let chunk = &buffer[..read];
306 let write_result = match &title_rx {
307 Some(rx) => {
308 let prefix = rx.borrow().clone();
309 writer
310 .write_all(&rewriter.rewrite(chunk, prefix.as_deref()))
311 .await
312 }
313 None => writer.write_all(chunk).await,
314 };
315 if write_result.is_err() || writer.flush().await.is_err() {
316 break;
317 }
318 tee_chunk(chunk, direction, &tee, &mut line);
319 }
320 changed = title_changed => {
321 match changed {
322 Ok(()) => {
323 let Some(rx) = &title_rx else { continue };
324 let prefix = rx.borrow().clone();
325 let Some(bytes) = rewriter.resync(prefix.as_deref()) else { continue };
326 if writer.write_all(&bytes).await.is_err()
327 || writer.flush().await.is_err()
328 {
329 break;
330 }
331 }
332 Err(_) => title_rx = None,
336 }
337 }
338 }
339 }
340 let _ = writer.shutdown().await;
343}
344
345#[derive(Debug, Default)]
375struct TitleRewriter {
376 pending: Vec<u8>,
380 title: Vec<u8>,
383 code: u8,
385 phase: Phase,
386 last_code: Option<u8>,
390 last_title: Option<Vec<u8>>,
391 last_terminator: Option<Vec<u8>>,
392}
393
394#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
397enum Phase {
398 #[default]
400 Idle,
401 Esc,
403 Open,
405 Code,
407 Title,
409 TitleEsc,
412}
413
414const ESC: u8 = 0x1B;
415const BEL: u8 = 0x07;
416
417impl TitleRewriter {
418 fn rewrite(&mut self, chunk: &[u8], prefix: Option<&str>) -> Vec<u8> {
423 let mut out = Vec::with_capacity(chunk.len());
424 for &byte in chunk {
425 match self.phase {
426 Phase::Idle => {
427 if byte == ESC {
428 self.pending.push(byte);
429 self.phase = Phase::Esc;
430 } else {
431 out.push(byte);
432 }
433 }
434 Phase::Esc => {
435 self.pending.push(byte);
436 if byte == b']' {
437 self.phase = Phase::Open;
438 } else {
439 self.flush_pending(&mut out);
440 }
441 }
442 Phase::Open => {
443 self.pending.push(byte);
444 if byte == b'0' || byte == b'2' {
445 self.code = byte;
446 self.phase = Phase::Code;
447 } else {
448 self.flush_pending(&mut out);
449 }
450 }
451 Phase::Code => {
452 self.pending.push(byte);
453 if byte == b';' {
454 self.phase = Phase::Title;
455 } else {
456 self.flush_pending(&mut out);
457 }
458 }
459 Phase::Title => {
460 self.pending.push(byte);
461 if byte == BEL {
462 self.finish(&mut out, prefix, &[BEL]);
463 } else if byte == ESC {
464 self.phase = Phase::TitleEsc;
465 } else {
466 self.title.push(byte);
467 }
468 }
469 Phase::TitleEsc => {
470 self.pending.push(byte);
471 if byte == b'\\' {
472 self.finish(&mut out, prefix, &[ESC, b'\\']);
473 } else {
474 self.flush_pending(&mut out);
477 }
478 }
479 }
480 if self.pending.len() > MAX_OSC_SEQUENCE_BYTES {
481 self.flush_pending(&mut out);
482 }
483 }
484 out
485 }
486
487 fn finish(&mut self, out: &mut Vec<u8>, prefix: Option<&str>, terminator: &[u8]) {
491 let rewritten = prefix.zip(std::str::from_utf8(&self.title).ok());
492 match rewritten {
493 Some((prefix, title)) => {
494 out.push(ESC);
495 out.push(b']');
496 out.push(self.code);
497 out.push(b';');
498 out.extend_from_slice(prefix.as_bytes());
499 out.extend_from_slice(title.as_bytes());
500 out.extend_from_slice(terminator);
501 }
502 None => out.extend_from_slice(&self.pending),
503 }
504 self.last_code = Some(self.code);
505 self.last_title = Some(self.title.clone());
506 self.last_terminator = Some(terminator.to_vec());
507 self.reset();
508 }
509
510 fn resync(&self, prefix: Option<&str>) -> Option<Vec<u8>> {
518 let prefix = prefix?;
519 let code = self.last_code?;
520 let title = std::str::from_utf8(self.last_title.as_ref()?).ok()?;
521 let terminator = self.last_terminator.as_ref()?;
522 let mut out = vec![ESC, b']', code, b';'];
523 out.extend_from_slice(prefix.as_bytes());
524 out.extend_from_slice(title.as_bytes());
525 out.extend_from_slice(terminator);
526 Some(out)
527 }
528
529 fn flush_pending(&mut self, out: &mut Vec<u8>) {
531 out.extend_from_slice(&self.pending);
532 self.reset();
533 }
534
535 fn reset(&mut self) {
536 self.pending.clear();
537 self.title.clear();
538 self.code = 0;
539 self.phase = Phase::Idle;
540 }
541}
542
543fn title_rewrite_enabled() -> bool {
546 !std::env::var(NO_TITLE_REWRITE_ENV).is_ok_and(|v| flag_is_truthy(&v))
547}
548
549fn flag_is_truthy(value: &str) -> bool {
553 matches!(
554 value.trim().to_ascii_lowercase().as_str(),
555 "1" | "true" | "yes" | "on"
556 )
557}
558
559fn title_prefix_for_model(model_id: &str) -> String {
565 let family = get_model_registry().get_model_family(model_id);
566 format!("{} {} · ", family.glyph(), family.label())
567}
568
569fn tee_chunk(
573 chunk: &[u8],
574 direction: Direction,
575 tee: &mpsc::Sender<(Direction, String)>,
576 line: &mut Vec<u8>,
577) {
578 for &byte in chunk {
579 if byte == b'\n' {
580 if line.len() < MAX_LINE_BYTES {
581 if let Ok(text) = std::str::from_utf8(line) {
582 let _ = tee.try_send((direction, text.to_string()));
583 }
584 }
585 line.clear();
586 } else if line.len() < MAX_LINE_BYTES {
587 line.push(byte);
588 }
589 }
590}
591
592async fn observe(
603 mut lines: mpsc::Receiver<(Direction, String)>,
604 socket: Option<PathBuf>,
605 every: Duration,
606 title_tx: watch::Sender<Option<String>>,
607 child_pid: Option<u32>,
608) {
609 let Ok(socket) = server::resolve_socket(socket) else {
610 return;
611 };
612 let mut tracker = StreamTracker::new();
613 let mut last_model: Option<String> = None;
614 let mut keepalive = tokio::time::interval(every);
615 keepalive.tick().await;
618
619 loop {
620 let observed = tokio::select! {
621 line = lines.recv() => match line {
622 Some((direction, text)) => tracker.observe_line(direction, &text),
623 None => break,
624 },
625 _ = keepalive.tick() => tracker.keepalive(),
626 };
627 if tracker.model() != last_model.as_deref() {
632 last_model = tracker.model().map(str::to_string);
633 let prefix = last_model.as_deref().map(title_prefix_for_model);
634 let _ = title_tx.send(prefix);
635 }
636 if let Some(mut request) = observed {
637 request.pid = child_pid;
638 if let Ok(payload) = serde_json::to_value(request) {
639 report(&socket, "observe", payload).await;
640 }
641 }
642 }
643
644 if let Some(session_id) = tracker.session_id() {
645 let mut payload = json!({ "session_id": session_id });
646 if let Some(pid) = child_pid {
647 payload["pid"] = Value::from(pid);
648 }
649 report(&socket, "end", payload).await;
650 }
651}
652
653async fn report(socket: &Path, op: &str, payload: Value) {
656 let envelope = DaemonEnvelope::service(SERVICE, op, payload);
657 let _ = tokio::time::timeout(REPORT_TIMEOUT, DaemonClient::new(socket).request(envelope)).await;
658}
659
660async fn forward_signals(child_pid: Option<u32>) {
663 let Some(pid) = child_pid.and_then(|pid| i32::try_from(pid).ok()) else {
664 return;
665 };
666 let pid = nix::unistd::Pid::from_raw(pid);
667 let (Ok(mut terminate), Ok(mut interrupt)) = (
668 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()),
669 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt()),
670 ) else {
671 return;
672 };
673 loop {
674 let signal = tokio::select! {
675 _ = terminate.recv() => nix::sys::signal::Signal::SIGTERM,
676 _ = interrupt.recv() => nix::sys::signal::Signal::SIGINT,
677 };
678 let _ = nix::sys::signal::kill(pid, signal);
679 }
680}
681
682fn exit_code(status: ExitStatus) -> i32 {
685 status
686 .code()
687 .or_else(|| status.signal().map(|signal| SIGNAL_EXIT_BASE + signal))
688 .unwrap_or(1)
689}
690
691#[cfg(test)]
692#[allow(clippy::unwrap_used, clippy::expect_used)]
693mod tests {
694 use std::pin::Pin;
695 use std::sync::{Arc, Mutex};
696 use std::task::{Context as TaskContext, Poll};
697
698 use tokio::io::AsyncBufReadExt;
699 use tokio::net::UnixListener;
700
701 use super::*;
702
703 #[derive(Clone, Default)]
706 struct Sink(Arc<Mutex<Vec<u8>>>);
707
708 impl Sink {
709 fn contents(&self) -> String {
710 String::from_utf8(self.0.lock().unwrap().clone()).unwrap()
711 }
712 }
713
714 impl AsyncWrite for Sink {
715 fn poll_write(
716 self: Pin<&mut Self>,
717 _cx: &mut TaskContext<'_>,
718 buf: &[u8],
719 ) -> Poll<std::io::Result<usize>> {
720 self.0.lock().unwrap().extend_from_slice(buf);
721 Poll::Ready(Ok(buf.len()))
722 }
723
724 fn poll_flush(
725 self: Pin<&mut Self>,
726 _cx: &mut TaskContext<'_>,
727 ) -> Poll<std::io::Result<()>> {
728 Poll::Ready(Ok(()))
729 }
730
731 fn poll_shutdown(
732 self: Pin<&mut Self>,
733 _cx: &mut TaskContext<'_>,
734 ) -> Poll<std::io::Result<()>> {
735 Poll::Ready(Ok(()))
736 }
737 }
738
739 async fn wrap_script(script: &str, socket: Option<PathBuf>) -> (i32, String) {
742 let sink = Sink::default();
743 let code = wrap_io(
744 "/bin/sh",
745 &["-c".to_string(), script.to_string()],
746 socket,
747 tokio::io::empty(),
748 sink.clone(),
749 )
750 .await
751 .unwrap();
752 (code, sink.contents())
753 }
754
755 fn parse(args: &[&str]) -> ClaudeWrapCommand {
757 let mut argv = vec!["claude-wrap"];
758 argv.extend_from_slice(args);
759 ClaudeWrapCommand::try_parse_from(argv).unwrap()
760 }
761
762 fn fake_daemon() -> (
764 tempfile::TempDir,
765 PathBuf,
766 Arc<tokio::sync::Mutex<Vec<Value>>>,
767 ) {
768 let dir = tempfile::tempdir_in("/tmp").unwrap();
769 let socket = dir.path().join("d.sock");
770 let listener = UnixListener::bind(&socket).unwrap();
771 let seen = Arc::new(tokio::sync::Mutex::new(Vec::new()));
772 let recorder = Arc::clone(&seen);
773 tokio::spawn(async move {
774 loop {
775 let Ok((stream, _)) = listener.accept().await else {
776 return;
777 };
778 let recorder = Arc::clone(&recorder);
779 tokio::spawn(async move {
780 let (read, mut write) = stream.into_split();
781 let mut line = String::new();
782 if tokio::io::BufReader::new(read)
783 .read_line(&mut line)
784 .await
785 .is_ok()
786 {
787 if let Ok(value) = serde_json::from_str::<Value>(&line) {
788 recorder.lock().await.push(value);
789 }
790 }
791 let _ = write.write_all(b"{\"ok\":true,\"payload\":{}}\n").await;
792 });
793 }
794 });
795 (dir, socket, seen)
796 }
797
798 #[test]
799 fn trailing_args_are_captured_verbatim_after_a_separator() {
800 let cmd = parse(&["--", "node", "cli.js", "--output-format", "stream-json"]);
801 assert_eq!(
802 cmd.argv,
803 vec!["node", "cli.js", "--output-format", "stream-json"]
804 );
805 assert!(cmd.socket.is_none());
806 }
807
808 #[test]
809 fn the_socket_override_is_parsed_before_the_separator() {
810 let cmd = parse(&["--socket", "/tmp/d.sock", "--", "claude", "-p"]);
811 assert_eq!(cmd.socket.unwrap(), PathBuf::from("/tmp/d.sock"));
812 assert_eq!(cmd.argv, vec!["claude", "-p"]);
813 }
814
815 #[tokio::test]
816 async fn an_empty_command_is_a_usage_error() {
817 let error = parse(&[]).run().await.unwrap_err();
818 assert!(error.to_string().contains("needs a command to run"));
819 }
820
821 #[tokio::test]
835 async fn signal_forwarding_gives_up_when_there_is_no_child() {
836 forward_signals(None).await;
839 }
840
841 #[tokio::test]
842 async fn the_child_is_wrapped_losslessly_and_its_exit_code_survives() {
843 let (_dir, socket, _seen) = fake_daemon();
844 let (code, forwarded) =
847 wrap_script("printf 'hello\\nnot json\\ntrailing'; exit 7", Some(socket)).await;
848 assert_eq!(code, 7);
849 assert_eq!(forwarded, "hello\nnot json\ntrailing");
850 }
851
852 #[tokio::test]
853 async fn stream_state_transitions_reach_the_daemon() {
854 let (_dir, socket, seen) = fake_daemon();
855 let script = concat!(
857 r#"printf '{"type":"system","subtype":"init","session_id":"s-1","cwd":"/w"}\n';"#,
858 r#"printf '{"type":"assistant","session_id":"s-1"}\n';"#,
859 r#"printf '{"type":"control_request","request_id":"r1","request":{"subtype":"can_use_tool"}}\n';"#,
860 r#"printf '{"type":"result"}\n'"#,
861 );
862 let (code, _forwarded) = wrap_script(script, Some(socket)).await;
863 assert_eq!(code, 0);
864
865 let envelopes = seen.lock().await.clone();
866 let states: Vec<String> = envelopes
867 .iter()
868 .filter(|e| e["op"] == "observe")
869 .filter_map(|e| e["payload"]["event"]["stream_state"].as_str())
870 .map(ToString::to_string)
871 .collect();
872 assert_eq!(
873 states,
874 vec!["idle", "working", "waiting_for_permission", "idle"]
875 );
876 assert_eq!(envelopes[0]["service"], "sessions");
878 assert_eq!(envelopes[0]["payload"]["session_id"], "s-1");
879 assert_eq!(envelopes[0]["payload"]["cwd"], "/w");
880 let ended = envelopes.last().unwrap();
881 assert_eq!(ended["op"], "end");
882 assert_eq!(ended["payload"]["session_id"], "s-1");
883 }
884
885 #[tokio::test]
886 async fn a_missing_daemon_never_affects_the_child() {
887 let dir = tempfile::tempdir_in("/tmp").unwrap();
889 let script = concat!(
890 r#"printf '{"type":"system","subtype":"init","session_id":"s-1"}\n';"#,
891 r#"printf 'not json at all\n';"#,
892 r#"exit 3"#,
893 );
894 let (code, forwarded) = wrap_script(script, Some(dir.path().join("absent.sock"))).await;
895 assert_eq!(code, 3);
896 assert!(forwarded.ends_with("not json at all\n"));
897 }
898
899 #[tokio::test]
900 async fn a_child_killed_by_a_signal_reports_the_shell_convention() {
901 let (_dir, socket, _seen) = fake_daemon();
902 let (code, _forwarded) = wrap_script("kill -TERM $$", Some(socket)).await;
903 assert_eq!(code, SIGNAL_EXIT_BASE + 15);
904 }
905
906 #[tokio::test]
907 async fn spawning_a_missing_program_is_an_error_not_a_panic() {
908 let error = wrap_io(
909 "/nonexistent/omni-dev-test-binary",
910 &[],
911 None,
912 tokio::io::empty(),
913 Sink::default(),
914 )
915 .await
916 .unwrap_err();
917 assert!(error.to_string().contains("failed to spawn"));
918 }
919
920 #[test]
921 fn over_long_and_non_utf8_lines_are_dropped_but_still_forwarded() {
922 let (tee, mut lines) = mpsc::channel(4);
923 let mut buffer = Vec::new();
924 let mut chunk = vec![b'x'; MAX_LINE_BYTES + 1];
925 chunk.push(b'\n');
926 chunk.extend_from_slice(&[0xff, 0xfe, b'\n']);
927 chunk.extend_from_slice(b"{\"type\":\"result\"}\n");
928 tee_chunk(&chunk, Direction::FromClaude, &tee, &mut buffer);
929 let (direction, text) = lines.try_recv().unwrap();
931 assert_eq!(direction, Direction::FromClaude);
932 assert_eq!(text, r#"{"type":"result"}"#);
933 assert!(lines.try_recv().is_err());
934 }
935
936 #[tokio::test]
937 async fn the_keepalive_re_reports_a_silent_session() {
938 let (_dir, socket, seen) = fake_daemon();
941 let (tee, lines) = mpsc::channel(4);
942 let (title_tx, _title_rx) = watch::channel(None);
943 let observer = tokio::spawn(observe(
944 lines,
945 Some(socket),
946 Duration::from_millis(20),
947 title_tx,
948 Some(4242),
949 ));
950 tee.send((
951 Direction::FromClaude,
952 r#"{"type":"system","subtype":"init","session_id":"ka-1"}"#.to_string(),
953 ))
954 .await
955 .unwrap();
956 tokio::time::sleep(Duration::from_millis(120)).await;
957 drop(tee);
958 observer.await.unwrap();
959
960 let envelopes = seen.lock().await.clone();
961 let observes: Vec<_> = envelopes.iter().filter(|e| e["op"] == "observe").collect();
962 assert!(observes.len() > 1, "expected keep-alives, got {observes:?}");
964 for envelope in &observes {
965 assert_eq!(envelope["payload"]["session_id"], "ka-1");
966 assert_eq!(envelope["payload"]["event"]["stream_state"], "idle");
967 assert_eq!(envelope["payload"]["pid"], 4242);
969 }
970 let end = envelopes.last().unwrap();
971 assert_eq!(end["op"], "end");
972 assert_eq!(end["payload"]["pid"], 4242);
973 }
974
975 #[tokio::test]
976 async fn the_pump_stops_when_the_far_side_goes_away() {
977 struct Closed;
980 impl AsyncWrite for Closed {
981 fn poll_write(
982 self: Pin<&mut Self>,
983 _cx: &mut TaskContext<'_>,
984 _buf: &[u8],
985 ) -> Poll<std::io::Result<usize>> {
986 Poll::Ready(Err(std::io::Error::from(std::io::ErrorKind::BrokenPipe)))
987 }
988 fn poll_flush(
989 self: Pin<&mut Self>,
990 _cx: &mut TaskContext<'_>,
991 ) -> Poll<std::io::Result<()>> {
992 Poll::Ready(Ok(()))
993 }
994 fn poll_shutdown(
995 self: Pin<&mut Self>,
996 _cx: &mut TaskContext<'_>,
997 ) -> Poll<std::io::Result<()>> {
998 Poll::Ready(Ok(()))
999 }
1000 }
1001 let (tee, mut lines) = mpsc::channel(4);
1002 pump(
1004 &b"{\"type\":\"result\"}\n"[..],
1005 Closed,
1006 Direction::FromClaude,
1007 tee,
1008 None,
1009 )
1010 .await;
1011 assert!(lines.try_recv().is_err());
1012 }
1013
1014 #[tokio::test]
1015 async fn pump_resyncs_the_title_from_the_watch_channel_alone_with_no_further_reads() {
1016 let (tee, _lines) = mpsc::channel(4);
1021 let (title_tx, title_rx) = watch::channel(None);
1022 let sink = Sink::default();
1023 let (mut writer_end, reader_end) = tokio::io::duplex(1024);
1024 writer_end.write_all(b"\x1b]0;2.1.132\x07").await.unwrap();
1025
1026 let pump_task = tokio::spawn(pump(
1027 reader_end,
1028 sink.clone(),
1029 Direction::FromClaude,
1030 tee,
1031 Some(title_rx),
1032 ));
1033
1034 tokio::time::sleep(Duration::from_millis(20)).await;
1035 assert_eq!(sink.contents(), "\x1b]0;2.1.132\x07");
1036
1037 title_tx.send(Some(PREFIX.to_string())).unwrap();
1038 tokio::time::sleep(Duration::from_millis(20)).await;
1039 assert!(
1040 sink.contents().contains("\x1b]0;🟡 Opus · 2.1.132\x07"),
1041 "expected a resynced title, got: {:?}",
1042 sink.contents()
1043 );
1044
1045 drop(writer_end);
1046 let _ = pump_task.await;
1047 }
1048
1049 #[test]
1050 fn a_full_tee_drops_lines_rather_than_blocking() {
1051 let (tee, _lines) = mpsc::channel(1);
1052 let mut buffer = Vec::new();
1053 tee_chunk(b"a\nb\nc\n", Direction::ToClaude, &tee, &mut buffer);
1054 assert_eq!(tee.capacity(), 0);
1056 }
1057
1058 const PREFIX: &str = "🟡 Opus · ";
1059
1060 #[test]
1061 fn bel_terminated_title_is_rewritten_with_the_model_prefix() {
1062 let mut rewriter = TitleRewriter::default();
1063 let out = rewriter.rewrite(b"\x1b]0;2.1.132\x07", Some(PREFIX));
1064 assert_eq!(out, b"\x1b]0;\xf0\x9f\x9f\xa1 Opus \xc2\xb7 2.1.132\x07");
1065 assert_eq!(
1066 String::from_utf8(out).unwrap(),
1067 "\x1b]0;🟡 Opus · 2.1.132\x07"
1068 );
1069 }
1070
1071 #[test]
1072 fn st_terminated_title_is_rewritten_with_the_model_prefix() {
1073 let mut rewriter = TitleRewriter::default();
1074 let out = rewriter.rewrite(b"\x1b]2;2.1.132\x1b\\", Some(PREFIX));
1075 assert_eq!(
1076 String::from_utf8(out).unwrap(),
1077 "\x1b]2;🟡 Opus · 2.1.132\x1b\\"
1078 );
1079 }
1080
1081 #[test]
1082 fn no_model_known_yet_leaves_the_title_unchanged() {
1083 let mut rewriter = TitleRewriter::default();
1084 let chunk = b"\x1b]0;2.1.132\x07";
1085 let out = rewriter.rewrite(chunk, None);
1086 assert_eq!(out, chunk);
1087 }
1088
1089 #[test]
1090 fn a_non_osc_chunk_is_untouched() {
1091 let mut rewriter = TitleRewriter::default();
1092 let chunk = b"just some ordinary assistant output, no escapes here\n";
1093 let out = rewriter.rewrite(chunk, Some(PREFIX));
1094 assert_eq!(out, chunk);
1095 }
1096
1097 #[test]
1098 fn a_sequence_split_across_two_reads_is_still_rewritten() {
1099 let mut rewriter = TitleRewriter::default();
1100 let mut out = rewriter.rewrite(b"before \x1b]0;2.1", Some(PREFIX));
1101 out.extend(rewriter.rewrite(b".132\x07 after", Some(PREFIX)));
1102 assert_eq!(
1103 String::from_utf8(out).unwrap(),
1104 "before \x1b]0;🟡 Opus · 2.1.132\x07 after"
1105 );
1106 }
1107
1108 #[test]
1109 fn an_osc_code_other_than_0_or_2_is_left_untouched() {
1110 let mut rewriter = TitleRewriter::default();
1112 let chunk = b"\x1b]8;;https://example.invalid\x07link text\x1b]8;;\x07";
1113 let out = rewriter.rewrite(chunk, Some(PREFIX));
1114 assert_eq!(out, chunk);
1115 }
1116
1117 #[test]
1118 fn an_overlong_sequence_without_a_terminator_is_flushed_unchanged() {
1119 let mut rewriter = TitleRewriter::default();
1120 let mut chunk = b"\x1b]0;".to_vec();
1121 chunk.extend(std::iter::repeat_n(b'x', MAX_OSC_SEQUENCE_BYTES + 16));
1122 let out = rewriter.rewrite(&chunk, Some(PREFIX));
1123 assert_eq!(out, chunk);
1124 }
1125
1126 #[test]
1127 fn a_malformed_st_terminator_is_flushed_unchanged() {
1128 let mut rewriter = TitleRewriter::default();
1131 let chunk = b"\x1b]0;oops\x1bnope";
1132 let out = rewriter.rewrite(chunk, Some(PREFIX));
1133 assert_eq!(out, chunk);
1134 }
1135
1136 #[test]
1137 fn resync_returns_none_before_anything_has_completed() {
1138 let rewriter = TitleRewriter::default();
1139 assert_eq!(rewriter.resync(Some(PREFIX)), None);
1140 }
1141
1142 #[test]
1143 fn resync_returns_none_without_a_prefix() {
1144 let mut rewriter = TitleRewriter::default();
1145 rewriter.rewrite(b"\x1b]0;2.1.132\x07", None);
1146 assert_eq!(rewriter.resync(None), None);
1147 }
1148
1149 #[test]
1150 fn resync_reformats_a_title_that_was_forwarded_before_the_model_was_known() {
1151 let mut rewriter = TitleRewriter::default();
1156 let forwarded = rewriter.rewrite(b"\x1b]0;2.1.132\x07", None);
1157 assert_eq!(forwarded, b"\x1b]0;2.1.132\x07");
1158 let corrected = rewriter.resync(Some(PREFIX)).unwrap();
1159 assert_eq!(
1160 String::from_utf8(corrected).unwrap(),
1161 "\x1b]0;🟡 Opus · 2.1.132\x07"
1162 );
1163 }
1164
1165 #[test]
1166 fn resync_reformats_under_a_changed_prefix_on_a_mid_session_model_switch() {
1167 let mut rewriter = TitleRewriter::default();
1168 rewriter.rewrite(b"\x1b]0;2.1.132\x07", Some(PREFIX));
1169 let resynced = rewriter.resync(Some("🟢 Sonnet · ")).unwrap();
1170 assert_eq!(
1171 String::from_utf8(resynced).unwrap(),
1172 "\x1b]0;🟢 Sonnet · 2.1.132\x07"
1173 );
1174 }
1175
1176 #[test]
1177 fn flag_is_truthy_recognises_on_and_off_values() {
1178 for value in ["1", "true", "TRUE", "yes", "on", " on "] {
1179 assert!(flag_is_truthy(value), "{value:?} should be truthy");
1180 }
1181 for value in ["0", "false", "no", "off", ""] {
1182 assert!(!flag_is_truthy(value), "{value:?} should not be truthy");
1183 }
1184 }
1185
1186 #[tokio::test]
1187 async fn the_wrapper_rewrites_claudes_title_end_to_end() {
1188 let (_dir, socket, _seen) = fake_daemon();
1189 let script = concat!(
1199 r#"printf '\033]0;2.1.132\007';"#,
1200 r#"printf '{"type":"system","subtype":"init","session_id":"s-1","model":"claude-opus-4-8"}\n';"#,
1201 r#"sleep 0.2;"#,
1202 r#"printf 'plain text after the title\n'"#,
1203 );
1204 let (_code, forwarded) = wrap_script(script, Some(socket)).await;
1205 assert!(
1209 forwarded.contains("\x1b]0;2.1.132\x07"),
1210 "the original title should still have been forwarded verbatim: {forwarded:?}"
1211 );
1212 assert!(
1215 forwarded.contains("\x1b]0;🟡 Opus · 2.1.132\x07"),
1216 "the title should have been corrected once the model became known: {forwarded:?}"
1217 );
1218 let uncorrected_at = forwarded.find("\x1b]0;2.1.132\x07").unwrap();
1219 let corrected_at = forwarded.find("\x1b]0;🟡 Opus · 2.1.132\x07").unwrap();
1220 assert!(uncorrected_at < corrected_at);
1221 assert!(forwarded.ends_with("plain text after the title\n"));
1222 }
1223
1224 }