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;
35
36use crate::daemon::client::DaemonClient;
37use crate::daemon::protocol::DaemonEnvelope;
38use crate::daemon::server;
39use crate::sessions::stream::{Direction, StreamTracker};
40
41/// The `sessions` service routing key on the daemon control socket.
42const SERVICE: &str = "sessions";
43
44/// How long a fire-and-forget report waits for the daemon before giving up.
45/// Short, and on a task that no byte ever waits behind.
46const REPORT_TIMEOUT: Duration = Duration::from_secs(2);
47
48/// How often the current state is re-reported while the child lives.
49///
50/// Comfortably inside the registry's 300s session TTL, so a session that sits
51/// idle at the prompt for an hour never ages out. This is the wrapper's bonus
52/// over the hook and transcript feeds: liveness bounded by the real process
53/// lifetime rather than by observed activity.
54const KEEPALIVE_INTERVAL: Duration = Duration::from_secs(30);
55
56/// How many teed lines may be in flight before further ones are dropped.
57/// Generous for the two-second worst case of a slow daemon, and bounded so a
58/// wedged observer can never grow memory or slow the child's I/O.
59const TEE_CAPACITY: usize = 256;
60
61/// Longest stream-json line the tee will reassemble; longer lines are forwarded
62/// verbatim like every other byte but not parsed. Matches the daemon control
63/// socket's own line ceiling.
64const MAX_LINE_BYTES: usize = 1024 * 1024;
65
66/// Read-buffer size for each direction of the byte pump.
67const PUMP_BUF_BYTES: usize = 64 * 1024;
68
69/// Exit status used when the child is killed by a signal, mirroring the shell's
70/// `128 + signo` convention.
71const SIGNAL_EXIT_BASE: i32 = 128;
72
73/// Runs a command transparently, reporting the Claude session state it streams.
74#[derive(Parser)]
75pub struct ClaudeWrapCommand {
76    /// Path to the daemon control socket. Defaults to the standard per-user path.
77    #[arg(long, value_name = "PATH")]
78    pub socket: Option<PathBuf>,
79
80    /// The command to run and its arguments, after a `--` separator.
81    #[arg(
82        trailing_var_arg = true,
83        allow_hyphen_values = true,
84        value_name = "CMD"
85    )]
86    pub argv: Vec<String>,
87}
88
89impl ClaudeWrapCommand {
90    /// Executes the wrapper: runs the given command, forwards its stdio
91    /// verbatim, and exits with the child's own status.
92    ///
93    /// This never returns normally on the wrapped path — it calls
94    /// [`std::process::exit`] so the child's exit code survives, which the
95    /// framework's `Result`-to-0/1 collapse in `main` could not express. The
96    /// consequence is that a `claude-wrap` invocation writes no request-log
97    /// record, which is deliberate: it is a process shim, not a user command.
98    pub async fn execute(self) -> Result<()> {
99        let code = self.run().await?;
100        std::process::exit(code)
101    }
102
103    /// Runs the wrapped command and returns the exit code it should be reported
104    /// with. Split out of [`execute`](Self::execute) so everything but the
105    /// process-ending `exit` itself is testable.
106    async fn run(self) -> Result<i32> {
107        let Some((program, args)) = self.argv.split_first() else {
108            bail!(
109                "claude-wrap needs a command to run, e.g. \
110                 `omni-dev claude-wrap -- claude --output-format stream-json`"
111            );
112        };
113
114        // An interactive launch is not the stream-json protocol, so there is
115        // nothing to observe and no reason to sit in the middle of it: replace
116        // this process with the child outright, which is as transparent as it
117        // gets (same pid, same terminal, same job control, same exit status).
118        if std::io::stdout().is_terminal() {
119            return Err(exec_replace(program, args));
120        }
121
122        wrap(program, args, self.socket).await
123    }
124}
125
126/// Replaces this process with `program`, returning the error only if the `exec`
127/// itself failed (on success it never returns).
128fn exec_replace(program: &str, args: &[String]) -> anyhow::Error {
129    let error = std::process::Command::new(program).args(args).exec();
130    anyhow::Error::new(error).context(format!("failed to exec {program}"))
131}
132
133/// Wraps `program`, joining it to this process's own stdin and stdout.
134async fn wrap(program: &str, args: &[String], socket: Option<PathBuf>) -> Result<i32> {
135    wrap_io(
136        program,
137        args,
138        socket,
139        tokio::io::stdin(),
140        tokio::io::stdout(),
141    )
142    .await
143}
144
145/// Spawns the child with piped stdio, pumps `input` and `output` through it
146/// verbatim while teeing both directions to the observer, and returns the
147/// child's exit code.
148///
149/// Generic over the two endpoints so the wrapping can be tested against a real
150/// child process without touching the test runner's own stdio — which is also
151/// the only way to assert that the forwarding is byte-for-byte lossless.
152async fn wrap_io<R, W>(
153    program: &str,
154    args: &[String],
155    socket: Option<PathBuf>,
156    input: R,
157    output: W,
158) -> Result<i32>
159where
160    R: AsyncRead + Unpin + Send + 'static,
161    W: AsyncWrite + Unpin + Send + 'static,
162{
163    // stderr is inherited and env is left untouched, so the child sees exactly
164    // the environment it would have without the wrapper. The child is
165    // deliberately *not* put in its own process group (unlike the managed
166    // `claude -p` subprocess in `crate::claude::ai::claude_cli`): a transparent
167    // wrapper must leave the child in the caller's group or job control breaks.
168    let mut child = tokio::process::Command::new(program)
169        .args(args)
170        .stdin(Stdio::piped())
171        .stdout(Stdio::piped())
172        .stderr(Stdio::inherit())
173        .spawn()
174        .with_context(|| format!("failed to spawn {program}"))?;
175
176    let child_stdin = child
177        .stdin
178        .take()
179        .ok_or_else(|| anyhow!("failed to capture the wrapped process's stdin"))?;
180    let child_stdout = child
181        .stdout
182        .take()
183        .ok_or_else(|| anyhow!("failed to capture the wrapped process's stdout"))?;
184    let child_pid = child.id();
185
186    let (tee, lines) = mpsc::channel::<(Direction, String)>(TEE_CAPACITY);
187    let observer = tokio::spawn(observe(lines, socket, KEEPALIVE_INTERVAL));
188    let signals = tokio::spawn(forward_signals(child_pid));
189
190    let from_child = tokio::spawn(pump(
191        child_stdout,
192        output,
193        Direction::FromClaude,
194        tee.clone(),
195    ));
196    let to_child = tokio::spawn(pump(input, child_stdin, Direction::ToClaude, tee.clone()));
197    drop(tee);
198
199    // Draining the child's stdout to EOF *before* reaping is what makes the
200    // wrapper lossless: every byte the child wrote reaches our stdout, exactly
201    // as it would have without us in the middle.
202    let _ = from_child.await;
203    // Our own stdin never reaches EOF on its own, so the input pump has to be
204    // cancelled; awaiting the aborted handle drops its tee sender, which is what
205    // eventually lets the observer finish.
206    to_child.abort();
207    let _ = to_child.await;
208    signals.abort();
209
210    let status = child
211        .wait()
212        .await
213        .context("failed to wait for the wrapped process")?;
214    let _ = observer.await;
215    Ok(exit_code(status))
216}
217
218/// Copies `reader` to `writer` byte-for-byte, teeing complete lines to the
219/// observer as it goes.
220///
221/// The copy is the priority: bytes are written and flushed before the tee is
222/// even attempted, and the tee is a non-blocking [`try_send`] whose failure is
223/// ignored. No I/O here can be delayed by the observer or the daemon.
224///
225/// [`try_send`]: tokio::sync::mpsc::Sender::try_send
226async fn pump<R, W>(
227    mut reader: R,
228    mut writer: W,
229    direction: Direction,
230    tee: mpsc::Sender<(Direction, String)>,
231) where
232    R: AsyncRead + Unpin,
233    W: AsyncWrite + Unpin,
234{
235    let mut buffer = vec![0u8; PUMP_BUF_BYTES];
236    let mut line = Vec::new();
237    loop {
238        let read = match reader.read(&mut buffer).await {
239            Ok(0) | Err(_) => break,
240            Ok(read) => read,
241        };
242        let chunk = &buffer[..read];
243        if writer.write_all(chunk).await.is_err() || writer.flush().await.is_err() {
244            break;
245        }
246        tee_chunk(chunk, direction, &tee, &mut line);
247    }
248    // Closing the write end propagates EOF (this is how the child learns its
249    // input has ended); a failure here means the peer is already gone.
250    let _ = writer.shutdown().await;
251}
252
253/// Reassembles newline-delimited lines out of a forwarded chunk and offers each
254/// to the observer, dropping any that is over-long, not UTF-8, or arrives while
255/// the channel is full.
256fn tee_chunk(
257    chunk: &[u8],
258    direction: Direction,
259    tee: &mpsc::Sender<(Direction, String)>,
260    line: &mut Vec<u8>,
261) {
262    for &byte in chunk {
263        if byte == b'\n' {
264            if line.len() < MAX_LINE_BYTES {
265                if let Ok(text) = std::str::from_utf8(line) {
266                    let _ = tee.try_send((direction, text.to_string()));
267                }
268            }
269            line.clear();
270        } else if line.len() < MAX_LINE_BYTES {
271            line.push(byte);
272        }
273    }
274}
275
276/// Consumes teed lines, tracks the session state, and reports every change to
277/// the daemon — plus a keep-alive while nothing changes, and an `end` once the
278/// stream is over.
279/// `every` is the keep-alive cadence, injected so tests can drive it without
280/// waiting out the real [`KEEPALIVE_INTERVAL`].
281async fn observe(
282    mut lines: mpsc::Receiver<(Direction, String)>,
283    socket: Option<PathBuf>,
284    every: Duration,
285) {
286    let Ok(socket) = server::resolve_socket(socket) else {
287        return;
288    };
289    let mut tracker = StreamTracker::new();
290    let mut keepalive = tokio::time::interval(every);
291    // The first tick of a tokio interval completes immediately; consume it so
292    // the keep-alive does not fire before anything has been observed.
293    keepalive.tick().await;
294
295    loop {
296        let observed = tokio::select! {
297            line = lines.recv() => match line {
298                Some((direction, text)) => tracker.observe_line(direction, &text),
299                None => break,
300            },
301            _ = keepalive.tick() => tracker.keepalive(),
302        };
303        if let Some(request) = observed {
304            if let Ok(payload) = serde_json::to_value(request) {
305                report(&socket, "observe", payload).await;
306            }
307        }
308    }
309
310    if let Some(session_id) = tracker.session_id() {
311        report(&socket, "end", json!({ "session_id": session_id })).await;
312    }
313}
314
315/// Sends one bounded, fire-and-forget op to the daemon's `sessions` service,
316/// swallowing every failure — a missing or wedged daemon must be a silent no-op.
317async fn report(socket: &Path, op: &str, payload: Value) {
318    let envelope = DaemonEnvelope::service(SERVICE, op, payload);
319    let _ = tokio::time::timeout(REPORT_TIMEOUT, DaemonClient::new(socket).request(envelope)).await;
320}
321
322/// Relays `SIGINT`/`SIGTERM` to the child, so a caller that signals the wrapper
323/// stops Claude rather than orphaning it.
324async fn forward_signals(child_pid: Option<u32>) {
325    let Some(pid) = child_pid.and_then(|pid| i32::try_from(pid).ok()) else {
326        return;
327    };
328    let pid = nix::unistd::Pid::from_raw(pid);
329    let (Ok(mut terminate), Ok(mut interrupt)) = (
330        tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()),
331        tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt()),
332    ) else {
333        return;
334    };
335    loop {
336        let signal = tokio::select! {
337            _ = terminate.recv() => nix::sys::signal::Signal::SIGTERM,
338            _ = interrupt.recv() => nix::sys::signal::Signal::SIGINT,
339        };
340        let _ = nix::sys::signal::kill(pid, signal);
341    }
342}
343
344/// The exit code to leave with: the child's own, or the shell's `128 + signo`
345/// when it died on a signal.
346fn exit_code(status: ExitStatus) -> i32 {
347    status
348        .code()
349        .or_else(|| status.signal().map(|signal| SIGNAL_EXIT_BASE + signal))
350        .unwrap_or(1)
351}
352
353#[cfg(test)]
354#[allow(clippy::unwrap_used, clippy::expect_used)]
355mod tests {
356    use std::pin::Pin;
357    use std::sync::{Arc, Mutex};
358    use std::task::{Context as TaskContext, Poll};
359
360    use tokio::io::AsyncBufReadExt;
361    use tokio::net::UnixListener;
362
363    use super::*;
364
365    /// An [`AsyncWrite`] that appends into a shared buffer, so a test can assert
366    /// on exactly the bytes the wrapper forwarded.
367    #[derive(Clone, Default)]
368    struct Sink(Arc<Mutex<Vec<u8>>>);
369
370    impl Sink {
371        fn contents(&self) -> String {
372            String::from_utf8(self.0.lock().unwrap().clone()).unwrap()
373        }
374    }
375
376    impl AsyncWrite for Sink {
377        fn poll_write(
378            self: Pin<&mut Self>,
379            _cx: &mut TaskContext<'_>,
380            buf: &[u8],
381        ) -> Poll<std::io::Result<usize>> {
382            self.0.lock().unwrap().extend_from_slice(buf);
383            Poll::Ready(Ok(buf.len()))
384        }
385
386        fn poll_flush(
387            self: Pin<&mut Self>,
388            _cx: &mut TaskContext<'_>,
389        ) -> Poll<std::io::Result<()>> {
390            Poll::Ready(Ok(()))
391        }
392
393        fn poll_shutdown(
394            self: Pin<&mut Self>,
395            _cx: &mut TaskContext<'_>,
396        ) -> Poll<std::io::Result<()>> {
397            Poll::Ready(Ok(()))
398        }
399    }
400
401    /// Runs `/bin/sh -c script` through the wrapper, returning its exit code and
402    /// everything it wrote downstream.
403    async fn wrap_script(script: &str, socket: Option<PathBuf>) -> (i32, String) {
404        let sink = Sink::default();
405        let code = wrap_io(
406            "/bin/sh",
407            &["-c".to_string(), script.to_string()],
408            socket,
409            tokio::io::empty(),
410            sink.clone(),
411        )
412        .await
413        .unwrap();
414        (code, sink.contents())
415    }
416
417    /// Parses a `claude-wrap` argument list the way the real CLI would.
418    fn parse(args: &[&str]) -> ClaudeWrapCommand {
419        let mut argv = vec!["claude-wrap"];
420        argv.extend_from_slice(args);
421        ClaudeWrapCommand::try_parse_from(argv).unwrap()
422    }
423
424    /// A one-shot daemon stand-in that records every envelope it is sent.
425    fn fake_daemon() -> (
426        tempfile::TempDir,
427        PathBuf,
428        Arc<tokio::sync::Mutex<Vec<Value>>>,
429    ) {
430        let dir = tempfile::tempdir_in("/tmp").unwrap();
431        let socket = dir.path().join("d.sock");
432        let listener = UnixListener::bind(&socket).unwrap();
433        let seen = Arc::new(tokio::sync::Mutex::new(Vec::new()));
434        let recorder = Arc::clone(&seen);
435        tokio::spawn(async move {
436            loop {
437                let Ok((stream, _)) = listener.accept().await else {
438                    return;
439                };
440                let recorder = Arc::clone(&recorder);
441                tokio::spawn(async move {
442                    let (read, mut write) = stream.into_split();
443                    let mut line = String::new();
444                    if tokio::io::BufReader::new(read)
445                        .read_line(&mut line)
446                        .await
447                        .is_ok()
448                    {
449                        if let Ok(value) = serde_json::from_str::<Value>(&line) {
450                            recorder.lock().await.push(value);
451                        }
452                    }
453                    let _ = write.write_all(b"{\"ok\":true,\"payload\":{}}\n").await;
454                });
455            }
456        });
457        (dir, socket, seen)
458    }
459
460    #[test]
461    fn trailing_args_are_captured_verbatim_after_a_separator() {
462        let cmd = parse(&["--", "node", "cli.js", "--output-format", "stream-json"]);
463        assert_eq!(
464            cmd.argv,
465            vec!["node", "cli.js", "--output-format", "stream-json"]
466        );
467        assert!(cmd.socket.is_none());
468    }
469
470    #[test]
471    fn the_socket_override_is_parsed_before_the_separator() {
472        let cmd = parse(&["--socket", "/tmp/d.sock", "--", "claude", "-p"]);
473        assert_eq!(cmd.socket.unwrap(), PathBuf::from("/tmp/d.sock"));
474        assert_eq!(cmd.argv, vec!["claude", "-p"]);
475    }
476
477    #[tokio::test]
478    async fn an_empty_command_is_a_usage_error() {
479        let error = parse(&[]).run().await.unwrap_err();
480        assert!(error.to_string().contains("needs a command to run"));
481    }
482
483    // Note: there is deliberately no test that drives `run`/`wrap` to completion.
484    // Both join the *process's own* stdin and stdout, which a test must not take
485    // over — and `run`'s terminal branch would `exec`-replace the test binary
486    // outright when the suite is run from an interactive shell. They are thin
487    // delegations; `wrap_io` underneath them is covered directly.
488
489    // Note: `exec_replace` is deliberately untested. On success it never returns,
490    // and its failure path cannot be exercised in-process either: `exec` resets
491    // `SIGPIPE` to `SIG_DFL` before the `execve`, and a failed `exec` leaves that
492    // reset in place (the "broken state" its docs warn about). Calling it from a
493    // test poisons the whole binary — every later test that writes to a closed
494    // pipe dies on signal 13.
495
496    #[tokio::test]
497    async fn signal_forwarding_gives_up_when_there_is_no_child() {
498        // A child already reaped has no pid to signal; this must return rather
499        // than spin.
500        forward_signals(None).await;
501    }
502
503    #[tokio::test]
504    async fn the_child_is_wrapped_losslessly_and_its_exit_code_survives() {
505        let (_dir, socket, _seen) = fake_daemon();
506        // A partial trailing line and a non-JSON line: neither is parseable, and
507        // both must still arrive downstream byte-for-byte.
508        let (code, forwarded) =
509            wrap_script("printf 'hello\\nnot json\\ntrailing'; exit 7", Some(socket)).await;
510        assert_eq!(code, 7);
511        assert_eq!(forwarded, "hello\nnot json\ntrailing");
512    }
513
514    #[tokio::test]
515    async fn stream_state_transitions_reach_the_daemon() {
516        let (_dir, socket, seen) = fake_daemon();
517        // A minimal but real turn: announce, work, ask permission, finish.
518        let script = concat!(
519            r#"printf '{"type":"system","subtype":"init","session_id":"s-1","cwd":"/w"}\n';"#,
520            r#"printf '{"type":"assistant","session_id":"s-1"}\n';"#,
521            r#"printf '{"type":"control_request","request_id":"r1","request":{"subtype":"can_use_tool"}}\n';"#,
522            r#"printf '{"type":"result"}\n'"#,
523        );
524        let (code, _forwarded) = wrap_script(script, Some(socket)).await;
525        assert_eq!(code, 0);
526
527        let envelopes = seen.lock().await.clone();
528        let states: Vec<String> = envelopes
529            .iter()
530            .filter(|e| e["op"] == "observe")
531            .filter_map(|e| e["payload"]["event"]["stream_state"].as_str())
532            .map(ToString::to_string)
533            .collect();
534        assert_eq!(
535            states,
536            vec!["idle", "working", "waiting_for_permission", "idle"]
537        );
538        // Identity rides the reports, and the stream's end is announced.
539        assert_eq!(envelopes[0]["service"], "sessions");
540        assert_eq!(envelopes[0]["payload"]["session_id"], "s-1");
541        assert_eq!(envelopes[0]["payload"]["cwd"], "/w");
542        let ended = envelopes.last().unwrap();
543        assert_eq!(ended["op"], "end");
544        assert_eq!(ended["payload"]["session_id"], "s-1");
545    }
546
547    #[tokio::test]
548    async fn a_missing_daemon_never_affects_the_child() {
549        // A socket path that does not exist: every report fails silently.
550        let dir = tempfile::tempdir_in("/tmp").unwrap();
551        let script = concat!(
552            r#"printf '{"type":"system","subtype":"init","session_id":"s-1"}\n';"#,
553            r#"printf 'not json at all\n';"#,
554            r#"exit 3"#,
555        );
556        let (code, forwarded) = wrap_script(script, Some(dir.path().join("absent.sock"))).await;
557        assert_eq!(code, 3);
558        assert!(forwarded.ends_with("not json at all\n"));
559    }
560
561    #[tokio::test]
562    async fn a_child_killed_by_a_signal_reports_the_shell_convention() {
563        let (_dir, socket, _seen) = fake_daemon();
564        let (code, _forwarded) = wrap_script("kill -TERM $$", Some(socket)).await;
565        assert_eq!(code, SIGNAL_EXIT_BASE + 15);
566    }
567
568    #[tokio::test]
569    async fn spawning_a_missing_program_is_an_error_not_a_panic() {
570        let error = wrap_io(
571            "/nonexistent/omni-dev-test-binary",
572            &[],
573            None,
574            tokio::io::empty(),
575            Sink::default(),
576        )
577        .await
578        .unwrap_err();
579        assert!(error.to_string().contains("failed to spawn"));
580    }
581
582    #[test]
583    fn over_long_and_non_utf8_lines_are_dropped_but_still_forwarded() {
584        let (tee, mut lines) = mpsc::channel(4);
585        let mut buffer = Vec::new();
586        let mut chunk = vec![b'x'; MAX_LINE_BYTES + 1];
587        chunk.push(b'\n');
588        chunk.extend_from_slice(&[0xff, 0xfe, b'\n']);
589        chunk.extend_from_slice(b"{\"type\":\"result\"}\n");
590        tee_chunk(&chunk, Direction::FromClaude, &tee, &mut buffer);
591        // Only the last (short, valid UTF-8) line is offered to the observer.
592        let (direction, text) = lines.try_recv().unwrap();
593        assert_eq!(direction, Direction::FromClaude);
594        assert_eq!(text, r#"{"type":"result"}"#);
595        assert!(lines.try_recv().is_err());
596    }
597
598    #[tokio::test]
599    async fn the_keepalive_re_reports_a_silent_session() {
600        // The wrapper's TTL-liveness guarantee: a session that says nothing more
601        // must keep being reported, or the registry ages it out at 5 minutes.
602        let (_dir, socket, seen) = fake_daemon();
603        let (tee, lines) = mpsc::channel(4);
604        let observer = tokio::spawn(observe(lines, Some(socket), Duration::from_millis(20)));
605        tee.send((
606            Direction::FromClaude,
607            r#"{"type":"system","subtype":"init","session_id":"ka-1"}"#.to_string(),
608        ))
609        .await
610        .unwrap();
611        tokio::time::sleep(Duration::from_millis(120)).await;
612        drop(tee);
613        observer.await.unwrap();
614
615        let envelopes = seen.lock().await.clone();
616        let observes: Vec<_> = envelopes.iter().filter(|e| e["op"] == "observe").collect();
617        // One for the state change, then repeats carrying the same state.
618        assert!(observes.len() > 1, "expected keep-alives, got {observes:?}");
619        for envelope in &observes {
620            assert_eq!(envelope["payload"]["session_id"], "ka-1");
621            assert_eq!(envelope["payload"]["event"]["stream_state"], "idle");
622        }
623        assert_eq!(envelopes.last().unwrap()["op"], "end");
624    }
625
626    #[tokio::test]
627    async fn the_pump_stops_when_the_far_side_goes_away() {
628        // A closed peer (the editor exiting mid-turn) must end the copy, not
629        // spin on a writer that can never succeed again.
630        struct Closed;
631        impl AsyncWrite for Closed {
632            fn poll_write(
633                self: Pin<&mut Self>,
634                _cx: &mut TaskContext<'_>,
635                _buf: &[u8],
636            ) -> Poll<std::io::Result<usize>> {
637                Poll::Ready(Err(std::io::Error::from(std::io::ErrorKind::BrokenPipe)))
638            }
639            fn poll_flush(
640                self: Pin<&mut Self>,
641                _cx: &mut TaskContext<'_>,
642            ) -> Poll<std::io::Result<()>> {
643                Poll::Ready(Ok(()))
644            }
645            fn poll_shutdown(
646                self: Pin<&mut Self>,
647                _cx: &mut TaskContext<'_>,
648            ) -> Poll<std::io::Result<()>> {
649                Poll::Ready(Ok(()))
650            }
651        }
652        let (tee, mut lines) = mpsc::channel(4);
653        // Returns rather than hanging, and tees nothing it could not forward.
654        pump(
655            &b"{\"type\":\"result\"}\n"[..],
656            Closed,
657            Direction::FromClaude,
658            tee,
659        )
660        .await;
661        assert!(lines.try_recv().is_err());
662    }
663
664    #[test]
665    fn a_full_tee_drops_lines_rather_than_blocking() {
666        let (tee, _lines) = mpsc::channel(1);
667        let mut buffer = Vec::new();
668        tee_chunk(b"a\nb\nc\n", Direction::ToClaude, &tee, &mut buffer);
669        // Two of the three were dropped; nothing blocked, nothing panicked.
670        assert_eq!(tee.capacity(), 0);
671    }
672}