Skip to main content

omni_dev/cli/
claude_wrap.rs

1//! `omni-dev claude-wrap` — a transparent wrapper around the `claude` process,
2//! teeing its `--output-format stream-json` stdio to the daemon's `sessions`
3//! service as the authoritative Feed 4.
4//!
5//! The Claude Code VS Code extension launches Claude through whatever executable
6//! its `claudeCode.claudeProcessWrapper` setting names, as
7//! `<wrapper> <real-cmd> <real-args…>`. Pointing that at this command puts us in
8//! the stream the extension itself reads, which is the only place the exact
9//! session state — in particular the `can_use_tool` permission prompt — is
10//! visible. See ADR-0057, and [`crate::sessions::stream`] for the state machine.
11//!
12//! **Fail-open is the hard rule.** This sits in Claude's launch path, so the
13//! worst case must be "lose state visibility", never "Claude won't launch". The
14//! byte-forwarding path therefore never awaits the parser, the daemon, or
15//! anything else: lines are handed to the observer through a *bounded* channel
16//! with [`try_send`](tokio::sync::mpsc::Sender::try_send) and dropped when it is
17//! full, and every reporting error is swallowed exactly as the `sessions hook`
18//! sink swallows its own.
19//!
20//! Nothing is logged or persisted. The observer reads only the state, the
21//! `session_id`, the `cwd` and the model out of the stream, and reports them to
22//! the daemon's existing `0600` Unix socket.
23
24use 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
42/// The `sessions` service routing key on the daemon control socket.
43const SERVICE: &str = "sessions";
44
45/// How long a fire-and-forget report waits for the daemon before giving up.
46/// Short, and on a task that no byte ever waits behind.
47const REPORT_TIMEOUT: Duration = Duration::from_secs(2);
48
49/// How often the current state is re-reported while the child lives.
50///
51/// Comfortably inside the registry's 300s session TTL, so a session that sits
52/// idle at the prompt for an hour never ages out. This is the wrapper's bonus
53/// over the hook and transcript feeds: liveness bounded by the real process
54/// lifetime rather than by observed activity.
55const KEEPALIVE_INTERVAL: Duration = Duration::from_secs(30);
56
57/// How many teed lines may be in flight before further ones are dropped.
58/// Generous for the two-second worst case of a slow daemon, and bounded so a
59/// wedged observer can never grow memory or slow the child's I/O.
60const TEE_CAPACITY: usize = 256;
61
62/// Longest stream-json line the tee will reassemble; longer lines are forwarded
63/// verbatim like every other byte but not parsed. Matches the daemon control
64/// socket's own line ceiling.
65const MAX_LINE_BYTES: usize = 1024 * 1024;
66
67/// Read-buffer size for each direction of the byte pump.
68const PUMP_BUF_BYTES: usize = 64 * 1024;
69
70/// Exit status used when the child is killed by a signal, mirroring the shell's
71/// `128 + signo` convention.
72const SIGNAL_EXIT_BASE: i32 = 128;
73
74/// Longest we will hold bytes back while looking for the parameter or
75/// terminator of a candidate OSC `0`/`2` title sequence, across any number of
76/// `read()` calls. Bounds memory and is the fail-open backstop for the title
77/// rewrite: a malformed or pathologically split sequence is flushed
78/// unchanged, rather than held back indefinitely, once this is exceeded.
79const MAX_OSC_SEQUENCE_BYTES: usize = 8 * 1024;
80
81/// Environment variable that, set to a truthy value, disables the OSC
82/// title rewrite entirely: Claude's own title sequence is then forwarded
83/// unchanged, exactly as it was before issue #1445. An escape hatch for the
84/// rare terminal/font where the rewritten title misrenders.
85const NO_TITLE_REWRITE_ENV: &str = "OMNI_DEV_CLAUDE_WRAP_NO_TITLE_REWRITE";
86
87/// Runs a command transparently, reporting the Claude session state it streams.
88#[derive(Parser)]
89pub struct ClaudeWrapCommand {
90    /// Path to the daemon control socket. Defaults to the standard per-user path.
91    #[arg(long, value_name = "PATH")]
92    pub socket: Option<PathBuf>,
93
94    /// The command to run and its arguments, after a `--` separator.
95    #[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    /// Executes the wrapper: runs the given command, forwards its stdio
105    /// verbatim, and exits with the child's own status.
106    ///
107    /// This never returns normally on the wrapped path — it calls
108    /// [`std::process::exit`] so the child's exit code survives, which the
109    /// framework's `Result`-to-0/1 collapse in `main` could not express. The
110    /// consequence is that a `claude-wrap` invocation writes no request-log
111    /// record, which is deliberate: it is a process shim, not a user command.
112    pub async fn execute(self) -> Result<()> {
113        let code = self.run().await?;
114        std::process::exit(code)
115    }
116
117    /// Runs the wrapped command and returns the exit code it should be reported
118    /// with. Split out of [`execute`](Self::execute) so everything but the
119    /// process-ending `exit` itself is testable.
120    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        // An interactive launch is not the stream-json protocol, so there is
129        // nothing to observe and no reason to sit in the middle of it: replace
130        // this process with the child outright, which is as transparent as it
131        // gets (same pid, same terminal, same job control, same exit status).
132        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
140/// Replaces this process with `program`, returning the error only if the `exec`
141/// itself failed (on success it never returns).
142fn 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
147/// Wraps `program`, joining it to this process's own stdin and stdout.
148async 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
159/// Spawns the child with piped stdio, pumps `input` and `output` through it
160/// verbatim while teeing both directions to the observer, and returns the
161/// child's exit code.
162///
163/// Generic over the two endpoints so the wrapping can be tested against a real
164/// child process without touching the test runner's own stdio — which is also
165/// the only way to assert that the forwarding is byte-for-byte lossless.
166async 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    // stderr is inherited and env is left untouched, so the child sees exactly
178    // the environment it would have without the wrapper. The child is
179    // deliberately *not* put in its own process group (unlike the managed
180    // `claude -p` subprocess in `crate::claude::ai::claude_cli`): a transparent
181    // wrapper must leave the child in the caller's group or job control breaks.
182    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    // The title rewrite only ever applies to the FromClaude direction — it
206    // rewrites what Claude asserts about itself, never what we send it.
207    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    // Draining the child's stdout to EOF *before* reaping is what makes the
225    // wrapper lossless: every byte the child wrote reaches our stdout, exactly
226    // as it would have without us in the middle.
227    let _ = from_child.await;
228    // Our own stdin never reaches EOF on its own, so the input pump has to be
229    // cancelled; awaiting the aborted handle drops its tee sender, which is what
230    // eventually lets the observer finish.
231    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
243/// Copies `reader` to `writer` byte-for-byte, teeing complete lines to the
244/// observer as it goes.
245///
246/// The copy is the priority: bytes are written and flushed before the tee is
247/// even attempted, and the tee is a non-blocking [`try_send`] whose failure is
248/// ignored. No I/O here can be delayed by the observer or the daemon.
249///
250/// `title_rx` is the one deliberate, narrow exception to "never depends on
251/// the observer" (see the [`TitleRewriter`] doc comment and the ADR-0057
252/// amendment for #1445): when present, each chunk is passed through
253/// [`TitleRewriter`] before being written, which does a single non-blocking
254/// [`watch::Receiver::borrow`] of a value the observer publishes — never an
255/// `.await` on the observer or the daemon. `None` (always the `ToClaude`
256/// direction, and `FromClaude` too when the rewrite is disabled) skips the
257/// rewrite entirely and writes the chunk verbatim, exactly as before #1445.
258///
259/// A second, independent branch races [`watch::Receiver::changed`] against
260/// the read: real Claude asserts its title once at startup, not on a timer,
261/// so waiting only on chunks read *from Claude* would very often lose the
262/// race against the observer classifying the model — with nothing left to
263/// reassert and catch it later. When the model becomes known (or changes),
264/// this branch calls [`TitleRewriter::resync`] to correct the cached title
265/// immediately, with no new bytes from Claude required. It is still
266/// non-blocking on the observer in the sense that matters: a value already
267/// sitting in the channel is read instantly, and if the observer never
268/// updates it, this branch simply never fires — it cannot hang the pump.
269///
270/// [`try_send`]: tokio::sync::mpsc::Sender::try_send
271async 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                // No receiver (ToClaude, or the rewrite is disabled): never
289                // resolves, so `select!` only ever waits on the read.
290                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                    // The observer's sender was dropped; stop selecting on
327                    // this branch rather than spinning on a channel that
328                    // will report closed forever.
329                    Err(_) => title_rx = None,
330                }
331            }
332        }
333    }
334    // Closing the write end propagates EOF (this is how the child learns its
335    // input has ended); a failure here means the peer is already gone.
336    let _ = writer.shutdown().await;
337}
338
339/// Rewrites Claude's own OSC `0`/`2` terminal-title sequence in flight,
340/// prepending a colour-glyph + model-family prefix, while leaving every other
341/// byte untouched (issue #1445).
342///
343/// **Fail-open by construction**, matching every other guarantee this module
344/// makes: a candidate sequence is forwarded byte-for-byte unchanged, exactly
345/// as received, whenever it
346/// - is not an OSC `0` or `2` sequence (any other OSC code, or a bare `ESC`
347///   not followed by `]`),
348/// - is not terminated with `BEL` or the two-byte `ESC \` (ST) form,
349/// - carries a title that is not valid UTF-8,
350/// - exceeds [`MAX_OSC_SEQUENCE_BYTES`] before terminating, or
351/// - arrives while no model is known yet (`prefix` is `None`).
352///
353/// State (`pending`/`title`/`code`/`phase`) persists across calls so a
354/// sequence split unfavourably across a `read()` buffer boundary is still
355/// recognized and rewritten correctly.
356///
357/// Real Claude Code asserts its title **once**, as part of its terminal-mode
358/// setup at startup, not on a timer — measured empirically, not the
359/// "continuously re-asserted" behavior issue #1445 assumed. Left as
360/// rewrite-on-assert alone, that one assertion almost always races ahead of
361/// the model becoming known (the observer needs to receive, tee, and parse
362/// the `system`/`init` line first), and since there is no later reassertion
363/// to catch, the title is never corrected for the rest of the session. Every
364/// completed sequence is therefore also cached — regardless of whether it
365/// was rewritten or forwarded unchanged — so [`Self::resync`] can re-emit a
366/// corrected title the moment the model becomes known (or changes again),
367/// without waiting for Claude to assert anything else.
368#[derive(Debug, Default)]
369struct TitleRewriter {
370    /// Every raw byte seen since leaving [`Phase::Idle`] — the exact bytes
371    /// flushed verbatim whenever a candidate sequence does not end up being
372    /// rewritten.
373    pending: Vec<u8>,
374    /// The title text accumulated once past `ESC ] <code> ;`, excluding the
375    /// terminator.
376    title: Vec<u8>,
377    /// The OSC parameter byte (`b'0'` or `b'2'`), once known.
378    code: u8,
379    phase: Phase,
380    /// The most recently completed sequence's parameter byte, title text,
381    /// and terminator — cached regardless of whether it was rewritten, so
382    /// [`Self::resync`] can reformat it under a new prefix.
383    last_code: Option<u8>,
384    last_title: Option<Vec<u8>>,
385    last_terminator: Option<Vec<u8>>,
386}
387
388/// `TitleRewriter`'s position within (or outside of) a candidate OSC title
389/// sequence.
390#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
391enum Phase {
392    /// Not inside any candidate escape sequence; bytes pass straight through.
393    #[default]
394    Idle,
395    /// Saw `ESC`; deciding whether `]` follows.
396    Esc,
397    /// Saw `ESC ]`; deciding whether a `0`/`2` title code follows.
398    Open,
399    /// Saw `ESC ] <code>`; deciding whether `;` follows.
400    Code,
401    /// Accumulating title text after `ESC ] <code> ;`, until `BEL` or ST.
402    Title,
403    /// Saw `ESC` while accumulating title text; deciding whether `\` (the
404    /// second byte of the ST terminator) follows.
405    TitleEsc,
406}
407
408const ESC: u8 = 0x1B;
409const BEL: u8 = 0x07;
410
411impl TitleRewriter {
412    /// Passes `chunk` through the rewriter, returning the bytes that should
413    /// actually be written downstream. `prefix` is the current
414    /// `"{glyph} {label} · "` string to prepend to a rewritten title, or
415    /// `None` while no model is known yet.
416    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                        // Not a valid ST terminator: the whole candidate is
469                        // malformed, so flush verbatim rather than guess.
470                        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    /// A completed sequence: rewrites it when `prefix` and a valid UTF-8
482    /// title are both available, otherwise flushes the original bytes
483    /// unchanged. Cached either way, for [`Self::resync`].
484    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    /// Re-emits the most recently completed title sequence under a new
505    /// `prefix`, so a title Claude already asserted — before any model was
506    /// known, or under a now-stale one — is corrected without waiting for
507    /// Claude to reassert it on its own (which, empirically, it may never
508    /// do again for the rest of the session). Returns `None` when nothing
509    /// has completed yet, `prefix` is still unavailable, or the cached title
510    /// is not valid UTF-8.
511    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    /// Flushes everything buffered so far, unchanged, and returns to `Idle`.
524    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
537/// Whether the OSC title rewrite is enabled — the default, unless
538/// [`NO_TITLE_REWRITE_ENV`] is set to a truthy value.
539fn title_rewrite_enabled() -> bool {
540    !std::env::var(NO_TITLE_REWRITE_ENV).is_ok_and(|v| flag_is_truthy(&v))
541}
542
543/// Whether a `NO_TITLE_REWRITE_ENV`-style flag value should be read as "on".
544/// Split out from [`title_rewrite_enabled`] so the parsing itself is testable
545/// without mutating process-wide environment state.
546fn flag_is_truthy(value: &str) -> bool {
547    matches!(
548        value.trim().to_ascii_lowercase().as_str(),
549        "1" | "true" | "yes" | "on"
550    )
551}
552
553/// Builds the `"{glyph} {label} · "` prefix to prepend to Claude's own OSC
554/// title text for `model_id`, classified via the shared [`ModelRegistry`]
555/// (issue #1445).
556///
557/// [`ModelRegistry`]: crate::claude::model_config::ModelRegistry
558fn 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
563/// Reassembles newline-delimited lines out of a forwarded chunk and offers each
564/// to the observer, dropping any that is over-long, not UTF-8, or arrives while
565/// the channel is full.
566fn 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
586/// Consumes teed lines, tracks the session state, and reports every change to
587/// the daemon — plus a keep-alive while nothing changes, and an `end` once the
588/// stream is over.
589/// `every` is the keep-alive cadence, injected so tests can drive it without
590/// waiting out the real [`KEEPALIVE_INTERVAL`].
591async 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    // The first tick of a tokio interval completes immediately; consume it so
604    // the keep-alive does not fire before anything has been observed.
605    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        // Publish the classified title prefix whenever the model changes —
616        // independent of `observed`, since a mid-session model switch does
617        // not necessarily move `SessionState` (issue #1445). The pump's
618        // `watch::Receiver::borrow` on the other end never blocks on this.
619        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
636/// Sends one bounded, fire-and-forget op to the daemon's `sessions` service,
637/// swallowing every failure — a missing or wedged daemon must be a silent no-op.
638async 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
643/// Relays `SIGINT`/`SIGTERM` to the child, so a caller that signals the wrapper
644/// stops Claude rather than orphaning it.
645async 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
665/// The exit code to leave with: the child's own, or the shell's `128 + signo`
666/// when it died on a signal.
667fn 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    /// An [`AsyncWrite`] that appends into a shared buffer, so a test can assert
687    /// on exactly the bytes the wrapper forwarded.
688    #[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    /// Runs `/bin/sh -c script` through the wrapper, returning its exit code and
723    /// everything it wrote downstream.
724    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    /// Parses a `claude-wrap` argument list the way the real CLI would.
739    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    /// A one-shot daemon stand-in that records every envelope it is sent.
746    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    // Note: there is deliberately no test that drives `run`/`wrap` to completion.
805    // Both join the *process's own* stdin and stdout, which a test must not take
806    // over — and `run`'s terminal branch would `exec`-replace the test binary
807    // outright when the suite is run from an interactive shell. They are thin
808    // delegations; `wrap_io` underneath them is covered directly.
809
810    // Note: `exec_replace` is deliberately untested. On success it never returns,
811    // and its failure path cannot be exercised in-process either: `exec` resets
812    // `SIGPIPE` to `SIG_DFL` before the `execve`, and a failed `exec` leaves that
813    // reset in place (the "broken state" its docs warn about). Calling it from a
814    // test poisons the whole binary — every later test that writes to a closed
815    // pipe dies on signal 13.
816
817    #[tokio::test]
818    async fn signal_forwarding_gives_up_when_there_is_no_child() {
819        // A child already reaped has no pid to signal; this must return rather
820        // than spin.
821        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        // A partial trailing line and a non-JSON line: neither is parseable, and
828        // both must still arrive downstream byte-for-byte.
829        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        // A minimal but real turn: announce, work, ask permission, finish.
839        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        // Identity rides the reports, and the stream's end is announced.
860        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        // A socket path that does not exist: every report fails silently.
871        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        // Only the last (short, valid UTF-8) line is offered to the observer.
913        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        // The wrapper's TTL-liveness guarantee: a session that says nothing more
922        // must keep being reported, or the registry ages it out at 5 minutes.
923        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        // One for the state change, then repeats carrying the same state.
945        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        // A closed peer (the editor exiting mid-turn) must end the copy, not
956        // spin on a writer that can never succeed again.
957        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        // Returns rather than hanging, and tees nothing it could not forward.
981        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        // Isolates pump()'s select! loop from the rest of the observe()
995        // pipeline: drives the watch channel directly, with a reader that
996        // never offers a second chunk, to prove the resync path does not
997        // depend on Claude sending anything else.
998        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        // Two of the three were dropped; nothing blocked, nothing panicked.
1033        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        // OSC 8 (hyperlink) must never be mistaken for a title sequence.
1089        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        // ESC not immediately followed by `\` is not a valid ST: the whole
1107        // candidate must be forwarded verbatim rather than guessed at.
1108        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        // The exact real-world case this exists for: Claude's one-and-only
1130        // title assertion raced ahead of the model becoming known, so it
1131        // went out unrewritten — resync must still be able to correct it
1132        // later, from the cache, with no further help from Claude.
1133        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        // Models the real race, not the happy path: real Claude asserts its
1168        // title once, at startup, *before* the model can possibly be known
1169        // (the observer has not even received the init line yet) — and,
1170        // empirically, does not reassert it later in the session. So the
1171        // very first assertion here is forwarded unrewritten (correct,
1172        // fail-open behavior), and the `sleep` that follows leaves the child
1173        // otherwise idle long enough for the async observer pipeline to
1174        // classify the model and for the pump's watch-driven resync to
1175        // correct the title on its own, with no further help from Claude.
1176        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        // The original, un-decorated assertion still reaches stdout exactly
1184        // as Claude sent it — fail-open means "forward unchanged", not "hold
1185        // back and hope".
1186        assert!(
1187            forwarded.contains("\x1b]0;2.1.132\x07"),
1188            "the original title should still have been forwarded verbatim: {forwarded:?}"
1189        );
1190        // And it is followed, with no further title assertion from Claude,
1191        // by a corrected one carrying the classified model prefix.
1192        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    // Note: there is deliberately no end-to-end test that sets
1203    // `NO_TITLE_REWRITE_ENV` and drives `wrap_script` — mutating that real
1204    // variable would race any other test in this module concurrently
1205    // spawning `wrap_io` (which reads it too), exactly the hazard
1206    // `request_log.rs`'s env-var tests avoid by mutating a private test-only
1207    // name instead. `flag_is_truthy_recognises_on_and_off_values` above
1208    // covers the parsing directly; the one-line `.then_some(title_rx)` wiring
1209    // in `wrap_io` needs no separate test.
1210}