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(
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    // The title rewrite only ever applies to the FromClaude direction — it
212    // rewrites what Claude asserts about itself, never what we send it.
213    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    // Draining the child's stdout to EOF *before* reaping is what makes the
231    // wrapper lossless: every byte the child wrote reaches our stdout, exactly
232    // as it would have without us in the middle.
233    let _ = from_child.await;
234    // Our own stdin never reaches EOF on its own, so the input pump has to be
235    // cancelled; awaiting the aborted handle drops its tee sender, which is what
236    // eventually lets the observer finish.
237    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
249/// Copies `reader` to `writer` byte-for-byte, teeing complete lines to the
250/// observer as it goes.
251///
252/// The copy is the priority: bytes are written and flushed before the tee is
253/// even attempted, and the tee is a non-blocking [`try_send`] whose failure is
254/// ignored. No I/O here can be delayed by the observer or the daemon.
255///
256/// `title_rx` is the one deliberate, narrow exception to "never depends on
257/// the observer" (see the [`TitleRewriter`] doc comment and the ADR-0057
258/// amendment for #1445): when present, each chunk is passed through
259/// [`TitleRewriter`] before being written, which does a single non-blocking
260/// [`watch::Receiver::borrow`] of a value the observer publishes — never an
261/// `.await` on the observer or the daemon. `None` (always the `ToClaude`
262/// direction, and `FromClaude` too when the rewrite is disabled) skips the
263/// rewrite entirely and writes the chunk verbatim, exactly as before #1445.
264///
265/// A second, independent branch races [`watch::Receiver::changed`] against
266/// the read: real Claude asserts its title once at startup, not on a timer,
267/// so waiting only on chunks read *from Claude* would very often lose the
268/// race against the observer classifying the model — with nothing left to
269/// reassert and catch it later. When the model becomes known (or changes),
270/// this branch calls [`TitleRewriter::resync`] to correct the cached title
271/// immediately, with no new bytes from Claude required. It is still
272/// non-blocking on the observer in the sense that matters: a value already
273/// sitting in the channel is read instantly, and if the observer never
274/// updates it, this branch simply never fires — it cannot hang the pump.
275///
276/// [`try_send`]: tokio::sync::mpsc::Sender::try_send
277async 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                // No receiver (ToClaude, or the rewrite is disabled): never
295                // resolves, so `select!` only ever waits on the read.
296                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                    // The observer's sender was dropped; stop selecting on
333                    // this branch rather than spinning on a channel that
334                    // will report closed forever.
335                    Err(_) => title_rx = None,
336                }
337            }
338        }
339    }
340    // Closing the write end propagates EOF (this is how the child learns its
341    // input has ended); a failure here means the peer is already gone.
342    let _ = writer.shutdown().await;
343}
344
345/// Rewrites Claude's own OSC `0`/`2` terminal-title sequence in flight,
346/// prepending a colour-glyph + model-family prefix, while leaving every other
347/// byte untouched (issue #1445).
348///
349/// **Fail-open by construction**, matching every other guarantee this module
350/// makes: a candidate sequence is forwarded byte-for-byte unchanged, exactly
351/// as received, whenever it
352/// - is not an OSC `0` or `2` sequence (any other OSC code, or a bare `ESC`
353///   not followed by `]`),
354/// - is not terminated with `BEL` or the two-byte `ESC \` (ST) form,
355/// - carries a title that is not valid UTF-8,
356/// - exceeds [`MAX_OSC_SEQUENCE_BYTES`] before terminating, or
357/// - arrives while no model is known yet (`prefix` is `None`).
358///
359/// State (`pending`/`title`/`code`/`phase`) persists across calls so a
360/// sequence split unfavourably across a `read()` buffer boundary is still
361/// recognized and rewritten correctly.
362///
363/// Real Claude Code asserts its title **once**, as part of its terminal-mode
364/// setup at startup, not on a timer — measured empirically, not the
365/// "continuously re-asserted" behavior issue #1445 assumed. Left as
366/// rewrite-on-assert alone, that one assertion almost always races ahead of
367/// the model becoming known (the observer needs to receive, tee, and parse
368/// the `system`/`init` line first), and since there is no later reassertion
369/// to catch, the title is never corrected for the rest of the session. Every
370/// completed sequence is therefore also cached — regardless of whether it
371/// was rewritten or forwarded unchanged — so [`Self::resync`] can re-emit a
372/// corrected title the moment the model becomes known (or changes again),
373/// without waiting for Claude to assert anything else.
374#[derive(Debug, Default)]
375struct TitleRewriter {
376    /// Every raw byte seen since leaving [`Phase::Idle`] — the exact bytes
377    /// flushed verbatim whenever a candidate sequence does not end up being
378    /// rewritten.
379    pending: Vec<u8>,
380    /// The title text accumulated once past `ESC ] <code> ;`, excluding the
381    /// terminator.
382    title: Vec<u8>,
383    /// The OSC parameter byte (`b'0'` or `b'2'`), once known.
384    code: u8,
385    phase: Phase,
386    /// The most recently completed sequence's parameter byte, title text,
387    /// and terminator — cached regardless of whether it was rewritten, so
388    /// [`Self::resync`] can reformat it under a new prefix.
389    last_code: Option<u8>,
390    last_title: Option<Vec<u8>>,
391    last_terminator: Option<Vec<u8>>,
392}
393
394/// `TitleRewriter`'s position within (or outside of) a candidate OSC title
395/// sequence.
396#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
397enum Phase {
398    /// Not inside any candidate escape sequence; bytes pass straight through.
399    #[default]
400    Idle,
401    /// Saw `ESC`; deciding whether `]` follows.
402    Esc,
403    /// Saw `ESC ]`; deciding whether a `0`/`2` title code follows.
404    Open,
405    /// Saw `ESC ] <code>`; deciding whether `;` follows.
406    Code,
407    /// Accumulating title text after `ESC ] <code> ;`, until `BEL` or ST.
408    Title,
409    /// Saw `ESC` while accumulating title text; deciding whether `\` (the
410    /// second byte of the ST terminator) follows.
411    TitleEsc,
412}
413
414const ESC: u8 = 0x1B;
415const BEL: u8 = 0x07;
416
417impl TitleRewriter {
418    /// Passes `chunk` through the rewriter, returning the bytes that should
419    /// actually be written downstream. `prefix` is the current
420    /// `"{glyph} {label} · "` string to prepend to a rewritten title, or
421    /// `None` while no model is known yet.
422    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                        // Not a valid ST terminator: the whole candidate is
475                        // malformed, so flush verbatim rather than guess.
476                        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    /// A completed sequence: rewrites it when `prefix` and a valid UTF-8
488    /// title are both available, otherwise flushes the original bytes
489    /// unchanged. Cached either way, for [`Self::resync`].
490    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    /// Re-emits the most recently completed title sequence under a new
511    /// `prefix`, so a title Claude already asserted — before any model was
512    /// known, or under a now-stale one — is corrected without waiting for
513    /// Claude to reassert it on its own (which, empirically, it may never
514    /// do again for the rest of the session). Returns `None` when nothing
515    /// has completed yet, `prefix` is still unavailable, or the cached title
516    /// is not valid UTF-8.
517    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    /// Flushes everything buffered so far, unchanged, and returns to `Idle`.
530    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
543/// Whether the OSC title rewrite is enabled — the default, unless
544/// [`NO_TITLE_REWRITE_ENV`] is set to a truthy value.
545fn title_rewrite_enabled() -> bool {
546    !std::env::var(NO_TITLE_REWRITE_ENV).is_ok_and(|v| flag_is_truthy(&v))
547}
548
549/// Whether a `NO_TITLE_REWRITE_ENV`-style flag value should be read as "on".
550/// Split out from [`title_rewrite_enabled`] so the parsing itself is testable
551/// without mutating process-wide environment state.
552fn flag_is_truthy(value: &str) -> bool {
553    matches!(
554        value.trim().to_ascii_lowercase().as_str(),
555        "1" | "true" | "yes" | "on"
556    )
557}
558
559/// Builds the `"{glyph} {label} · "` prefix to prepend to Claude's own OSC
560/// title text for `model_id`, classified via the shared [`ModelRegistry`]
561/// (issue #1445).
562///
563/// [`ModelRegistry`]: crate::claude::model_config::ModelRegistry
564fn 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
569/// Reassembles newline-delimited lines out of a forwarded chunk and offers each
570/// to the observer, dropping any that is over-long, not UTF-8, or arrives while
571/// the channel is full.
572fn 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
592/// Consumes teed lines, tracks the session state, and reports every change to
593/// the daemon — plus a keep-alive while nothing changes, and an `end` once the
594/// stream is over.
595/// `every` is the keep-alive cadence, injected so tests can drive it without
596/// waiting out the real [`KEEPALIVE_INTERVAL`]. `child_pid` is the wrapped
597/// `claude`, sent on every report — the same pid its hooks send — so the `end`
598/// this wrapper sends when an in-place resume has already replaced its child
599/// cannot end the resumed session (#1948). It is also the seed for pid-based
600/// liveness (#1916), whose identity-token reading is entirely the daemon's own
601/// [`pid_watcher`](crate::sessions::pid_watcher) work, never this wrapper's.
602async 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    // The first tick of a tokio interval completes immediately; consume it so
616    // the keep-alive does not fire before anything has been observed.
617    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        // Publish the classified title prefix whenever the model changes —
628        // independent of `observed`, since a mid-session model switch does
629        // not necessarily move `SessionState` (issue #1445). The pump's
630        // `watch::Receiver::borrow` on the other end never blocks on this.
631        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
653/// Sends one bounded, fire-and-forget op to the daemon's `sessions` service,
654/// swallowing every failure — a missing or wedged daemon must be a silent no-op.
655async 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
660/// Relays `SIGINT`/`SIGTERM` to the child, so a caller that signals the wrapper
661/// stops Claude rather than orphaning it.
662async 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
682/// The exit code to leave with: the child's own, or the shell's `128 + signo`
683/// when it died on a signal.
684fn 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    /// An [`AsyncWrite`] that appends into a shared buffer, so a test can assert
704    /// on exactly the bytes the wrapper forwarded.
705    #[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    /// Runs `/bin/sh -c script` through the wrapper, returning its exit code and
740    /// everything it wrote downstream.
741    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    /// Parses a `claude-wrap` argument list the way the real CLI would.
756    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    /// A one-shot daemon stand-in that records every envelope it is sent.
763    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    // Note: there is deliberately no test that drives `run`/`wrap` to completion.
822    // Both join the *process's own* stdin and stdout, which a test must not take
823    // over — and `run`'s terminal branch would `exec`-replace the test binary
824    // outright when the suite is run from an interactive shell. They are thin
825    // delegations; `wrap_io` underneath them is covered directly.
826
827    // Note: `exec_replace` is deliberately untested. On success it never returns,
828    // and its failure path cannot be exercised in-process either: `exec` resets
829    // `SIGPIPE` to `SIG_DFL` before the `execve`, and a failed `exec` leaves that
830    // reset in place (the "broken state" its docs warn about). Calling it from a
831    // test poisons the whole binary — every later test that writes to a closed
832    // pipe dies on signal 13.
833
834    #[tokio::test]
835    async fn signal_forwarding_gives_up_when_there_is_no_child() {
836        // A child already reaped has no pid to signal; this must return rather
837        // than spin.
838        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        // A partial trailing line and a non-JSON line: neither is parseable, and
845        // both must still arrive downstream byte-for-byte.
846        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        // A minimal but real turn: announce, work, ask permission, finish.
856        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        // Identity rides the reports, and the stream's end is announced.
877        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        // A socket path that does not exist: every report fails silently.
888        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        // Only the last (short, valid UTF-8) line is offered to the observer.
930        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        // The wrapper's TTL-liveness guarantee: a session that says nothing more
939        // must keep being reported, or the registry ages it out at 5 minutes.
940        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        // One for the state change, then repeats carrying the same state.
963        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            // The wrapped child's pid, so a resume can tell it apart (#1948).
968            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        // A closed peer (the editor exiting mid-turn) must end the copy, not
978        // spin on a writer that can never succeed again.
979        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        // Returns rather than hanging, and tees nothing it could not forward.
1003        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        // Isolates pump()'s select! loop from the rest of the observe()
1017        // pipeline: drives the watch channel directly, with a reader that
1018        // never offers a second chunk, to prove the resync path does not
1019        // depend on Claude sending anything else.
1020        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        // Two of the three were dropped; nothing blocked, nothing panicked.
1055        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        // OSC 8 (hyperlink) must never be mistaken for a title sequence.
1111        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        // ESC not immediately followed by `\` is not a valid ST: the whole
1129        // candidate must be forwarded verbatim rather than guessed at.
1130        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        // The exact real-world case this exists for: Claude's one-and-only
1152        // title assertion raced ahead of the model becoming known, so it
1153        // went out unrewritten — resync must still be able to correct it
1154        // later, from the cache, with no further help from Claude.
1155        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        // Models the real race, not the happy path: real Claude asserts its
1190        // title once, at startup, *before* the model can possibly be known
1191        // (the observer has not even received the init line yet) — and,
1192        // empirically, does not reassert it later in the session. So the
1193        // very first assertion here is forwarded unrewritten (correct,
1194        // fail-open behavior), and the `sleep` that follows leaves the child
1195        // otherwise idle long enough for the async observer pipeline to
1196        // classify the model and for the pump's watch-driven resync to
1197        // correct the title on its own, with no further help from Claude.
1198        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        // The original, un-decorated assertion still reaches stdout exactly
1206        // as Claude sent it — fail-open means "forward unchanged", not "hold
1207        // back and hope".
1208        assert!(
1209            forwarded.contains("\x1b]0;2.1.132\x07"),
1210            "the original title should still have been forwarded verbatim: {forwarded:?}"
1211        );
1212        // And it is followed, with no further title assertion from Claude,
1213        // by a corrected one carrying the classified model prefix.
1214        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    // Note: there is deliberately no end-to-end test that sets
1225    // `NO_TITLE_REWRITE_ENV` and drives `wrap_script` — mutating that real
1226    // variable would race any other test in this module concurrently
1227    // spawning `wrap_io` (which reads it too), exactly the hazard
1228    // `request_log.rs`'s env-var tests avoid by mutating a private test-only
1229    // name instead. `flag_is_truthy_recognises_on_and_off_values` above
1230    // covers the parsing directly; the one-line `.then_some(title_rx)` wiring
1231    // in `wrap_io` needs no separate test.
1232}