Skip to main content

claude_wrapper/
duplex.rs

1//! Long-lived duplex stream-json sessions.
2//!
3//! [`DuplexSession`] holds a `claude` subprocess open in
4//! `--input-format stream-json --output-format stream-json` mode for
5//! the duration of a conversation. A single child is held open across
6//! many turns; user messages are written to its stdin, NDJSON events
7//! are read from its stdout and dispatched back to `send()` callers.
8//!
9//! # When to use
10//!
11//! [`DuplexSession`] is the recommended primitive for long-running
12//! hosts that drive multi-turn conversations: agent servers, IDE
13//! backends, daemons, chat UIs. Holding the child open across turns
14//! amortizes init cost and unlocks capabilities that are awkward or
15//! impossible from a transient subprocess: mid-turn permission
16//! decisions ([`PermissionHandler`]), clean
17//! [interrupts](DuplexSession::interrupt), and a typed
18//! [event subscriber stream](DuplexSession::subscribe) that fans out
19//! events to multiple consumers.
20//!
21//! For short-lived processes (CLIs, build scripts, batch jobs,
22//! lambdas) where each turn can stand on its own, prefer
23//! [`QueryCommand`] for one-off calls or [`Session`] for transient
24//! multi-turn with cumulative cost / history tracking.
25//!
26//! # Cost and budget bookkeeping
27//!
28//! [`DuplexSession`] itself keeps no cross-turn accounting. The cost
29//! on each [`TurnResult`] is the CLI's running total for the session,
30//! not that turn's cost (see [`TurnResult::total_cost_usd`]). For
31//! per-turn cost, cumulative cost, turn history, and a
32//! [`BudgetTracker`]-enforced spend ceiling (send fails fast with
33//! [`Error::BudgetExceeded`] once the ceiling is hit), wrap the
34//! session in a [`Conversation`] --
35//! it is a thin bookkeeping layer over this module, not a different
36//! transport.
37//!
38//! # Resident I/O and process exit
39//!
40//! Stderr is drained continuously, including while a permission handler is
41//! awaiting its decision. Only the latest 64 KiB of diagnostic bytes are kept;
42//! they are included in a nonzero process-exit error. Shutdown drains remaining
43//! stdout and gives stderr a bounded final read, so an escaped descendant
44//! holding a pipe open cannot make diagnostic collection wait forever.
45//!
46//! A turn's [`TurnResult`] and the process exit are separate evidence. A result
47//! can arrive before a later process failure. Use [`DuplexSession::wait_for_exit`]
48//! or [`DuplexSession::close`] for process settlement. An observed zero exit
49//! completes the session; a nonzero exit or I/O/settlement failure is reported
50//! through [`SessionExitStatus::Failed`] and the error returned by `close`.
51//!
52//! # Resource limits and deadlines
53//!
54//! A duplex session is unbounded by default: no per-frame or per-turn
55//! memory ceiling, no turn or control deadline, and no idle lifetime. The
56//! client's [`ClaudeBuilder::output_limit`](crate::ClaudeBuilder::output_limit)
57//! and [`ClaudeBuilder::timeout`](crate::ClaudeBuilder::timeout) apply to
58//! one-shot runs only. Opt in per session:
59//!
60//! - [`DuplexOptions::max_frame_bytes`]: largest single stdout line.
61//! - [`DuplexOptions::max_turn_bytes`]: stdout bytes consumed per turn,
62//!   including newlines, blank lines, and unparseable lines.
63//! - [`DuplexOptions::max_turn_events`]: events retained per
64//!   [`TurnResult`].
65//! - [`DuplexOptions::turn_timeout`]: one turn, from acceptance through
66//!   its `result`.
67//! - [`DuplexOptions::control_timeout`]: one control request, from
68//!   acceptance through its `control_response`.
69//!
70//! Byte limits count wire data, not exact allocator or parsed-JSON heap
71//! usage. The reader also uses a fixed-size input buffer.
72//!
73//! Per-turn counters and deadlines reset for each turn. When any policy
74//! trips, the session closes: new requests are rejected with
75//! [`Error::DuplexClosed`], the owned process group is stopped with the
76//! client's configured [`kill_grace`](crate::ClaudeBuilder::kill_grace) and
77//! [`termination_signal`](crate::ClaudeBuilder::termination_signal) (the
78//! graceful shutdown budget is skipped; with
79//! [`process_group(false)`](crate::ClaudeBuilder::process_group) only the
80//! direct child is killed), the direct child is reaped, and
81//! then every accepted turn and control waiter receives
82//! [`Error::DuplexLimitExceeded`] or [`Error::DuplexDeadlineExceeded`].
83//! Requests still queued but not yet accepted receive
84//! [`Error::DuplexClosed`]. [`DuplexSession::close`] returns the same
85//! policy error. Its `process_settled` field reports whether the direct
86//! child was reaped; it says nothing about escaped descendants, and a
87//! deadline error never implies the CLI acknowledged an interrupt. A turn
88//! that trips a policy never resolves as a truncated successful
89//! [`TurnResult`].
90//!
91//! Dropping a [`DuplexSession::send`] or [`DuplexSession::interrupt`]
92//! future only abandons the wait. The request stays pending in the
93//! session until its `result` or `control_response` arrives, or until its
94//! configured deadline closes the session. An abandoned turn continues to
95//! block another `send` with [`Error::DuplexTurnInFlight`].
96//!
97//! [`QueryCommand`]: crate::QueryCommand
98//! [`Session`]: crate::session::Session
99//! [`Conversation`]: crate::conversation::Conversation
100//! [`BudgetTracker`]: crate::budget::BudgetTracker
101//!
102//! # Example
103//!
104//! ```no_run
105//! use claude_wrapper::Claude;
106//! use claude_wrapper::duplex::{DuplexOptions, DuplexSession};
107//!
108//! # async fn example() -> claude_wrapper::Result<()> {
109//! let claude = Claude::builder().build()?;
110//! let session = DuplexSession::spawn(
111//!     &claude,
112//!     DuplexOptions::default().model("haiku"),
113//! ).await?;
114//!
115//! let turn = session.send("hello").await?;
116//! if let Some(text) = turn.result_text() {
117//!     println!("{text}");
118//! }
119//!
120//! session.close().await?;
121//! # Ok(())
122//! # }
123//! ```
124//!
125//! # Subscribers
126//!
127//! For event-driven UIs that want to react to assistant tokens,
128//! tool-use blocks, or system events as they arrive, call
129//! [`DuplexSession::subscribe`] before issuing a [`DuplexSession::send`].
130//! Each receiver gets its own buffered view of the event stream;
131//! slow consumers see [`tokio::sync::broadcast::error::RecvError::Lagged`]
132//! rather than blocking the session task.
133//!
134//! ```no_run
135//! use claude_wrapper::Claude;
136//! use claude_wrapper::duplex::{DuplexOptions, DuplexSession, InboundEvent};
137//!
138//! # async fn example() -> claude_wrapper::Result<()> {
139//! let claude = Claude::builder().build()?;
140//! let session = DuplexSession::spawn(&claude, DuplexOptions::default()).await?;
141//!
142//! let mut rx = session.subscribe();
143//! let _turn = session.send("hello").await?;
144//!
145//! while let Ok(event) = rx.try_recv() {
146//!     match event {
147//!         InboundEvent::SystemInit { session_id } => {
148//!             println!("session id: {session_id}");
149//!         }
150//!         InboundEvent::Assistant(_) => {
151//!             // partial or complete assistant message
152//!         }
153//!         _ => {}
154//!     }
155//! }
156//!
157//! session.close().await?;
158//! # Ok(())
159//! # }
160//! ```
161//!
162//! For interleaved (concurrent) event handling while a turn is in
163//! flight, drive `rx.recv()` and the `send()` future together via
164//! `tokio::select!`. Pin the send future and use a block scope so
165//! its borrow of the session ends before [`DuplexSession::close`].
166//!
167//! # Mid-turn permission decisions
168//!
169//! Configure a [`PermissionHandler`] at spawn time to answer the
170//! CLI's permission prompts in-flight. The session writes
171//! `--permission-prompt-tool stdio` automatically when a handler is
172//! set, so the CLI emits `control_request` messages for tool use
173//! over the duplex channel rather than blocking on a TUI prompt.
174//!
175//! ```no_run
176//! use claude_wrapper::Claude;
177//! use claude_wrapper::duplex::{
178//!     DuplexOptions, DuplexSession, PermissionDecision, PermissionHandler,
179//! };
180//!
181//! # async fn example() -> claude_wrapper::Result<()> {
182//! let handler = PermissionHandler::new(|req| async move {
183//!     if req.tool_name == "Bash" {
184//!         PermissionDecision::Deny { message: "bash is denied".into() }
185//!     } else {
186//!         PermissionDecision::Allow { updated_input: None }
187//!     }
188//! });
189//!
190//! let claude = Claude::builder().build()?;
191//! let session = DuplexSession::spawn(
192//!     &claude,
193//!     DuplexOptions::default().on_permission(handler),
194//! ).await?;
195//! # Ok(())
196//! # }
197//! ```
198//!
199//! For human-in-the-loop UIs, return [`PermissionDecision::Defer`]
200//! from the handler, capture the [`PermissionRequest::request_id`],
201//! and answer later via [`DuplexSession::respond_to_permission`].
202//!
203//! The callback is the CLI's approval fallback, so an already-approved tool
204//! does not necessarily reach it. For tools that must ask, configure an explicit
205//! ask rule in [`DuplexOptions::settings`], for example
206//! `{"permissions":{"ask":["Bash"]}}`. Do not also auto-approve that action.
207//! Native denial and deferred-denial regressions exercise the resident transport
208//! at the supported 2.1.220 and 2.1.303 endpoints. They do not add SDK hook setup
209//! or change the CLI's permission evaluation rules. See the
210//! [official permission guide](https://code.claude.com/docs/en/agent-sdk/user-input).
211//!
212//! # Mid-turn interrupt
213//!
214//! [`DuplexSession::interrupt`] sends a clean
215//! `control_request {subtype: "interrupt"}` to the CLI. The CLI
216//! stops generating, closes the in-flight turn (`send().await`
217//! resolves with the truncated [`TurnResult`]), and answers our
218//! interrupt with a `control_response`. Use this instead of dropping
219//! the session or killing the child when you want to cancel one
220//! turn but keep the conversation going.
221//!
222//! ```no_run
223//! use std::time::Duration;
224//! use claude_wrapper::Claude;
225//! use claude_wrapper::duplex::{DuplexOptions, DuplexSession};
226//!
227//! # async fn example() -> claude_wrapper::Result<()> {
228//! let claude = Claude::builder().build()?;
229//! let session = DuplexSession::spawn(&claude, DuplexOptions::default()).await?;
230//!
231//! let send_fut = session.send("write a long essay about rust");
232//! let interrupt_fut = async {
233//!     tokio::time::sleep(Duration::from_millis(500)).await;
234//!     session.interrupt().await
235//! };
236//!
237//! let (turn, interrupt_result) = tokio::join!(send_fut, interrupt_fut);
238//! let _truncated = turn?;
239//! interrupt_result?;
240//! # Ok(())
241//! # }
242//! ```
243//!
244//! # Phased rollout
245//!
246//! This module rolled out in four PRs tracked in
247//! <https://github.com/joshrotenberg/claude-wrapper/issues/561>:
248//! `spawn`/`send`/`close` (PR 1), `subscribe` (PR 2), mid-turn
249//! permission handling (PR 3), and `interrupt` (PR 4, this one).
250
251use std::collections::{HashMap, VecDeque};
252use std::future::Future;
253use std::pin::Pin;
254use std::process::{ExitStatus, Stdio};
255use std::sync::Arc;
256use std::time::Duration;
257
258use serde_json::Value;
259use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
260use tokio::process::{Child, ChildStdin, ChildStdout, Command};
261use tokio::sync::{broadcast, mpsc, oneshot, watch};
262use tokio::task::JoinHandle;
263use tokio::time::Instant;
264use tracing::{Instrument, debug, warn};
265
266use crate::Claude;
267use crate::command::spawn_args::{SharedSpawnArgs, shell_quote};
268use crate::error::{DuplexLimit, DuplexOperation, Error, Result};
269use crate::tool_pattern::ToolPattern;
270use crate::types::{Effort, HermeticScope, PermissionMode};
271
272/// Default capacity of the per-session [`broadcast::Sender`] backing
273/// [`DuplexSession::subscribe`].
274///
275/// Override per-session via [`DuplexOptions::subscriber_capacity`].
276pub const DEFAULT_SUBSCRIBER_CAPACITY: usize = 256;
277
278/// A mid-turn permission prompt from the CLI for a single tool
279/// invocation.
280///
281/// Forwarded to the [`PermissionHandler`] registered via
282/// [`DuplexOptions::on_permission`]. Capture
283/// [`Self::request_id`] inside your handler if you intend to return
284/// [`PermissionDecision::Defer`] and answer later via
285/// [`DuplexSession::respond_to_permission`].
286#[derive(Debug, Clone)]
287pub struct PermissionRequest {
288    /// CLI-assigned correlation id. Pass this to
289    /// [`DuplexSession::respond_to_permission`] when deferring.
290    pub request_id: String,
291    /// The tool the model wants to use (e.g. `"Bash"`, `"Edit"`).
292    pub tool_name: String,
293    /// The tool's `input` payload as the model produced it.
294    pub input: Value,
295    /// The full `request` object as sent by the CLI, for fields not
296    /// promoted to typed accessors.
297    pub raw: Value,
298}
299
300/// The decision returned from a [`PermissionHandler`] (or passed to
301/// [`DuplexSession::respond_to_permission`] for deferred decisions).
302///
303/// `Allow` and `Deny` both write a control response to the CLI
304/// immediately. `Defer` causes the run loop to skip writing a
305/// response; the caller is then expected to invoke
306/// [`DuplexSession::respond_to_permission`] later. Passing `Defer`
307/// to `respond_to_permission` is a no-op.
308#[derive(Debug, Clone)]
309pub enum PermissionDecision {
310    /// Allow the tool to run, optionally with rewritten input.
311    Allow {
312        /// Replace the model's input with this object before running
313        /// the tool. `None` keeps the original input.
314        updated_input: Option<Value>,
315    },
316    /// Deny the tool. The `message` is surfaced to the model.
317    Deny {
318        /// Human-readable explanation given back to the model.
319        message: String,
320    },
321    /// Decision pending; the caller will supply it later via
322    /// [`DuplexSession::respond_to_permission`].
323    Defer,
324}
325
326type PermissionFuture = Pin<Box<dyn Future<Output = PermissionDecision> + Send + 'static>>;
327type PermissionFn = dyn Fn(PermissionRequest) -> PermissionFuture + Send + Sync + 'static;
328
329/// A user-supplied async callback invoked when the CLI requests
330/// permission to use a tool.
331///
332/// Construct with [`Self::new`], passing an `async fn` or
333/// async-block closure. Cheap to clone (`Arc` under the hood).
334///
335/// The handler runs inline on the duplex session's task. The CLI is
336/// blocked on the response while the handler runs, and the session reads
337/// no stdout and accepts no other requests until it returns, so keep it
338/// short: an async policy check (DB lookup, remote call) is fine. If the
339/// decision needs human input, return [`PermissionDecision::Defer`]
340/// promptly and answer via [`DuplexSession::respond_to_permission`] when
341/// ready.
342///
343/// With [`DuplexOptions::turn_timeout`] set, the turn deadline includes
344/// time spent in the handler and any deferred wait. On expiry the
345/// handler's future is dropped and the session closes. A handler invoked
346/// while no deadline-bound operation is pending is not bounded.
347#[derive(Clone)]
348pub struct PermissionHandler {
349    inner: Arc<PermissionFn>,
350}
351
352impl PermissionHandler {
353    /// Wrap an async closure as a permission handler.
354    ///
355    /// # Example
356    ///
357    /// ```
358    /// use claude_wrapper::duplex::{PermissionDecision, PermissionHandler};
359    ///
360    /// let _handler = PermissionHandler::new(|req| async move {
361    ///     if req.tool_name == "Bash" {
362    ///         PermissionDecision::Deny { message: "no bash".into() }
363    ///     } else {
364    ///         PermissionDecision::Allow { updated_input: None }
365    ///     }
366    /// });
367    /// ```
368    pub fn new<F, Fut>(f: F) -> Self
369    where
370        F: Fn(PermissionRequest) -> Fut + Send + Sync + 'static,
371        Fut: Future<Output = PermissionDecision> + Send + 'static,
372    {
373        Self {
374            inner: Arc::new(move |req| Box::pin(f(req))),
375        }
376    }
377
378    fn invoke(&self, req: PermissionRequest) -> PermissionFuture {
379        (self.inner)(req)
380    }
381}
382
383impl std::fmt::Debug for PermissionHandler {
384    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
385        f.debug_struct("PermissionHandler").finish_non_exhaustive()
386    }
387}
388
389/// Configuration for [`DuplexSession::spawn`].
390///
391/// Builder methods cover the spawn-time options shared with
392/// [`QueryCommand`](crate::QueryCommand); the flag emission lives on a
393/// common internal `SharedSpawnArgs`, so the oneshot and duplex paths
394/// cannot drift on how a knob is rendered. The spawn call always
395/// includes
396/// `--print --verbose --input-format stream-json --output-format stream-json`
397/// regardless of these options.
398///
399/// A few `QueryCommand` knobs are intentionally not surfaced here
400/// because they only make sense for a oneshot run or are owned by the
401/// duplex transport itself:
402///
403/// - Transport is fixed: `output_format`, `input_format`, `verbose`, and
404///   `prompt_via_stdin` are pinned by the duplex spawn and not
405///   configurable. Partial message chunks are off by default; opt in with
406///   [`Self::include_partial_messages`].
407/// - `retry_policy` reruns a whole oneshot invocation; a duplex session
408///   holds one child open across turns, so there is nothing to retry at
409///   this layer.
410/// - `brief` and `from_pr` shape a single oneshot run (SendUserMessage
411///   for one-turn agent-to-user replies, resume-from-PR startup); a
412///   duplex host drives turns and session selection itself.
413/// - `prompt_suggestions` and `replay_user_messages` shape stdin/stream
414///   echoing; the duplex layer owns its own stream plumbing.
415///
416/// Use [`Self::arg`] if you need one of these on a duplex spawn anyway.
417///
418/// Resource and time policies ([`Self::max_frame_bytes`],
419/// [`Self::max_turn_bytes`], [`Self::max_turn_events`],
420/// [`Self::turn_timeout`], [`Self::control_timeout`]) are host-side and
421/// emit no CLI flags. All are unlimited by default. See the
422/// [module docs](crate::duplex#resource-limits-and-deadlines).
423#[derive(Debug, Default, Clone)]
424pub struct DuplexOptions {
425    // Spawn-time knobs shared with QueryCommand; the flag emission
426    // lives on SharedSpawnArgs so the two builders cannot drift.
427    shared: SharedSpawnArgs,
428    additional_args: Vec<String>,
429    subscriber_capacity: Option<usize>,
430    on_permission: Option<PermissionHandler>,
431    include_partial_messages: bool,
432    policy: DuplexPolicy,
433}
434
435/// Opt-in host-side resource and time policies. Every field is `None`
436/// (unlimited) by default.
437#[derive(Debug, Default, Clone, Copy)]
438struct DuplexPolicy {
439    max_frame_bytes: Option<usize>,
440    max_turn_bytes: Option<usize>,
441    max_turn_events: Option<usize>,
442    turn_timeout: Option<Duration>,
443    control_timeout: Option<Duration>,
444}
445
446impl DuplexOptions {
447    /// Set the model for this session (`--model`).
448    #[must_use]
449    pub fn model(mut self, model: impl Into<String>) -> Self {
450        self.shared.model = Some(model.into());
451        self
452    }
453
454    /// Set the system prompt for this session (`--system-prompt`).
455    #[must_use]
456    pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
457        self.shared.system_prompt = Some(prompt.into());
458        self
459    }
460
461    /// Append to the default system prompt (`--append-system-prompt`).
462    #[must_use]
463    pub fn append_system_prompt(mut self, prompt: impl Into<String>) -> Self {
464        self.shared.append_system_prompt = Some(prompt.into());
465        self
466    }
467
468    /// Resume a prior session by id (`--resume <session_id>`).
469    ///
470    /// Mirrors [`QueryCommand::resume`](crate::QueryCommand::resume)
471    /// for the duplex path. The spawned `claude` process picks up the
472    /// conversation that produced `session_id` and continues it; turns
473    /// sent through [`DuplexSession::send`] append to the existing
474    /// history rather than starting fresh.
475    ///
476    /// Use case: a host (IDE, MCP server, agent backend) wants to
477    /// upgrade a passive on-disk session to a live duplex one --
478    /// pulls the `session_id` out of the existing JSONL log, opens a
479    /// duplex session here, and the next turn extends the same
480    /// conversation.
481    ///
482    /// `resume` and [`Self::continue_session`] are mutually exclusive
483    /// at the CLI; passing both lets the CLI decide (it errors today).
484    #[must_use]
485    pub fn resume(mut self, session_id: impl Into<String>) -> Self {
486        self.shared.resume = Some(session_id.into());
487        self
488    }
489
490    /// Continue the most recent session in the current working
491    /// directory (`--continue`).
492    ///
493    /// Mirrors [`QueryCommand::continue_session`](crate::QueryCommand::continue_session)
494    /// for the duplex path. Use [`Self::resume`] to pick a specific
495    /// session id; use this when "the last one" is what you want.
496    #[must_use]
497    pub fn continue_session(mut self) -> Self {
498        self.shared.continue_session = true;
499        self
500    }
501
502    /// Run this session in a fresh git worktree (`--worktree [name]`).
503    ///
504    /// `name` is the optional worktree name (the CLI auto-generates
505    /// one if omitted). Calling this method always enables the
506    /// worktree flag, with or without a name.
507    ///
508    /// Use case: an agent host wants the chat's writes isolated from
509    /// the current working tree -- the chat opens with a fresh
510    /// worktree, mutations land there, and the host can inspect or
511    /// merge later.
512    #[must_use]
513    pub fn worktree(mut self, name: Option<impl Into<String>>) -> Self {
514        self.shared.worktree = true;
515        if let Some(n) = name {
516            self.shared.worktree_name = Some(n.into());
517        }
518        self
519    }
520
521    /// Pin the session to a named subagent (`--agent <name>`).
522    ///
523    /// `name` is resolved by the CLI in this order: inline
524    /// definitions from [`Self::agents_json`], then user-level
525    /// `~/.claude/agents/<name>.md` files, then project-level dirs
526    /// loaded by the active `--setting-sources`.
527    ///
528    /// **Caveat**: as of Claude Code 2.1.143, the CLI silently
529    /// ignores an unknown `name` and falls back to the default
530    /// behavior -- no warning, no error. Callers that want a hard
531    /// "agent must exist" semantics should validate the name out of
532    /// band (e.g. via [`crate::artifacts::AgentsRoot::get`]) before
533    /// passing it here.
534    #[must_use]
535    pub fn agent(mut self, name: impl Into<String>) -> Self {
536        self.shared.agent = Some(name.into());
537        self
538    }
539
540    /// Inline subagent definitions for this session
541    /// (`--agents <json>`).
542    ///
543    /// `json` is a JSON object keyed by agent name, with each value
544    /// carrying at least `description` and `prompt`. Inline
545    /// definitions take precedence over on-disk
546    /// `~/.claude/agents/*.md` of the same name. Pass [`Self::agent`]
547    /// to select which one to use as the session's persona.
548    ///
549    /// Example: `{"reviewer": {"description": "Reviews code",
550    /// "prompt": "You are a code reviewer"}}`.
551    #[must_use]
552    pub fn agents_json(mut self, json: impl Into<String>) -> Self {
553        self.shared.agents_json = Some(json.into());
554        self
555    }
556
557    /// Set the permission mode for this session
558    /// (`--permission-mode <mode>`).
559    ///
560    /// Mirrors [`QueryCommand::permission_mode`](crate::QueryCommand::permission_mode)
561    /// for the duplex path. The default mode (when this method isn't
562    /// called) drops to the CLI's interactive prompt for every
563    /// tool-use approval, which is broken for non-interactive duplex
564    /// sessions -- nothing answers the prompts and the session stalls
565    /// or fails. Call this with [`PermissionMode::AcceptEdits`] for
566    /// the "edit files autonomously" pattern, [`PermissionMode::Plan`]
567    /// for read-only planning, etc.
568    ///
569    /// Bypass mode is a footgun; reach for [`Self::dangerously_skip_permissions`]
570    /// (or, for stricter discipline, [`crate::dangerous::DangerousClient`])
571    /// when you really need it.
572    #[must_use]
573    pub fn permission_mode(mut self, mode: PermissionMode) -> Self {
574        self.shared.permission_mode = Some(mode);
575        self
576    }
577
578    /// Forward nested subagent text and thinking as stream-json messages
579    /// (`--forward-subagent-text`). Parent tool-use IDs remain in the raw events.
580    /// Only meaningful with stream-json output; duplex sessions already use it.
581    #[must_use]
582    pub fn forward_subagent_text(mut self) -> Self {
583        self.shared.forward_subagent_text = true;
584        self
585    }
586
587    /// Make bypass mode available without activating it
588    /// (`--allow-dangerously-skip-permissions`). This does not select a bypass
589    /// permission mode or disable permission checks by itself.
590    #[must_use]
591    pub fn allow_dangerously_skip_permissions(mut self) -> Self {
592        self.shared.allow_dangerously_skip_permissions = true;
593        self
594    }
595
596    /// Pass `--dangerously-skip-permissions` to the spawned session.
597    ///
598    /// Bypasses ALL permission checks -- file edits, bash, network,
599    /// the lot. Use only when you know the session runs in a trusted
600    /// sandbox (a fresh worktree, a container, etc.). For most "run
601    /// autonomously" cases you want [`Self::permission_mode`] with
602    /// [`PermissionMode::AcceptEdits`] instead.
603    #[must_use]
604    pub fn dangerously_skip_permissions(mut self) -> Self {
605        self.shared.dangerously_skip_permissions = true;
606        self
607    }
608
609    /// Start a new session under a caller-chosen id
610    /// (`--session-id <uuid>`).
611    ///
612    /// Mirrors [`QueryCommand::session_id`](crate::QueryCommand::session_id)
613    /// for the duplex path. Unlike [`Self::resume`] (pick up an
614    /// existing session) or [`Self::continue_session`] (pick up the
615    /// most recent one), this mints a fresh session whose id the host
616    /// knows up front -- useful when the host indexes sessions
617    /// externally before the first turn completes.
618    #[must_use]
619    pub fn session_id(mut self, id: impl Into<String>) -> Self {
620        self.shared.session_id = Some(id.into());
621        self
622    }
623
624    /// Set a JSON schema for structured output validation
625    /// (`--json-schema <schema>`).
626    ///
627    /// Mirrors [`QueryCommand::json_schema`](crate::QueryCommand::json_schema)
628    /// for the duplex path. `schema` is the inline JSON of the schema;
629    /// the turn's closing result message carries the validated
630    /// `structured_output`.
631    #[must_use]
632    pub fn json_schema(mut self, schema: impl Into<String>) -> Self {
633        self.shared.json_schema = Some(schema.into());
634        self
635    }
636
637    /// Add allowed tool patterns (`--allowed-tools`).
638    ///
639    /// Mirrors [`QueryCommand::allowed_tools`](crate::QueryCommand::allowed_tools)
640    /// for the duplex path: accepts anything convertible into
641    /// [`ToolPattern`], including bare strings (e.g. `"Bash"`,
642    /// `"Bash(git log:*)"`, `"mcp__my-server__*"`), and joins them
643    /// into the comma-separated form the CLI expects.
644    #[must_use]
645    pub fn allowed_tools<I, T>(mut self, tools: I) -> Self
646    where
647        I: IntoIterator<Item = T>,
648        T: Into<ToolPattern>,
649    {
650        self.shared
651            .allowed_tools
652            .extend(tools.into_iter().map(Into::into));
653        self
654    }
655
656    /// Add a single allowed tool pattern.
657    #[must_use]
658    pub fn allowed_tool(mut self, tool: impl Into<ToolPattern>) -> Self {
659        self.shared.allowed_tools.push(tool.into());
660        self
661    }
662
663    /// Add disallowed tool patterns (`--disallowed-tools`).
664    #[must_use]
665    pub fn disallowed_tools<I, T>(mut self, tools: I) -> Self
666    where
667        I: IntoIterator<Item = T>,
668        T: Into<ToolPattern>,
669    {
670        self.shared
671            .disallowed_tools
672            .extend(tools.into_iter().map(Into::into));
673        self
674    }
675
676    /// Add a single disallowed tool pattern.
677    #[must_use]
678    pub fn disallowed_tool(mut self, tool: impl Into<ToolPattern>) -> Self {
679        self.shared.disallowed_tools.push(tool.into());
680        self
681    }
682
683    /// Cap the number of agentic turns (`--max-turns <n>`).
684    ///
685    /// The cap applies per turn sent through [`DuplexSession::send`];
686    /// a turn that exhausts it closes with an `error_max_turns`
687    /// result rather than an assistant reply.
688    #[must_use]
689    pub fn max_turns(mut self, turns: u32) -> Self {
690        self.shared.max_turns = Some(turns);
691        self
692    }
693
694    /// Cap claude's own spend for the session
695    /// (`--max-budget-usd <usd>`).
696    ///
697    /// This is the CLI's cap, checked post-hoc after each API call,
698    /// so a session can overspend before tripping. It is distinct
699    /// from the wrapper's [`BudgetTracker`](crate::budget::BudgetTracker)
700    /// ceiling, which gates dispatch host-side -- attach one via
701    /// [`Conversation::with_budget`](crate::conversation::Conversation::with_budget)
702    /// to stop a duplex conversation before the next turn is sent.
703    #[must_use]
704    pub fn max_budget_usd(mut self, budget: f64) -> Self {
705        self.shared.max_budget_usd = Some(budget);
706        self
707    }
708
709    /// Set a fallback model for when the primary is overloaded
710    /// (`--fallback-model <model>`).
711    #[must_use]
712    pub fn fallback_model(mut self, model: impl Into<String>) -> Self {
713        self.shared.fallback_model = Some(model.into());
714        self
715    }
716
717    /// Set the reasoning effort level (`--effort <level>`).
718    #[must_use]
719    pub fn effort(mut self, effort: Effort) -> Self {
720        self.shared.effort = Some(effort);
721        self
722    }
723
724    /// Add an additional directory for tool access
725    /// (`--add-dir <dir>`, repeatable).
726    #[must_use]
727    pub fn add_dir(mut self, dir: impl Into<String>) -> Self {
728        self.shared.add_dir.push(dir.into());
729        self
730    }
731
732    /// Add an MCP config file path (`--mcp-config <path>`,
733    /// repeatable).
734    ///
735    /// Pair with [`crate::McpConfigBuilder`] to generate the file.
736    #[must_use]
737    pub fn mcp_config(mut self, path: impl Into<String>) -> Self {
738        self.shared.mcp_config.push(path.into());
739        self
740    }
741
742    /// Only use MCP servers from `--mcp-config` files, ignoring the
743    /// user- and project-level MCP configuration
744    /// (`--strict-mcp-config`).
745    #[must_use]
746    pub fn strict_mcp_config(mut self) -> Self {
747        self.shared.strict_mcp_config = true;
748        self
749    }
750
751    /// Comma-separated list of setting sources the CLI loads, for example
752    /// `"user,project,local"` (`--setting-sources`). Pass an empty string to
753    /// load none, sealing the session's promptspace against ambient project
754    /// config (agents, skills, `CLAUDE.md`). Mirrors
755    /// [`QueryCommand::setting_sources`](crate::QueryCommand::setting_sources).
756    #[must_use]
757    pub fn setting_sources(mut self, sources: impl Into<String>) -> Self {
758        self.shared.setting_sources = Some(sources.into());
759        self
760    }
761
762    /// Seal the ambient `~/.claude` config for a reproducible session
763    /// ([`HermeticScope::Full`]).
764    ///
765    /// Sets `--setting-sources ""`, `--strict-mcp-config`, and
766    /// `--exclude-dynamic-system-prompt-sections`. This gives a warm
767    /// duplex session a clean seal without the [`Self::arg`] escape
768    /// hatch. Mirrors
769    /// [`QueryCommand::hermetic`](crate::QueryCommand::hermetic).
770    ///
771    /// This is not [`Self::bare`]: a hermetic seal leaves OAuth and
772    /// keychain auth working, whereas `--bare` forces API-key billing.
773    /// A later [`Self::setting_sources`] call overrides the seal scope.
774    #[must_use]
775    pub fn hermetic(mut self) -> Self {
776        self.shared.apply_hermetic(HermeticScope::Full);
777        self
778    }
779
780    /// Seal the ambient `~/.claude` config at an explicit
781    /// [`HermeticScope`].
782    ///
783    /// See [`Self::hermetic`] for the flag set. Mirrors
784    /// [`QueryCommand::hermetic_scoped`](crate::QueryCommand::hermetic_scoped).
785    #[must_use]
786    pub fn hermetic_scoped(mut self, scope: HermeticScope) -> Self {
787        self.shared.apply_hermetic(scope);
788        self
789    }
790
791    /// Do not persist the session to on-disk history
792    /// (`--no-session-persistence`).
793    #[must_use]
794    pub fn no_session_persistence(mut self) -> Self {
795        self.shared.no_session_persistence = true;
796        self
797    }
798
799    /// Set the list of available built-in tools (`--tools`).
800    ///
801    /// Use `""` to disable all tools, `"default"` for all tools, or
802    /// specific tool names like `["Bash", "Edit", "Read"]`. This is
803    /// distinct from [`Self::allowed_tools`], which controls tool
804    /// permissions rather than which built-ins load. Mirrors
805    /// [`QueryCommand::tools`](crate::QueryCommand::tools).
806    #[must_use]
807    pub fn tools(mut self, tools: impl IntoIterator<Item = impl Into<String>>) -> Self {
808        self.shared.tools.extend(tools.into_iter().map(Into::into));
809        self
810    }
811
812    /// Add a file resource to download at startup (`--file`).
813    ///
814    /// Format: `file_id:relative_path` (e.g. `file_abc:doc.txt`).
815    /// Repeatable. Mirrors [`QueryCommand::file`](crate::QueryCommand::file).
816    #[must_use]
817    pub fn file(mut self, spec: impl Into<String>) -> Self {
818        self.shared.file.push(spec.into());
819        self
820    }
821
822    /// Path to a settings JSON file or a JSON string (`--settings`).
823    ///
824    /// Mirrors [`QueryCommand::settings`](crate::QueryCommand::settings).
825    #[must_use]
826    pub fn settings(mut self, settings: impl Into<String>) -> Self {
827        self.shared.settings = Some(settings.into());
828        self
829    }
830
831    /// When resuming, create a new session id instead of reusing the
832    /// original (`--fork-session`).
833    ///
834    /// Only meaningful alongside [`Self::resume`] or
835    /// [`Self::continue_session`]. Mirrors
836    /// [`QueryCommand::fork_session`](crate::QueryCommand::fork_session).
837    #[must_use]
838    pub fn fork_session(mut self) -> Self {
839        self.shared.fork_session = true;
840        self
841    }
842
843    /// Enable debug logging with an optional filter, e.g. `"api,hooks"`
844    /// (`--debug`). Mirrors
845    /// [`QueryCommand::debug_filter`](crate::QueryCommand::debug_filter).
846    #[must_use]
847    pub fn debug_filter(mut self, filter: impl Into<String>) -> Self {
848        self.shared.debug_filter = Some(filter.into());
849        self
850    }
851
852    /// Write debug logs to the given file path (`--debug-file`).
853    ///
854    /// Mirrors [`QueryCommand::debug_file`](crate::QueryCommand::debug_file).
855    #[must_use]
856    pub fn debug_file(mut self, path: impl Into<String>) -> Self {
857        self.shared.debug_file = Some(path.into());
858        self
859    }
860
861    /// Beta feature headers for API key authentication (`--betas`).
862    ///
863    /// Mirrors [`QueryCommand::betas`](crate::QueryCommand::betas).
864    #[must_use]
865    pub fn betas(mut self, betas: impl Into<String>) -> Self {
866        self.shared.betas = Some(betas.into());
867        self
868    }
869
870    /// Load plugins from the given directory for this session
871    /// (`--plugin-dir`). Repeatable. Mirrors
872    /// [`QueryCommand::plugin_dir`](crate::QueryCommand::plugin_dir).
873    #[must_use]
874    pub fn plugin_dir(mut self, dir: impl Into<String>) -> Self {
875        self.shared.plugin_dirs.push(dir.into());
876        self
877    }
878
879    /// Fetch a plugin `.zip` from a URL for this session only
880    /// (`--plugin-url`). Repeatable. Mirrors
881    /// [`QueryCommand::plugin_url`](crate::QueryCommand::plugin_url).
882    #[must_use]
883    pub fn plugin_url(mut self, url: impl Into<String>) -> Self {
884        self.shared.plugin_urls.push(url.into());
885        self
886    }
887
888    /// Create a tmux session for the worktree (`--tmux`).
889    ///
890    /// Mirrors [`QueryCommand::tmux`](crate::QueryCommand::tmux).
891    #[must_use]
892    pub fn tmux(mut self) -> Self {
893        self.shared.tmux = true;
894        self
895    }
896
897    /// Run in minimal mode (`--bare`).
898    ///
899    /// Skips hooks, LSP, plugin sync, attribution, auto-memory,
900    /// background prefetches, keychain reads, and `CLAUDE.md`
901    /// auto-discovery. Anthropic auth is restricted to
902    /// `ANTHROPIC_API_KEY` or `apiKeyHelper`; OAuth and keychain are
903    /// never read. Mirrors [`QueryCommand::bare`](crate::QueryCommand::bare).
904    #[must_use]
905    pub fn bare(mut self) -> Self {
906        self.shared.bare = true;
907        self
908    }
909
910    /// Start with all customizations disabled (`--safe-mode`).
911    ///
912    /// Disables `CLAUDE.md`, skills, plugins, hooks, MCP servers,
913    /// custom commands and agents, and output styles for
914    /// troubleshooting. Mirrors
915    /// [`QueryCommand::safe_mode`](crate::QueryCommand::safe_mode).
916    #[must_use]
917    pub fn safe_mode(mut self) -> Self {
918        self.shared.safe_mode = true;
919        self
920    }
921
922    /// Disable all slash-command skills (`--disable-slash-commands`).
923    ///
924    /// Mirrors
925    /// [`QueryCommand::disable_slash_commands`](crate::QueryCommand::disable_slash_commands).
926    #[must_use]
927    pub fn disable_slash_commands(mut self) -> Self {
928        self.shared.disable_slash_commands = true;
929        self
930    }
931
932    /// Include every hook lifecycle event in the stream-json output
933    /// (`--include-hook-events`).
934    ///
935    /// Duplex sessions always run in stream-json, so this takes effect
936    /// without extra configuration. Mirrors
937    /// [`QueryCommand::include_hook_events`](crate::QueryCommand::include_hook_events).
938    #[must_use]
939    pub fn include_hook_events(mut self) -> Self {
940        self.shared.include_hook_events = true;
941        self
942    }
943
944    /// Move per-machine sections (cwd, env info, memory paths, git
945    /// status) out of the system prompt and into the first user
946    /// message (`--exclude-dynamic-system-prompt-sections`).
947    ///
948    /// Improves cross-user prompt-cache reuse. Only applies with the
949    /// default system prompt; ignored with [`Self::system_prompt`].
950    /// Mirrors
951    /// [`QueryCommand::exclude_dynamic_system_prompt_sections`](crate::QueryCommand::exclude_dynamic_system_prompt_sections).
952    #[must_use]
953    pub fn exclude_dynamic_system_prompt_sections(mut self) -> Self {
954        self.shared.exclude_dynamic_system_prompt_sections = true;
955        self
956    }
957
958    /// Set a display name for this session (`--name`). Shown in the
959    /// `/resume` picker and terminal title. Mirrors
960    /// [`QueryCommand::name`](crate::QueryCommand::name).
961    #[must_use]
962    pub fn name(mut self, name: impl Into<String>) -> Self {
963        self.shared.name = Some(name.into());
964        self
965    }
966
967    /// Add a raw argument to the spawn command line.
968    ///
969    /// Escape hatch for flags not covered by the dedicated builder
970    /// methods.
971    #[must_use]
972    pub fn arg(mut self, arg: impl Into<String>) -> Self {
973        self.additional_args.push(arg.into());
974        self
975    }
976
977    /// Set the per-session [`broadcast::Sender`] capacity backing
978    /// [`DuplexSession::subscribe`].
979    ///
980    /// Defaults to [`DEFAULT_SUBSCRIBER_CAPACITY`] (256). Larger
981    /// values give slow subscribers more room before they
982    /// [`Lagged`](tokio::sync::broadcast::error::RecvError::Lagged);
983    /// smaller values reclaim memory if you do not subscribe.
984    #[must_use]
985    pub fn subscriber_capacity(mut self, capacity: usize) -> Self {
986        self.subscriber_capacity = Some(capacity);
987        self
988    }
989
990    /// Register a [`PermissionHandler`] to answer the CLI's tool-use
991    /// permission prompts in-flight.
992    ///
993    /// When set, the spawn command line includes
994    /// `--permission-prompt-tool stdio`, which configures the CLI to
995    /// emit `control_request` messages for tool use over the duplex
996    /// channel rather than blocking on a TUI prompt.
997    ///
998    /// Without a handler, the session does not pass
999    /// `--permission-prompt-tool` and the CLI applies its default
1000    /// permission policy (driven by `--permission-mode`).
1001    ///
1002    /// Runs only when the CLI's permission rules request a decision. Allowed
1003    /// tools or permissive modes may resolve an action without calling it.
1004    /// Configure an explicit ask rule through [`Self::settings`] for actions
1005    /// that should reach the callback. The resident transport keeps stdin open;
1006    /// native denial and deferred-denial are tested at both supported endpoints.
1007    #[must_use]
1008    pub fn on_permission(mut self, handler: PermissionHandler) -> Self {
1009        self.on_permission = Some(handler);
1010        self
1011    }
1012
1013    /// Emit partial message chunks as they arrive
1014    /// (`--include-partial-messages`). Off by default.
1015    ///
1016    /// The CLI then writes `stream_event` messages while a turn is in
1017    /// progress. They are broadcast to [`DuplexSession::subscribe`]
1018    /// receivers as [`InboundEvent::StreamEvent`] and retained in
1019    /// [`TurnResult::events`] like any other event, so a long reply can
1020    /// produce many events. Pair this with [`Self::max_turn_events`] or
1021    /// [`Self::max_turn_bytes`] when per-turn memory must be bounded.
1022    #[must_use]
1023    pub fn include_partial_messages(mut self) -> Self {
1024        self.include_partial_messages = true;
1025        self
1026    }
1027
1028    /// Cap the size of one stdout frame (one NDJSON line), in bytes,
1029    /// including its newline terminator. Default: unlimited.
1030    ///
1031    /// The check runs before an unterminated line is extended, so a child
1032    /// that writes an endless line without a newline cannot grow the
1033    /// retained raw frame past this size. Applies whether or not a turn is in
1034    /// flight. Exceeding it closes the session with
1035    /// [`Error::DuplexLimitExceeded`] (see the
1036    /// [module docs](crate::duplex#resource-limits-and-deadlines)).
1037    ///
1038    /// [`ClaudeBuilder::output_limit`](crate::ClaudeBuilder::output_limit)
1039    /// does not apply to duplex sessions.
1040    #[must_use]
1041    pub fn max_frame_bytes(mut self, bytes: usize) -> Self {
1042        self.policy.max_frame_bytes = Some(bytes);
1043        self
1044    }
1045
1046    /// Cap the stdout bytes consumed during one turn. Default: unlimited.
1047    ///
1048    /// Counts every byte read while a turn is pending, from the moment the
1049    /// session accepts the [`DuplexSession::send`] through the closing
1050    /// `result` line: newline terminators, blank lines, and lines that fail
1051    /// to parse all count. The counter resets for each turn; bytes read
1052    /// while no turn is pending do not count, except for a partial frame
1053    /// carried into the new turn: its buffered prefix counts too. Exceeding
1054    /// the limit closes the session with [`Error::DuplexLimitExceeded`].
1055    #[must_use]
1056    pub fn max_turn_bytes(mut self, bytes: usize) -> Self {
1057        self.policy.max_turn_bytes = Some(bytes);
1058        self
1059    }
1060
1061    /// Cap the number of events retained in one turn's
1062    /// [`TurnResult::events`]. Default: unlimited.
1063    ///
1064    /// The count resets for each turn. Exceeding it closes the session with
1065    /// [`Error::DuplexLimitExceeded`] instead of returning a truncated
1066    /// turn. This is separate from [`Self::subscriber_capacity`]: a slow
1067    /// subscriber sees
1068    /// [`RecvError::Lagged`](tokio::sync::broadcast::error::RecvError::Lagged)
1069    /// and the session continues, while a retained-event overflow ends the
1070    /// session.
1071    #[must_use]
1072    pub fn max_turn_events(mut self, events: usize) -> Self {
1073        self.policy.max_turn_events = Some(events);
1074        self
1075    }
1076
1077    /// Bound each turn with a deadline. Default: none.
1078    ///
1079    /// The clock starts when the session task accepts a
1080    /// [`DuplexSession::send`], before the prompt is written to stdin, and
1081    /// stops when the closing `result` line is read. The deadline covers
1082    /// the stdin write, every [`PermissionHandler`] call made during the
1083    /// turn, and any wait for [`DuplexSession::respond_to_permission`]
1084    /// after a [`PermissionDecision::Defer`]. Expiry closes the session
1085    /// with [`Error::DuplexDeadlineExceeded`].
1086    ///
1087    /// Independent of the client's
1088    /// [`ClaudeBuilder::timeout`](crate::ClaudeBuilder::timeout), which does
1089    /// not apply to duplex sessions. An idle session with no pending turn
1090    /// has no lifetime limit. Cleanup can extend the wait beyond the deadline
1091    /// by the configured kill grace and process settlement time. Zero expires
1092    /// immediately; durations too large for the clock are treated as unlimited.
1093    /// An expired deadline takes precedence over a result still awaiting
1094    /// dispatch, even if that result is already buffered.
1095    #[must_use]
1096    pub fn turn_timeout(mut self, timeout: Duration) -> Self {
1097        self.policy.turn_timeout = Some(timeout);
1098        self
1099    }
1100
1101    /// Bound each outbound control request, such as
1102    /// [`DuplexSession::interrupt`], with a deadline. Default: none.
1103    ///
1104    /// The clock starts when the session task accepts the request, before
1105    /// it is written to stdin, and stops when the matching
1106    /// `control_response` is read. The acknowledgment is independent of
1107    /// any turn result: an interrupted turn's result does not satisfy this
1108    /// deadline, and an acknowledgment does not end the turn. Expiry closes
1109    /// the session with [`Error::DuplexDeadlineExceeded`]. Cleanup can extend
1110    /// the wait beyond the deadline; zero and unrepresentable durations have
1111    /// the same behavior as [`Self::turn_timeout`].
1112    #[must_use]
1113    pub fn control_timeout(mut self, timeout: Duration) -> Self {
1114        self.policy.control_timeout = Some(timeout);
1115        self
1116    }
1117
1118    fn build_args(&self) -> Vec<String> {
1119        let mut args = vec![
1120            "--print".to_string(),
1121            "--verbose".to_string(),
1122            "--output-format".to_string(),
1123            "stream-json".to_string(),
1124            "--input-format".to_string(),
1125            "stream-json".to_string(),
1126        ];
1127
1128        self.shared.append_to(&mut args);
1129
1130        if self.include_partial_messages {
1131            args.push("--include-partial-messages".to_string());
1132        }
1133        if self.on_permission.is_some() {
1134            args.push("--permission-prompt-tool".to_string());
1135            args.push("stdio".to_string());
1136        }
1137        args.extend(self.additional_args.iter().cloned());
1138
1139        args
1140    }
1141
1142    /// Assemble the exact argv [`DuplexSession::spawn`] passes to the
1143    /// CLI binary: the client's global args followed by this option
1144    /// set's flags. Single assembly path shared by the spawn and
1145    /// [`Self::to_command_string`], so the preview cannot drift from
1146    /// what actually runs.
1147    fn spawn_command_args(&self, claude: &Claude) -> Vec<String> {
1148        let mut args = claude.global_args.clone();
1149        args.extend(self.build_args());
1150        args
1151    }
1152
1153    /// Return the full spawn command as a string that could be run in
1154    /// a shell.
1155    ///
1156    /// The duplex analog of
1157    /// [`QueryCommand::to_command_string`](crate::QueryCommand::to_command_string):
1158    /// the binary path from the [`Claude`] client plus the exact
1159    /// arguments [`DuplexSession::spawn`] would pass for these
1160    /// options, including the client's global args. Both share one
1161    /// args-assembly path, so this preview always matches the real
1162    /// spawn. Arguments containing spaces or special shell characters
1163    /// are shell-quoted.
1164    ///
1165    /// # Example
1166    ///
1167    /// ```no_run
1168    /// use claude_wrapper::{Claude, DuplexOptions};
1169    ///
1170    /// # fn example() -> claude_wrapper::Result<()> {
1171    /// let claude = Claude::builder().build()?;
1172    ///
1173    /// let opts = DuplexOptions::default()
1174    ///     .agent("reviewer")
1175    ///     .setting_sources("project");
1176    ///
1177    /// println!("Would spawn: {}", opts.to_command_string(&claude));
1178    /// # Ok(())
1179    /// # }
1180    /// ```
1181    #[must_use]
1182    pub fn to_command_string(&self, claude: &Claude) -> String {
1183        let args = self.spawn_command_args(claude);
1184        let quoted_args = args.iter().map(|arg| shell_quote(arg)).collect::<Vec<_>>();
1185        format!("{} {}", claude.binary().display(), quoted_args.join(" "))
1186    }
1187}
1188
1189/// The result of one turn through a [`DuplexSession`].
1190///
1191/// `result` is the raw JSON of the `{"type": "result", ...}` message
1192/// that closed the turn. `events` carries every other message
1193/// received during the turn (system, assistant, stream_event, user)
1194/// in arrival order, with the closing `result` excluded.
1195#[derive(Debug, Clone)]
1196pub struct TurnResult {
1197    /// The raw `{"type": "result", ...}` message that ended the turn.
1198    pub result: Value,
1199    /// Every other message received during the turn, in order.
1200    pub events: Vec<Value>,
1201}
1202
1203impl TurnResult {
1204    /// Extract `result.result` as a string, if present.
1205    #[must_use]
1206    pub fn result_text(&self) -> Option<&str> {
1207        self.result.get("result").and_then(Value::as_str)
1208    }
1209
1210    /// Extract `result.session_id`, if present.
1211    #[must_use]
1212    pub fn session_id(&self) -> Option<&str> {
1213        self.result.get("session_id").and_then(Value::as_str)
1214    }
1215
1216    /// Extract `total_cost_usd` (preferred) or the legacy `cost_usd`
1217    /// field, if either is present.
1218    ///
1219    /// This is the CLI's running total for the session, not the cost
1220    /// of this turn: each `result` message reports everything spent
1221    /// so far, earlier turns included. When the session was spawned
1222    /// with [`DuplexOptions::resume`] or
1223    /// [`DuplexOptions::continue_session`], the CLI restores the
1224    /// resumed session's total, so the figure also covers spend from
1225    /// before this process started.
1226    ///
1227    /// A turn's own cost is the change from the previous turn's
1228    /// total. [`Conversation`](crate::conversation::Conversation)
1229    /// records that change for each turn and keeps the cumulative
1230    /// figure.
1231    #[must_use]
1232    pub fn total_cost_usd(&self) -> Option<f64> {
1233        self.result
1234            .get("total_cost_usd")
1235            .or_else(|| self.result.get("cost_usd"))
1236            .and_then(Value::as_f64)
1237    }
1238
1239    /// Extract `duration_ms`, if present.
1240    #[must_use]
1241    pub fn duration_ms(&self) -> Option<u64> {
1242        self.result.get("duration_ms").and_then(Value::as_u64)
1243    }
1244
1245    /// Extract `result.subtype`, if present: `"success"` for a turn
1246    /// that completed, or an error subtype such as
1247    /// `"error_during_execution"` for an interrupted turn.
1248    #[must_use]
1249    pub fn subtype(&self) -> Option<&str> {
1250        self.result.get("subtype").and_then(Value::as_str)
1251    }
1252
1253    /// Extract `result.is_error`. Returns `false` when the field is
1254    /// absent.
1255    #[must_use]
1256    pub fn is_error(&self) -> bool {
1257        self.result
1258            .get("is_error")
1259            .and_then(Value::as_bool)
1260            .unwrap_or(false)
1261    }
1262}
1263
1264/// Turn a reported running total into the cost of the turn that
1265/// reported it.
1266///
1267/// The CLI reports `total_cost_usd` as the running total for the
1268/// session, so a turn costs the difference from the previous total,
1269/// clamped at zero. `prev_total` is updated to `total`. A turn that
1270/// reports no total returns `None` and leaves `prev_total` alone.
1271pub(crate) fn turn_cost_delta(prev_total: &mut Option<f64>, total: Option<f64>) -> Option<f64> {
1272    let total = total?;
1273    let prev = prev_total.replace(total).unwrap_or(0.0);
1274    Some((total - prev).max(0.0))
1275}
1276
1277/// A classified inbound event broadcast to [`DuplexSession::subscribe`]
1278/// receivers.
1279///
1280/// Every non-`result` message coming back from the CLI is broadcast as
1281/// one of these variants. The closing `{"type": "result"}` message is
1282/// not broadcast; it resolves the in-flight [`DuplexSession::send`]
1283/// future and lands in [`TurnResult::result`].
1284///
1285/// Subscribers see the same set of events that accumulate in
1286/// [`TurnResult::events`], in the same order, just classified. Adding
1287/// a typed accessor for a new event type later (e.g. promoting a
1288/// `system` subtype into its own variant) is non-breaking against the
1289/// `Other` fallback.
1290#[derive(Debug, Clone)]
1291pub enum InboundEvent {
1292    /// First `{"type": "system", "subtype": "init"}` event for the
1293    /// session. Carries the CLI-assigned `session_id`.
1294    SystemInit {
1295        /// The CLI-assigned session id, useful for logging or
1296        /// future resume support.
1297        session_id: String,
1298    },
1299    /// `{"type": "assistant", ...}` -- either a complete assistant
1300    /// message or, in stream-json mode, a partial chunk.
1301    Assistant(Value),
1302    /// `{"type": "stream_event", ...}` -- low-level streaming event
1303    /// emitted while a turn is in progress.
1304    StreamEvent(Value),
1305    /// `{"type": "user", ...}` -- typically a tool result echo from
1306    /// the CLI side.
1307    User(Value),
1308    /// Any other event type, including non-`init` `system` events
1309    /// and any message types not yet recognised by this enum.
1310    Other(Value),
1311}
1312
1313fn classify(msg: &Value) -> InboundEvent {
1314    match msg.get("type").and_then(Value::as_str) {
1315        Some("system") => {
1316            if msg.get("subtype").and_then(Value::as_str) == Some("init")
1317                && let Some(id) = msg.get("session_id").and_then(Value::as_str)
1318            {
1319                return InboundEvent::SystemInit {
1320                    session_id: id.to_string(),
1321                };
1322            }
1323            InboundEvent::Other(msg.clone())
1324        }
1325        Some("assistant") => InboundEvent::Assistant(msg.clone()),
1326        Some("stream_event") => InboundEvent::StreamEvent(msg.clone()),
1327        Some("user") => InboundEvent::User(msg.clone()),
1328        _ => InboundEvent::Other(msg.clone()),
1329    }
1330}
1331
1332/// Liveness state of a [`DuplexSession`]'s background task.
1333///
1334/// Surfaced through [`DuplexSession::is_alive`],
1335/// [`DuplexSession::exit_status`], and
1336/// [`DuplexSession::wait_for_exit`] for service-shaped hosts that
1337/// want non-consuming visibility into whether a session is still
1338/// usable. The closing [`DuplexSession::close`] still returns the
1339/// full [`Result`] for the one caller that consumes the session.
1340///
1341/// `Failed` carries a `String` rather than the full
1342/// [`Error`] because the underlying watch channel requires `Clone`
1343/// and `Error` is not `Clone` (its `Io` variant wraps a non-`Clone`
1344/// `std::io::Error`). The full error remains available via
1345/// [`DuplexSession::close`].
1346#[derive(Debug, Clone)]
1347pub enum SessionExitStatus {
1348    /// The session task is still running.
1349    Running,
1350    /// The child was reaped with a zero exit status and no I/O error.
1351    Completed,
1352    /// The session task ended with an error. Carries the error's
1353    /// `Display` rendering.
1354    Failed(String),
1355}
1356
1357/// A long-lived `claude` subprocess in stream-json duplex mode.
1358///
1359/// Owns a background task that holds the child open, writes user
1360/// messages to its stdin, and reads NDJSON events from its stdout.
1361/// One turn at a time: calling [`Self::send`] while another turn is
1362/// in flight returns [`Error::DuplexTurnInFlight`].
1363///
1364/// See the [module docs](crate::duplex) for the full design.
1365#[derive(Debug)]
1366pub struct DuplexSession {
1367    outbound_tx: mpsc::UnboundedSender<OutboundMsg>,
1368    events_tx: broadcast::Sender<InboundEvent>,
1369    exit_rx: watch::Receiver<SessionExitStatus>,
1370    join: JoinHandle<Result<()>>,
1371}
1372
1373#[derive(Debug)]
1374enum OutboundMsg {
1375    Send {
1376        prompt: String,
1377        reply: oneshot::Sender<Result<TurnResult>>,
1378    },
1379    PermissionResponse {
1380        request_id: String,
1381        decision: PermissionDecision,
1382    },
1383    Interrupt {
1384        reply: oneshot::Sender<Result<()>>,
1385    },
1386}
1387
1388impl DuplexSession {
1389    /// Spawn a fresh `claude` subprocess in duplex mode.
1390    ///
1391    /// The child is started with
1392    /// `--print --verbose --input-format stream-json --output-format stream-json`
1393    /// plus any options applied via `opts`. The session task takes
1394    /// ownership of the child; dropping the returned handle (or
1395    /// calling [`Self::close`]) shuts the task down.
1396    pub async fn spawn(claude: &Claude, opts: DuplexOptions) -> Result<Self> {
1397        let capacity = opts
1398            .subscriber_capacity
1399            .unwrap_or(DEFAULT_SUBSCRIBER_CAPACITY);
1400        let permission_handler = opts.on_permission.clone();
1401
1402        let command_args = opts.spawn_command_args(claude);
1403
1404        debug!(
1405            arg_count = command_args.len(),
1406            "spawning duplex claude session"
1407        );
1408
1409        let mut cmd = Command::new(&claude.binary);
1410        cmd.args(&command_args)
1411            .stdin(Stdio::piped())
1412            .stdout(Stdio::piped())
1413            .stderr(Stdio::piped())
1414            .kill_on_drop(true);
1415        crate::exec::apply_child_environment(cmd.as_std_mut(), claude.clear_env, &claude.env);
1416        // Own process group (Unix) so shutdown can signal the whole
1417        // group, not just the direct child (see exec::GroupKillGuard).
1418        // Opt out via ClaudeBuilder::process_group.
1419        crate::exec::apply_process_group(&mut cmd, claude.process_group);
1420
1421        if let Some(ref dir) = claude.working_dir {
1422            cmd.current_dir(dir);
1423        }
1424
1425        let mut child = crate::exec::spawn_retrying_txtbsy(&mut cmd)
1426            .await
1427            .map_err(|e| Error::Io {
1428                message: format!("failed to spawn claude: {e}"),
1429                source: e,
1430                working_dir: claude.working_dir.clone(),
1431            })?;
1432        let group =
1433            crate::exec::arm_and_notify(claude.process_group, child.id(), claude.on_spawn.as_ref());
1434
1435        let stdin = child.stdin.take().expect("stdin was piped");
1436        let stdout = child.stdout.take().expect("stdout was piped");
1437
1438        let (outbound_tx, outbound_rx) = mpsc::unbounded_channel();
1439        let (events_tx, _initial_rx) = broadcast::channel(capacity);
1440        let (exit_tx, exit_rx) = watch::channel(SessionExitStatus::Running);
1441
1442        // A session-lifetime span, entered inside the spawned task. The
1443        // run loop interleaves events from every turn on one stdout
1444        // stream, so without this its lines ("dropping orphan result
1445        // event", parse failures, shutdown-budget kills) cannot be
1446        // attributed to a session at all when several are open.
1447        //
1448        // Hosts correlate spans using subscriber-owned context; native IDs
1449        // and configured model strings stay in explicit events/results.
1450        let session_span = tracing::debug_span!(
1451            "claude.session",
1452            model_configured = opts.shared.model.is_some(),
1453            permission_mode = opts
1454                .shared
1455                .permission_mode
1456                .as_ref()
1457                .map(|m| m.as_arg())
1458                .unwrap_or("default"),
1459            resumed = opts.shared.resume.is_some(),
1460            turns = tracing::field::Empty,
1461            exit = tracing::field::Empty,
1462        );
1463        let join = tokio::spawn(
1464            run_session(
1465                child,
1466                group,
1467                claude.kill_grace,
1468                claude.termination_signal,
1469                stdin,
1470                stdout,
1471                outbound_rx,
1472                events_tx.clone(),
1473                permission_handler,
1474                opts.policy,
1475                exit_tx,
1476            )
1477            .instrument(session_span),
1478        );
1479
1480        Ok(Self {
1481            outbound_tx,
1482            events_tx,
1483            exit_rx,
1484            join,
1485        })
1486    }
1487
1488    /// Send one user message and await the closing result event.
1489    ///
1490    /// Returns [`Error::DuplexTurnInFlight`] if another turn is
1491    /// already pending, and [`Error::DuplexClosed`] if the session
1492    /// task has already exited.
1493    /// `DuplexClosed` may arrive before final diagnostic collection and
1494    /// exit-state publication. Await [`Self::wait_for_exit`] for settled
1495    /// process evidence instead of relying on an immediate snapshot.
1496    ///
1497    /// The returned [`TurnResult`] holds this turn's events only, but
1498    /// its [`TurnResult::total_cost_usd`] is the session's running
1499    /// total. For per-turn and cumulative cost, history, and a budget
1500    /// ceiling, send through a
1501    /// [`Conversation`](crate::conversation::Conversation) instead.
1502    ///
1503    /// With opt-in policies configured, returns
1504    /// [`Error::DuplexLimitExceeded`] or [`Error::DuplexDeadlineExceeded`]
1505    /// after the session has closed and its child was stopped. Dropping
1506    /// this future abandons the wait only; the turn stays in flight until
1507    /// its result arrives or its deadline closes the session. See
1508    /// [Resource limits and deadlines](crate::duplex#resource-limits-and-deadlines).
1509    pub async fn send(&self, prompt: impl Into<String>) -> Result<TurnResult> {
1510        let (reply_tx, reply_rx) = oneshot::channel();
1511        self.outbound_tx
1512            .send(OutboundMsg::Send {
1513                prompt: prompt.into(),
1514                reply: reply_tx,
1515            })
1516            .map_err(|_| Error::DuplexClosed)?;
1517        reply_rx.await.map_err(|_| Error::DuplexClosed)?
1518    }
1519
1520    /// Subscribe to the session's classified inbound event stream.
1521    ///
1522    /// Returns a [`broadcast::Receiver<InboundEvent>`] that receives
1523    /// every non-`result` event as it arrives. Each subscriber gets
1524    /// its own buffered view; subscribers added later miss earlier
1525    /// events. Slow subscribers see
1526    /// [`RecvError::Lagged`](tokio::sync::broadcast::error::RecvError::Lagged)
1527    /// rather than blocking the session task.
1528    ///
1529    /// Subscribers see the same events that accumulate in
1530    /// [`TurnResult::events`], in the same order.
1531    ///
1532    /// # Example
1533    ///
1534    /// ```no_run
1535    /// use claude_wrapper::Claude;
1536    /// use claude_wrapper::duplex::{DuplexOptions, DuplexSession, InboundEvent};
1537    ///
1538    /// # async fn example() -> claude_wrapper::Result<()> {
1539    /// let claude = Claude::builder().build()?;
1540    /// let session = DuplexSession::spawn(&claude, DuplexOptions::default()).await?;
1541    /// let mut rx = session.subscribe();
1542    ///
1543    /// // Subscribe before send so we receive every event.
1544    /// let _turn = session.send("hello").await?;
1545    ///
1546    /// while let Ok(event) = rx.try_recv() {
1547    ///     if let InboundEvent::SystemInit { session_id } = event {
1548    ///         println!("session id: {session_id}");
1549    ///     }
1550    /// }
1551    /// # Ok(())
1552    /// # }
1553    /// ```
1554    #[must_use]
1555    pub fn subscribe(&self) -> broadcast::Receiver<InboundEvent> {
1556        self.events_tx.subscribe()
1557    }
1558
1559    /// Cheap, non-blocking liveness check.
1560    ///
1561    /// Returns `true` while the session task is running, `false` once
1562    /// it has exited (whether normally or with an error). Multiple
1563    /// concurrent callers are allowed, and the call does not consume
1564    /// the session: [`Self::close`] still works after polling.
1565    ///
1566    /// Reads the latest value from a `tokio::sync::watch` channel
1567    /// updated from inside the session task, so it never blocks and
1568    /// reflects state set just before the task returns.
1569    #[must_use]
1570    pub fn is_alive(&self) -> bool {
1571        matches!(*self.exit_rx.borrow(), SessionExitStatus::Running)
1572    }
1573
1574    /// Snapshot the session task's [`SessionExitStatus`].
1575    ///
1576    /// Returns [`SessionExitStatus::Running`] while the task is still
1577    /// alive, [`SessionExitStatus::Completed`] after a clean exit, or
1578    /// [`SessionExitStatus::Failed`] with the underlying error
1579    /// rendered to a string.
1580    ///
1581    /// Like [`Self::is_alive`], this is a cheap non-blocking read.
1582    #[must_use]
1583    pub fn exit_status(&self) -> SessionExitStatus {
1584        self.exit_rx.borrow().clone()
1585    }
1586
1587    /// Block until the session task transitions out of
1588    /// [`SessionExitStatus::Running`] and return the terminal status.
1589    ///
1590    /// Returns immediately if the task has already exited. Multiple
1591    /// concurrent callers are supported (each gets its own receiver
1592    /// clone), and the call does not consume the session.
1593    ///
1594    /// If the underlying watch sender is dropped without ever
1595    /// publishing a terminal state -- which should not happen in
1596    /// practice, but is treated defensively -- this returns the last
1597    /// observed value.
1598    pub async fn wait_for_exit(&self) -> SessionExitStatus {
1599        let mut rx = self.exit_rx.clone();
1600        loop {
1601            {
1602                let value = rx.borrow_and_update();
1603                if !matches!(*value, SessionExitStatus::Running) {
1604                    return value.clone();
1605                }
1606            }
1607            if rx.changed().await.is_err() {
1608                return rx.borrow().clone();
1609            }
1610        }
1611    }
1612
1613    /// Answer a deferred permission request from a different task.
1614    ///
1615    /// Use this after the [`PermissionHandler`] returned
1616    /// [`PermissionDecision::Defer`] for the matching `request_id`.
1617    /// Passing `decision = PermissionDecision::Defer` here is a
1618    /// no-op (logged at `warn`); pass `Allow` or `Deny`.
1619    ///
1620    /// Returns [`Error::DuplexClosed`] if the session task has
1621    /// already exited.
1622    ///
1623    /// # Example
1624    ///
1625    /// ```no_run
1626    /// use claude_wrapper::Claude;
1627    /// use claude_wrapper::duplex::{
1628    ///     DuplexOptions, DuplexSession, PermissionDecision, PermissionHandler,
1629    /// };
1630    /// use tokio::sync::mpsc;
1631    ///
1632    /// # async fn example() -> claude_wrapper::Result<()> {
1633    /// // Forward request_ids out to a UI thread; answer asynchronously.
1634    /// let (tx, _rx) = mpsc::unbounded_channel::<String>();
1635    /// let handler = PermissionHandler::new(move |req| {
1636    ///     let tx = tx.clone();
1637    ///     async move {
1638    ///         let _ = tx.send(req.request_id);
1639    ///         PermissionDecision::Defer
1640    ///     }
1641    /// });
1642    ///
1643    /// let claude = Claude::builder().build()?;
1644    /// let session = DuplexSession::spawn(
1645    ///     &claude,
1646    ///     DuplexOptions::default().on_permission(handler),
1647    /// ).await?;
1648    ///
1649    /// // ...later, from the UI thread:
1650    /// session.respond_to_permission(
1651    ///     "req-abc",
1652    ///     PermissionDecision::Allow { updated_input: None },
1653    /// )?;
1654    /// # Ok(())
1655    /// # }
1656    /// ```
1657    pub fn respond_to_permission(
1658        &self,
1659        request_id: impl Into<String>,
1660        decision: PermissionDecision,
1661    ) -> Result<()> {
1662        if matches!(decision, PermissionDecision::Defer) {
1663            warn!("respond_to_permission called with Defer; ignoring");
1664            return Ok(());
1665        }
1666        self.outbound_tx
1667            .send(OutboundMsg::PermissionResponse {
1668                request_id: request_id.into(),
1669                decision,
1670            })
1671            .map_err(|_| Error::DuplexClosed)?;
1672        Ok(())
1673    }
1674
1675    /// Send a clean interrupt to the CLI and wait for its
1676    /// acknowledgment.
1677    ///
1678    /// Writes a `control_request {subtype: "interrupt"}` and resolves
1679    /// when the matching `control_response` comes back. The
1680    /// in-flight turn (if any) closes shortly after with a truncated
1681    /// [`TurnResult`] -- the [`DuplexSession::send`] future for it
1682    /// resolves independently. Either ordering is possible; await
1683    /// both via `tokio::join!` if you care about both outcomes.
1684    ///
1685    /// Returns:
1686    /// - `Ok(())` when the CLI acknowledges with `subtype: "success"`.
1687    /// - [`Error::DuplexControlFailed`] when the CLI answers with an
1688    ///   error payload.
1689    /// - [`Error::DuplexClosed`] if the session task exited before
1690    ///   the response arrived.
1691    /// - [`Error::DuplexDeadlineExceeded`] if
1692    ///   [`DuplexOptions::control_timeout`] (or another pending deadline)
1693    ///   expired first, or [`Error::DuplexLimitExceeded`] if a resource
1694    ///   limit closed the session.
1695    ///
1696    /// The acknowledgment and the turn result are independent. Dropping
1697    /// this future abandons the wait only; the request stays pending until
1698    /// its `control_response` arrives or a deadline closes the session.
1699    ///
1700    /// # Example
1701    ///
1702    /// ```no_run
1703    /// use std::time::Duration;
1704    /// use claude_wrapper::Claude;
1705    /// use claude_wrapper::duplex::{DuplexOptions, DuplexSession};
1706    ///
1707    /// # async fn example() -> claude_wrapper::Result<()> {
1708    /// let claude = Claude::builder().build()?;
1709    /// let session = DuplexSession::spawn(&claude, DuplexOptions::default()).await?;
1710    ///
1711    /// let send_fut = session.send("a question that triggers tool use");
1712    /// let interrupt_fut = async {
1713    ///     tokio::time::sleep(Duration::from_millis(250)).await;
1714    ///     session.interrupt().await
1715    /// };
1716    ///
1717    /// let (turn, interrupt) = tokio::join!(send_fut, interrupt_fut);
1718    /// let _truncated = turn?;
1719    /// interrupt?;
1720    /// # Ok(())
1721    /// # }
1722    /// ```
1723    pub async fn interrupt(&self) -> Result<()> {
1724        let (reply_tx, reply_rx) = oneshot::channel();
1725        self.outbound_tx
1726            .send(OutboundMsg::Interrupt { reply: reply_tx })
1727            .map_err(|_| Error::DuplexClosed)?;
1728        reply_rx.await.map_err(|_| Error::DuplexClosed)?
1729    }
1730
1731    /// Close the session and wait for the underlying task to exit.
1732    ///
1733    /// Drops the outbound channel sender, which the session task
1734    /// observes as `recv() -> None`, then closes stdin and reaps the
1735    /// child.
1736    ///
1737    /// Returns an error for a nonzero child exit, even if an earlier
1738    /// turn already delivered a successful result. Failed native exits
1739    /// return [`Error::DuplexProcessFailed`] with the native status, a
1740    /// bounded tail of stderr, and whether the shutdown budget expired.
1741    /// Semantic turn failures remain in [`TurnResult::result`].
1742    pub async fn close(self) -> Result<()> {
1743        drop(self.outbound_tx);
1744        drop(self.events_tx);
1745        match self.join.await {
1746            Ok(result) => result,
1747            Err(e) if e.is_cancelled() => Ok(()),
1748            Err(e) => Err(Error::Io {
1749                message: format!("duplex session task panicked: {e}"),
1750                source: std::io::Error::other(e.to_string()),
1751                working_dir: None,
1752            }),
1753        }
1754    }
1755}
1756
1757/// Time budget for the graceful child shutdown after the run loop
1758/// exits. If the child is still alive after this deadline, apply the
1759/// configured stop policy so close() does not hang on the subprocess.
1760const SHUTDOWN_BUDGET: Duration = Duration::from_secs(5);
1761
1762// Keep only the most recent diagnostic bytes; a resident process may run
1763// indefinitely and stderr is not a protocol line stream.
1764const STDERR_TAIL_BYTES: usize = 64 * 1024;
1765const STDERR_SETTLE_BUDGET: Duration = Duration::from_millis(200);
1766
1767async fn read_stderr_tail<R: tokio::io::AsyncRead + Unpin>(
1768    reader: &mut R,
1769    tail: &mut VecDeque<u8>,
1770) -> std::io::Result<()> {
1771    let mut chunk = [0u8; 8192];
1772    loop {
1773        let read = match reader.read(&mut chunk).await {
1774            Ok(0) => return Ok(()),
1775            Ok(read) => read,
1776            Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
1777            Err(error) => return Err(error),
1778        };
1779        let excess = (tail.len() + read).saturating_sub(STDERR_TAIL_BYTES);
1780        tail.drain(..excess);
1781        tail.extend(&chunk[..read]);
1782    }
1783}
1784
1785#[allow(clippy::too_many_arguments)]
1786async fn run_session(
1787    mut child: Child,
1788    group: crate::exec::GroupKillGuard,
1789    kill_grace: Option<Duration>,
1790    termination_signal: crate::TerminationSignal,
1791    stdin: ChildStdin,
1792    stdout: ChildStdout,
1793    outbound_rx: mpsc::UnboundedReceiver<OutboundMsg>,
1794    events_tx: broadcast::Sender<InboundEvent>,
1795    permission_handler: Option<PermissionHandler>,
1796    policy: DuplexPolicy,
1797    exit_tx: watch::Sender<SessionExitStatus>,
1798) -> Result<()> {
1799    let mut stderr = child.stderr.take().expect("stderr was piped");
1800    let mut tail = VecDeque::new();
1801    let (stderr_failed_tx, stderr_failed_rx) = oneshot::channel();
1802    // These futures share this scope. No detached reader task or unbounded
1803    // channel can survive session settlement or a dropped session task.
1804    let (status, stderr_result) = {
1805        let drain_stderr = async {
1806            let result = read_stderr_tail(&mut stderr, &mut tail).await;
1807            if result.is_err() {
1808                let _ = stderr_failed_tx.send(());
1809            }
1810            result
1811        };
1812        let session = run_session_child(
1813            child,
1814            group,
1815            kill_grace,
1816            termination_signal,
1817            stdin,
1818            stdout,
1819            outbound_rx,
1820            events_tx,
1821            permission_handler,
1822            policy,
1823            stderr_failed_rx,
1824        );
1825        tokio::pin!(drain_stderr);
1826        tokio::pin!(session);
1827        tokio::select! {
1828            status = &mut session => {
1829                // A descendant can keep stderr open after the leader exits.
1830                // Retain available diagnostics without waiting forever for EOF.
1831                let read = tokio::time::timeout(
1832                    STDERR_SETTLE_BUDGET, &mut drain_stderr,
1833                ).await.ok();
1834                (status, read)
1835            }
1836            read = &mut drain_stderr => (session.await, Some(read)),
1837        }
1838    };
1839    let stderr = String::from_utf8_lossy(&tail.into_iter().collect::<Vec<_>>()).into_owned();
1840    let result = resident_outcome(status, stderr_result, stderr);
1841    let final_state = match &result {
1842        Ok(()) => SessionExitStatus::Completed,
1843        Err(error) => SessionExitStatus::Failed(error.to_string()),
1844    };
1845    tracing::Span::current().record(
1846        "exit",
1847        if result.is_ok() {
1848            "completed"
1849        } else {
1850            "failed"
1851        },
1852    );
1853    let _ = exit_tx.send(final_state);
1854    result
1855}
1856
1857fn resident_outcome(
1858    status: Result<(ExitStatus, bool)>,
1859    stderr_result: Option<std::io::Result<()>>,
1860    stderr: String,
1861) -> Result<()> {
1862    match (status, stderr_result) {
1863        (Err(error), _) => Err(error),
1864        (Ok((status, shutdown_budget_exceeded)), read) if !status.success() => {
1865            Err(Error::DuplexProcessFailed {
1866                status,
1867                shutdown_budget_exceeded,
1868                stderr,
1869                stderr_read_error: read.and_then(std::result::Result::err),
1870            })
1871        }
1872        (Ok(_), Some(Err(source))) => Err(Error::Io {
1873            message: format!("failed to read duplex stderr; retained diagnostics: {stderr}"),
1874            source,
1875            working_dir: None,
1876        }),
1877        (Ok(_), _) => Ok(()),
1878    }
1879}
1880
1881/// An accepted operation's deadline, published to the supervisor.
1882#[derive(Debug, Clone, Copy, PartialEq)]
1883struct OpDeadline {
1884    at: Instant,
1885    operation: DuplexOperation,
1886    timeout: Duration,
1887}
1888
1889impl OpDeadline {
1890    /// Start a deadline now. A timeout too large to represent is treated
1891    /// as no deadline.
1892    fn start(operation: DuplexOperation, timeout: Option<Duration>) -> Option<Self> {
1893        let timeout = timeout?;
1894        Some(Self {
1895            at: Instant::now().checked_add(timeout)?,
1896            operation,
1897            timeout,
1898        })
1899    }
1900
1901    fn failure(self) -> PolicyFailure {
1902        PolicyFailure::Deadline {
1903            operation: self.operation,
1904            timeout: self.timeout,
1905        }
1906    }
1907}
1908
1909/// Why an opt-in policy closed the session. `Copy`, unlike [`Error`], so
1910/// one failure can be rendered for every waiter and for `close`.
1911#[derive(Debug, Clone, Copy)]
1912enum PolicyFailure {
1913    Limit {
1914        limit: DuplexLimit,
1915        max: usize,
1916    },
1917    Deadline {
1918        operation: DuplexOperation,
1919        timeout: Duration,
1920    },
1921}
1922
1923impl PolicyFailure {
1924    fn error(self, process_settled: bool) -> Error {
1925        match self {
1926            Self::Limit { limit, max } => Error::DuplexLimitExceeded {
1927                limit,
1928                max,
1929                process_settled,
1930            },
1931            Self::Deadline { operation, timeout } => Error::DuplexDeadlineExceeded {
1932                operation,
1933                timeout,
1934                process_settled,
1935            },
1936        }
1937    }
1938}
1939
1940/// Stdout bytes consumed by the pending turn. `used` is `None` while no
1941/// turn is pending, so bytes between turns are not counted.
1942#[derive(Debug, Default)]
1943struct TurnBytes {
1944    limit: Option<usize>,
1945    used: Option<usize>,
1946}
1947
1948impl TurnBytes {
1949    fn start(&mut self) {
1950        self.used = Some(0);
1951    }
1952
1953    fn stop(&mut self) {
1954        self.used = None;
1955    }
1956
1957    fn charge(&mut self, bytes: usize) -> std::result::Result<(), PolicyFailure> {
1958        if let Some(used) = self.used.as_mut() {
1959            let total = used.saturating_add(bytes);
1960            if let Some(max) = self.limit
1961                && total > max
1962            {
1963                return Err(PolicyFailure::Limit {
1964                    limit: DuplexLimit::TurnBytes,
1965                    max,
1966                });
1967            }
1968            *used = total;
1969        }
1970        Ok(())
1971    }
1972}
1973
1974enum FrameRead {
1975    /// One line, without its `\n` or `\r\n` terminator.
1976    Frame(Vec<u8>),
1977    Eof,
1978}
1979
1980enum FrameError {
1981    Io(std::io::Error),
1982    Limit(PolicyFailure),
1983}
1984
1985/// Newline framing over a persistent buffer.
1986///
1987/// Cancel safe: the only await is `fill_buf`, which consumes nothing, and
1988/// bytes are copied into `partial` and consumed from the reader with no
1989/// await in between, so dropping a pending read loses no bytes. (Rebuilding
1990/// a `read_line` future around a local buffer would drop a partial line.)
1991/// Both limits are checked before `partial` is extended.
1992struct FrameReader<R> {
1993    reader: BufReader<R>,
1994    partial: Vec<u8>,
1995    max_frame_bytes: Option<usize>,
1996}
1997
1998impl<R: tokio::io::AsyncRead + Unpin> FrameReader<R> {
1999    fn new(reader: R, max_frame_bytes: Option<usize>) -> Self {
2000        Self {
2001            reader: BufReader::new(reader),
2002            partial: Vec::new(),
2003            max_frame_bytes,
2004        }
2005    }
2006
2007    async fn next_frame(
2008        &mut self,
2009        turn: &mut TurnBytes,
2010    ) -> std::result::Result<FrameRead, FrameError> {
2011        // A frame begun while idle can become part of this turn. Charge
2012        // its buffered prefix once, before consuming any further bytes.
2013        if turn.used == Some(0) {
2014            turn.charge(self.partial.len()).map_err(FrameError::Limit)?;
2015        }
2016        loop {
2017            let available = match self.reader.fill_buf().await {
2018                Ok(available) => available,
2019                Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
2020                Err(error) => return Err(FrameError::Io(error)),
2021            };
2022            if available.is_empty() {
2023                if self.partial.is_empty() {
2024                    return Ok(FrameRead::Eof);
2025                }
2026                // A final unterminated line is still delivered, as `lines()` did.
2027                return Self::validate_frame(std::mem::take(&mut self.partial));
2028            }
2029            let (take, complete) = match available.iter().position(|&byte| byte == b'\n') {
2030                Some(end) => (end + 1, true),
2031                None => (available.len(), false),
2032            };
2033            if let Some(max) = self.max_frame_bytes
2034                && self.partial.len().saturating_add(take) > max
2035            {
2036                return Err(FrameError::Limit(PolicyFailure::Limit {
2037                    limit: DuplexLimit::FrameBytes,
2038                    max,
2039                }));
2040            }
2041            turn.charge(take).map_err(FrameError::Limit)?;
2042            self.partial.extend_from_slice(&available[..take]);
2043            self.reader.consume(take);
2044            if complete {
2045                let mut frame = std::mem::take(&mut self.partial);
2046                frame.pop();
2047                if frame.last() == Some(&b'\r') {
2048                    frame.pop();
2049                }
2050                return Self::validate_frame(frame);
2051            }
2052        }
2053    }
2054
2055    fn validate_frame(frame: Vec<u8>) -> std::result::Result<FrameRead, FrameError> {
2056        // Preserve the former Lines reader's fatal invalid-UTF-8 behavior.
2057        std::str::from_utf8(&frame).map_err(|error| {
2058            FrameError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, error))
2059        })?;
2060        Ok(FrameRead::Frame(frame))
2061    }
2062
2063    fn into_inner(self) -> BufReader<R> {
2064        self.reader
2065    }
2066}
2067
2068type PendingTurn = Option<(oneshot::Sender<Result<TurnResult>>, Vec<Value>)>;
2069type PendingControl = HashMap<String, (oneshot::Sender<Result<()>>, Option<OpDeadline>)>;
2070
2071/// Why the I/O loop stopped.
2072enum IoEnd {
2073    Eof,
2074    Closed,
2075    StderrFailed,
2076    Read(Error),
2077    Policy(PolicyFailure),
2078}
2079
2080/// Session state that outlives the I/O future. The supervisor can drop
2081/// that future at any await (a stdin write, a permission callback) and
2082/// still settle every accepted waiter from here.
2083struct Resident {
2084    stdin: ChildStdin,
2085    frames: FrameReader<ChildStdout>,
2086    turn_bytes: TurnBytes,
2087    pending: PendingTurn,
2088    turn_deadline: Option<OpDeadline>,
2089    pending_control: PendingControl,
2090    next_control_id: u64,
2091    deadline_tx: watch::Sender<Option<OpDeadline>>,
2092    policy: DuplexPolicy,
2093    session_span: tracing::Span,
2094    turns: u64,
2095    turn_span: Option<tracing::Span>,
2096    turn_started: Option<std::time::Instant>,
2097    // The CLI reports a running total on each result; the turn span
2098    // records the change from the previous one.
2099    last_total_cost: Option<f64>,
2100}
2101
2102impl Resident {
2103    fn earliest_deadline(&self) -> Option<OpDeadline> {
2104        self.turn_deadline
2105            .into_iter()
2106            .chain(self.pending_control.values().filter_map(|(_, d)| *d))
2107            .min_by_key(|deadline| deadline.at)
2108    }
2109
2110    fn expired_deadline(&self) -> Option<PolicyFailure> {
2111        self.earliest_deadline()
2112            .filter(|deadline| deadline.at <= Instant::now())
2113            .map(OpDeadline::failure)
2114    }
2115
2116    /// Publish the earliest pending deadline. Called synchronously after
2117    /// every accept and every settle, before the next await.
2118    fn publish_deadline(&self) {
2119        let next = self.earliest_deadline();
2120        self.deadline_tx.send_if_modified(|current| {
2121            let changed = *current != next;
2122            *current = next;
2123            changed
2124        });
2125    }
2126
2127    fn finish_turn(&mut self) {
2128        self.turn_deadline = None;
2129        self.turn_bytes.stop();
2130        self.publish_deadline();
2131    }
2132
2133    fn record_turn_outcome(&mut self, result: &Value) {
2134        let total = result
2135            .get("total_cost_usd")
2136            .or_else(|| result.get("cost_usd"))
2137            .and_then(Value::as_f64);
2138        let cost = turn_cost_delta(&mut self.last_total_cost, total);
2139        let Some(span) = self.turn_span.take() else {
2140            return;
2141        };
2142        span.record(
2143            "is_error",
2144            result
2145                .get("is_error")
2146                .and_then(Value::as_bool)
2147                .unwrap_or(false),
2148        );
2149        if let Some(sub) = result.get("subtype").and_then(Value::as_str) {
2150            let label = match sub {
2151                "success" => "success",
2152                "error_during_execution" => "error_during_execution",
2153                "error_max_turns" => "error_max_turns",
2154                "error_max_budget_usd" => "error_max_budget_usd",
2155                _ => "other",
2156            };
2157            span.record("subtype", label);
2158        }
2159        if let (Some(c), Some(t)) = (cost, total) {
2160            span.record("cost_usd", c);
2161            span.record("total_cost_usd", t);
2162        }
2163        if let Some(started) = self.turn_started.take() {
2164            span.record("duration_ms", started.elapsed().as_millis() as u64);
2165        }
2166    }
2167
2168    async fn handle_frame(
2169        &mut self,
2170        frame: &[u8],
2171        events_tx: &broadcast::Sender<InboundEvent>,
2172        permission_handler: Option<&PermissionHandler>,
2173    ) -> Option<PolicyFailure> {
2174        if frame.iter().all(u8::is_ascii_whitespace) {
2175            return None;
2176        }
2177        let parsed = match serde_json::from_slice::<Value>(frame) {
2178            Ok(v) => v,
2179            Err(e) => {
2180                debug!(line_bytes = frame.len(), json_error = ?e.classify(), "failed to parse duplex event, skipping");
2181                return None;
2182            }
2183        };
2184        // The pump can arm and settle an operation in one poll. Check after
2185        // parsing too, before a reply can hide an expired watch update.
2186        if let Some(failure) = self.expired_deadline() {
2187            return Some(failure);
2188        }
2189        // Peek at the semantic outcome before handle_inbound consumes it.
2190        let is_result = parsed.get("type").and_then(Value::as_str) == Some("result");
2191        if is_result {
2192            self.record_turn_outcome(&parsed);
2193        }
2194        match handle_inbound(
2195            parsed,
2196            &mut self.pending,
2197            events_tx,
2198            self.policy.max_turn_events,
2199        ) {
2200            InboundAction::None => {}
2201            InboundAction::EventLimit(max) => {
2202                return Some(PolicyFailure::Limit {
2203                    limit: DuplexLimit::TurnEvents,
2204                    max,
2205                });
2206            }
2207            InboundAction::Permission(req) => {
2208                let request_id = req.request_id.clone();
2209                let decision = match permission_handler {
2210                    Some(h) => h.invoke(req).await,
2211                    None => {
2212                        warn!("received can_use_tool with no permission handler; auto-denying");
2213                        PermissionDecision::Deny {
2214                            message: "no permission handler configured on duplex session".into(),
2215                        }
2216                    }
2217                };
2218                if matches!(decision, PermissionDecision::Defer) {
2219                    debug!("permission handler deferred; waiting for respond_to_permission");
2220                } else if write_permission_response(&mut self.stdin, &request_id, &decision)
2221                    .await
2222                    .is_err()
2223                {
2224                    warn!("failed to write permission response");
2225                }
2226            }
2227            InboundAction::ControlResponse {
2228                request_id,
2229                outcome,
2230            } => {
2231                if let Some((reply, _)) = self.pending_control.remove(&request_id) {
2232                    let _ = reply.send(outcome);
2233                    self.publish_deadline();
2234                } else {
2235                    debug!("received control_response with no pending request");
2236                }
2237            }
2238        }
2239        if is_result && self.pending.is_none() {
2240            self.finish_turn();
2241        }
2242        None
2243    }
2244
2245    async fn handle_outbound(&mut self, msg: OutboundMsg) {
2246        match msg {
2247            OutboundMsg::Send { prompt, reply } => {
2248                if self.pending.is_some() {
2249                    let _ = reply.send(Err(Error::DuplexTurnInFlight));
2250                    return;
2251                }
2252                // Accepted: the deadline and byte budget start before the
2253                // write, so a blocked stdin write is bounded too.
2254                self.pending = Some((reply, Vec::new()));
2255                self.turn_deadline =
2256                    OpDeadline::start(DuplexOperation::Turn, self.policy.turn_timeout);
2257                self.turn_bytes.start();
2258                self.publish_deadline();
2259                if let Err(e) = write_user(&mut self.stdin, &prompt).await {
2260                    if let Some((reply, _)) = self.pending.take() {
2261                        let _ = reply.send(Err(e));
2262                    }
2263                    self.finish_turn();
2264                    return;
2265                }
2266                self.turns += 1;
2267                // One span per turn, so the events a turn produces are
2268                // attributable to it rather than to the session as a
2269                // whole. Outcome fields are recorded when the result
2270                // event arrives.
2271                self.turn_span = Some(tracing::debug_span!(
2272                    parent: &self.session_span,
2273                    "claude.turn",
2274                    turn = self.turns,
2275                    is_error = tracing::field::Empty,
2276                    subtype = tracing::field::Empty,
2277                    cost_usd = tracing::field::Empty,
2278                    total_cost_usd = tracing::field::Empty,
2279                    duration_ms = tracing::field::Empty,
2280                ));
2281                self.turn_started = Some(std::time::Instant::now());
2282            }
2283            OutboundMsg::PermissionResponse {
2284                request_id,
2285                decision,
2286            } => {
2287                if write_permission_response(&mut self.stdin, &request_id, &decision)
2288                    .await
2289                    .is_err()
2290                {
2291                    warn!("failed to write deferred permission response");
2292                }
2293            }
2294            OutboundMsg::Interrupt { reply } => {
2295                self.next_control_id += 1;
2296                let request_id = format!("interrupt-{}", self.next_control_id);
2297                let deadline =
2298                    OpDeadline::start(DuplexOperation::Control, self.policy.control_timeout);
2299                self.pending_control
2300                    .insert(request_id.clone(), (reply, deadline));
2301                self.publish_deadline();
2302                if let Err(e) =
2303                    write_control_request(&mut self.stdin, &request_id, "interrupt").await
2304                {
2305                    if let Some((reply, _)) = self.pending_control.remove(&request_id) {
2306                        let _ = reply.send(Err(e));
2307                    }
2308                    self.publish_deadline();
2309                }
2310            }
2311        }
2312    }
2313}
2314
2315/// The resident I/O loop: stdout frames, outbound requests, and the
2316/// stderr failure signal. Owns no state that a waiter needs, so the
2317/// supervisor may drop it at any await.
2318async fn pump(
2319    res: &mut Resident,
2320    outbound_rx: &mut mpsc::UnboundedReceiver<OutboundMsg>,
2321    events_tx: &broadcast::Sender<InboundEvent>,
2322    permission_handler: Option<&PermissionHandler>,
2323    stderr_failed: &mut oneshot::Receiver<()>,
2324) -> IoEnd {
2325    let mut stderr_failure_open = true;
2326    loop {
2327        if let Some(failure) = res.expired_deadline() {
2328            return IoEnd::Policy(failure);
2329        }
2330        // Outbound requests must get a chance to start their deadlines even
2331        // when stdout is continuously ready.
2332        tokio::select! {
2333            frame = res.frames.next_frame(&mut res.turn_bytes) => match frame {
2334                Ok(FrameRead::Frame(frame)) => {
2335                    if let Some(failure) =
2336                        res.handle_frame(&frame, events_tx, permission_handler).await
2337                    {
2338                        return IoEnd::Policy(failure);
2339                    }
2340                    // Frames already buffered need no I/O; keep yielding so
2341                    // a continuously ready stdout cannot monopolize the task.
2342                    tokio::task::consume_budget().await;
2343                }
2344                Ok(FrameRead::Eof) => return IoEnd::Eof,
2345                Err(FrameError::Io(source)) => {
2346                    return IoEnd::Read(Error::Io {
2347                        message: "failed to read duplex stdout".to_string(),
2348                        source,
2349                        working_dir: None,
2350                    });
2351                }
2352                Err(FrameError::Limit(failure)) => return IoEnd::Policy(failure),
2353            },
2354
2355            failure = &mut *stderr_failed, if stderr_failure_open => {
2356                stderr_failure_open = false;
2357                if failure.is_ok() {
2358                    return IoEnd::StderrFailed;
2359                }
2360            },
2361
2362            msg = outbound_rx.recv() => match msg {
2363                Some(msg) => res.handle_outbound(msg).await,
2364                None => return IoEnd::Closed,
2365            },
2366        }
2367    }
2368}
2369
2370/// Resolve when `deadline` passes; never when there is none.
2371async fn expire(deadline: Option<OpDeadline>) -> PolicyFailure {
2372    match deadline {
2373        Some(deadline) => {
2374            tokio::time::sleep_until(deadline.at).await;
2375            deadline.failure()
2376        }
2377        None => std::future::pending().await,
2378    }
2379}
2380
2381/// Run `pump` until it ends or the earliest published deadline passes.
2382///
2383/// The deadline is polled first, so a continuously ready stdout cannot
2384/// starve it, and it can cancel `pump` while that is awaiting a stdin
2385/// write or a permission callback.
2386async fn supervise(
2387    pump: impl Future<Output = IoEnd>,
2388    deadline_rx: &mut watch::Receiver<Option<OpDeadline>>,
2389) -> IoEnd {
2390    tokio::pin!(pump);
2391    let mut deadlines_open = true;
2392    loop {
2393        let next = *deadline_rx.borrow_and_update();
2394        tokio::select! {
2395            biased;
2396
2397            _ = expire(next) => {
2398                // The operation may have settled after `next` was read.
2399                // Only the current earliest deadline can expire.
2400                if let Some(current) = *deadline_rx.borrow_and_update()
2401                    && current.at <= Instant::now()
2402                {
2403                    return IoEnd::Policy(current.failure());
2404                }
2405            }
2406            changed = deadline_rx.changed(), if deadlines_open => {
2407                deadlines_open = changed.is_ok();
2408            }
2409            end = &mut pump => return end,
2410        }
2411    }
2412}
2413
2414/// Answer a request that was queued but never accepted.
2415fn reject_queued(msg: OutboundMsg) {
2416    match msg {
2417        OutboundMsg::Send { reply, .. } => {
2418            let _ = reply.send(Err(Error::DuplexClosed));
2419        }
2420        OutboundMsg::Interrupt { reply } => {
2421            let _ = reply.send(Err(Error::DuplexClosed));
2422        }
2423        OutboundMsg::PermissionResponse { .. } => {}
2424    }
2425}
2426
2427#[allow(clippy::too_many_arguments)]
2428async fn run_session_child(
2429    mut child: Child,
2430    mut group: crate::exec::GroupKillGuard,
2431    kill_grace: Option<Duration>,
2432    termination_signal: crate::TerminationSignal,
2433    stdin: ChildStdin,
2434    stdout: ChildStdout,
2435    mut outbound_rx: mpsc::UnboundedReceiver<OutboundMsg>,
2436    events_tx: broadcast::Sender<InboundEvent>,
2437    permission_handler: Option<PermissionHandler>,
2438    policy: DuplexPolicy,
2439    mut stderr_failed: oneshot::Receiver<()>,
2440) -> Result<(ExitStatus, bool)> {
2441    let (deadline_tx, mut deadline_rx) = watch::channel(None);
2442    let mut res = Resident {
2443        stdin,
2444        frames: FrameReader::new(stdout, policy.max_frame_bytes),
2445        turn_bytes: TurnBytes {
2446            limit: policy.max_turn_bytes,
2447            used: None,
2448        },
2449        pending: None,
2450        turn_deadline: None,
2451        pending_control: HashMap::new(),
2452        next_control_id: 0,
2453        deadline_tx,
2454        policy,
2455        // The session span is this task's own span (see DuplexSession::spawn).
2456        session_span: tracing::Span::current(),
2457        turns: 0,
2458        turn_span: None,
2459        turn_started: None,
2460        last_total_cost: None,
2461    };
2462
2463    // The I/O future is dropped when this returns, before any cleanup.
2464    let end = supervise(
2465        pump(
2466            &mut res,
2467            &mut outbound_rx,
2468            &events_tx,
2469            permission_handler.as_ref(),
2470            &mut stderr_failed,
2471        ),
2472        &mut deadline_rx,
2473    )
2474    .await;
2475
2476    // Reject new requests immediately and settle any that were queued but
2477    // never accepted. Accepted waiters are settled after the child is.
2478    outbound_rx.close();
2479    while let Ok(msg) = outbound_rx.try_recv() {
2480        reject_queued(msg);
2481    }
2482
2483    let mut failure = None;
2484    let mut stream_err = None;
2485    match end {
2486        IoEnd::Policy(f) => failure = Some(f),
2487        IoEnd::Read(error) => stream_err = Some(error),
2488        IoEnd::Eof | IoEnd::Closed | IoEnd::StderrFailed => {}
2489    }
2490
2491    let Resident {
2492        stdin,
2493        frames,
2494        pending,
2495        pending_control,
2496        session_span,
2497        turns,
2498        ..
2499    } = res;
2500    drop(stdin);
2501    let mut stdout = frames.into_inner();
2502    // Stderr is drained by the enclosing future, including while this
2503    // function awaits a permission handler or settles the child.
2504    let mut unused_stderr = tokio::io::empty();
2505
2506    let mut status = None;
2507    if failure.is_none() {
2508        // Accepted operations keep their deadlines during graceful shutdown.
2509        let graceful = crate::exec::drain_until(
2510            tokio::time::timeout(SHUTDOWN_BUDGET, child.wait()),
2511            &mut stdout,
2512            &mut unused_stderr,
2513        );
2514        let waited = tokio::select! {
2515            biased;
2516            waited = graceful => Some(waited),
2517            expired = expire(*deadline_rx.borrow()) => {
2518                failure = Some(expired);
2519                None
2520            }
2521        };
2522        if let Some(waited) = waited {
2523            let settlement = settle_graceful(
2524                waited,
2525                &mut child,
2526                &mut group,
2527                kill_grace,
2528                termination_signal,
2529                &mut stdout,
2530            );
2531            tokio::pin!(settlement);
2532            status = Some(tokio::select! {
2533                biased;
2534                settled = &mut settlement => settled,
2535                expired = expire(*deadline_rx.borrow()) => {
2536                    failure = Some(expired);
2537                    // Do not restart cleanup or spend kill_grace twice.
2538                    settlement.await
2539                }
2540            });
2541        }
2542    }
2543
2544    // A tripped policy skips the graceful budget but honors the client's
2545    // kill_grace and termination_signal.
2546    let mut process_settled = status.as_ref().is_some_and(Result::is_ok);
2547    if let Some(failure) = failure
2548        && status.is_none()
2549    {
2550        warn!(?failure, "duplex policy tripped; stopping child");
2551        match crate::exec::drain_until(
2552            crate::exec::stop_and_reap(
2553                &mut child,
2554                &mut group,
2555                kill_grace,
2556                termination_signal,
2557                None,
2558            ),
2559            &mut stdout,
2560            &mut unused_stderr,
2561        )
2562        .await
2563        {
2564            Ok(()) => process_settled = true,
2565            Err(error) => warn!(%error, "failed to settle duplex child after policy failure"),
2566        }
2567    }
2568
2569    let settle_error = || match failure {
2570        Some(failure) => failure.error(process_settled),
2571        None => Error::DuplexClosed,
2572    };
2573    if let Some((reply, _)) = pending {
2574        let _ = reply.send(Err(settle_error()));
2575    }
2576    for (_, (reply, _)) in pending_control {
2577        let _ = reply.send(Err(settle_error()));
2578    }
2579
2580    session_span.record("turns", turns);
2581    match (failure, stream_err, status) {
2582        (Some(failure), _, _) => Err(failure.error(process_settled)),
2583        (None, Some(error), _) => Err(error),
2584        (None, None, Some(status)) => status,
2585        (None, None, None) => unreachable!("graceful shutdown always records a status"),
2586    }
2587}
2588
2589/// Finish the graceful shutdown path from the outcome of the bounded
2590/// wait: a native exit, a wait error, or an expired shutdown budget.
2591async fn settle_graceful(
2592    waited: std::result::Result<std::io::Result<ExitStatus>, tokio::time::error::Elapsed>,
2593    child: &mut Child,
2594    group: &mut crate::exec::GroupKillGuard,
2595    kill_grace: Option<Duration>,
2596    termination_signal: crate::TerminationSignal,
2597    stdout: &mut BufReader<ChildStdout>,
2598) -> Result<(ExitStatus, bool)> {
2599    let mut unused_stderr = tokio::io::empty();
2600    match waited {
2601        Ok(Ok(status)) => {
2602            group.disarm();
2603            Ok((status, false))
2604        }
2605        Ok(Err(source)) => {
2606            let error = Error::Io {
2607                message: "failed to wait for duplex child".into(),
2608                source,
2609                working_dir: None,
2610            };
2611            match crate::exec::drain_until(
2612                crate::exec::stop_and_reap(child, group, kill_grace, termination_signal, None),
2613                stdout,
2614                &mut unused_stderr,
2615            )
2616            .await
2617            {
2618                Ok(()) => Err(error),
2619                Err(cleanup) => Err(cleanup),
2620            }
2621        }
2622        Err(_) => {
2623            warn!("duplex child did not exit within shutdown budget; stopping");
2624            match crate::exec::drain_until(
2625                crate::exec::stop_and_reap(child, group, kill_grace, termination_signal, None),
2626                stdout,
2627                &mut unused_stderr,
2628            )
2629            .await
2630            {
2631                Ok(()) => child
2632                    .wait()
2633                    .await
2634                    .map(|status| (status, true))
2635                    .map_err(|source| Error::Io {
2636                        message: "failed to wait for stopped duplex child".into(),
2637                        source,
2638                        working_dir: None,
2639                    }),
2640                Err(error) => Err(error),
2641            }
2642        }
2643    }
2644}
2645
2646/// Action returned from [`handle_inbound`] for the run loop to act
2647/// on after the side-effects (broadcast, accumulate, resolve) are
2648/// done.
2649enum InboundAction {
2650    /// No further action -- side-effects were all handled inline.
2651    None,
2652    /// A `control_request {subtype: "can_use_tool"}` was received and
2653    /// needs the [`PermissionHandler`] invoked. The run loop awaits
2654    /// the handler and writes the response.
2655    Permission(PermissionRequest),
2656    /// A `control_response` matching one of our outbound
2657    /// `control_request`s arrived. The run loop matches `request_id`
2658    /// against its `pending_control` table and resolves the
2659    /// corresponding oneshot.
2660    ControlResponse {
2661        request_id: String,
2662        outcome: Result<()>,
2663    },
2664    /// Retaining this event would exceed the configured per-turn event
2665    /// limit. Nothing was broadcast or retained.
2666    EventLimit(usize),
2667}
2668
2669/// The configured event limit, if the pending turn already holds that many.
2670fn event_limit(pending: &PendingTurn, max_events: Option<usize>) -> Option<usize> {
2671    let (_, events) = pending.as_ref()?;
2672    max_events.filter(|max| events.len() >= *max)
2673}
2674
2675fn handle_inbound(
2676    msg: Value,
2677    pending: &mut PendingTurn,
2678    events_tx: &broadcast::Sender<InboundEvent>,
2679    max_events: Option<usize>,
2680) -> InboundAction {
2681    // Results and well-formed control responses are never retained. Check
2682    // the limit before broadcasting so subscribers see only retained events.
2683    let kind = msg.get("type").and_then(Value::as_str);
2684    let retained = match kind {
2685        Some("result") => false,
2686        Some("control_response") => parse_control_response(&msg).is_none(),
2687        _ => true,
2688    };
2689    if retained && let Some(max) = event_limit(pending, max_events) {
2690        return InboundAction::EventLimit(max);
2691    }
2692    match kind {
2693        Some("result") => {
2694            if let Some((reply, events)) = pending.take() {
2695                let _ = reply.send(Ok(TurnResult {
2696                    result: msg,
2697                    events,
2698                }));
2699            } else {
2700                debug!("dropping orphan result event with no pending turn");
2701            }
2702            InboundAction::None
2703        }
2704        Some("control_request") => {
2705            // can_use_tool flows through the permission handler;
2706            // anything else is logged + accumulated as Other for now.
2707            if msg
2708                .get("request")
2709                .and_then(|r| r.get("subtype"))
2710                .and_then(Value::as_str)
2711                == Some("can_use_tool")
2712                && let Some(req) = parse_permission_request(&msg)
2713            {
2714                if let Some((_, events)) = pending.as_mut() {
2715                    events.push(msg);
2716                }
2717                return InboundAction::Permission(req);
2718            }
2719            debug!("received unhandled control_request; treating as Other");
2720            let _ = events_tx.send(InboundEvent::Other(msg.clone()));
2721            if let Some((_, events)) = pending.as_mut() {
2722                events.push(msg);
2723            }
2724            InboundAction::None
2725        }
2726        Some("control_response") => {
2727            if let Some((request_id, outcome)) = parse_control_response(&msg) {
2728                return InboundAction::ControlResponse {
2729                    request_id,
2730                    outcome,
2731                };
2732            }
2733            debug!("received malformed control_response; treating as Other");
2734            let _ = events_tx.send(InboundEvent::Other(msg.clone()));
2735            if let Some((_, events)) = pending.as_mut() {
2736                events.push(msg);
2737            }
2738            InboundAction::None
2739        }
2740        _ => {
2741            // Broadcast a classified copy. Send error means no
2742            // subscribers, which is fine -- subscribers are optional.
2743            let _ = events_tx.send(classify(&msg));
2744
2745            if let Some((_, events)) = pending.as_mut() {
2746                events.push(msg);
2747            } else {
2748                debug!("dropping inbound event with no pending turn");
2749            }
2750            InboundAction::None
2751        }
2752    }
2753}
2754
2755fn parse_permission_request(msg: &Value) -> Option<PermissionRequest> {
2756    let request_id = msg.get("request_id").and_then(Value::as_str)?;
2757    let request = msg.get("request")?;
2758    let tool_name = request.get("tool_name").and_then(Value::as_str)?;
2759    let input = request.get("input").cloned().unwrap_or(Value::Null);
2760    Some(PermissionRequest {
2761        request_id: request_id.to_string(),
2762        tool_name: tool_name.to_string(),
2763        input,
2764        raw: request.clone(),
2765    })
2766}
2767
2768/// Pull `(request_id, outcome)` out of a `control_response` envelope.
2769///
2770/// Returns `None` if `request_id` is missing or the subtype is
2771/// unrecognised. `Some((id, Ok(())))` for `subtype: "success"`,
2772/// `Some((id, Err(DuplexControlFailed)))` for `subtype: "error"`.
2773fn parse_control_response(msg: &Value) -> Option<(String, Result<()>)> {
2774    let response = msg.get("response")?;
2775    let request_id = response.get("request_id").and_then(Value::as_str)?;
2776    let outcome = match response.get("subtype").and_then(Value::as_str) {
2777        Some("success") => Ok(()),
2778        Some("error") => {
2779            let message = response
2780                .get("error")
2781                .and_then(Value::as_str)
2782                .unwrap_or("unknown control_response error")
2783                .to_string();
2784            Err(Error::DuplexControlFailed { message })
2785        }
2786        _ => return None,
2787    };
2788    Some((request_id.to_string(), outcome))
2789}
2790
2791async fn write_user(stdin: &mut ChildStdin, prompt: &str) -> Result<()> {
2792    let user_msg = serde_json::json!({
2793        "type": "user",
2794        "message": {
2795            "role": "user",
2796            "content": prompt,
2797        },
2798        "parent_tool_use_id": null,
2799    });
2800    write_line(stdin, &user_msg, "user message").await
2801}
2802
2803async fn write_control_request(
2804    stdin: &mut ChildStdin,
2805    request_id: &str,
2806    subtype: &str,
2807) -> Result<()> {
2808    let envelope = serde_json::json!({
2809        "type": "control_request",
2810        "request_id": request_id,
2811        "request": { "subtype": subtype },
2812    });
2813    write_line(stdin, &envelope, "control_request").await
2814}
2815
2816async fn write_permission_response(
2817    stdin: &mut ChildStdin,
2818    request_id: &str,
2819    decision: &PermissionDecision,
2820) -> Result<()> {
2821    let inner = match decision {
2822        PermissionDecision::Allow { updated_input } => {
2823            let mut obj = serde_json::Map::new();
2824            obj.insert("behavior".to_string(), Value::String("allow".to_string()));
2825            if let Some(input) = updated_input {
2826                obj.insert("updatedInput".to_string(), input.clone());
2827            }
2828            Value::Object(obj)
2829        }
2830        PermissionDecision::Deny { message } => serde_json::json!({
2831            "behavior": "deny",
2832            "message": message,
2833        }),
2834        PermissionDecision::Defer => {
2835            // Caller path is supposed to filter this; defensive guard.
2836            return Ok(());
2837        }
2838    };
2839    let envelope = serde_json::json!({
2840        "type": "control_response",
2841        "response": {
2842            "request_id": request_id,
2843            "subtype": "success",
2844            "response": inner,
2845        },
2846    });
2847    write_line(stdin, &envelope, "control_response").await
2848}
2849
2850async fn write_line(stdin: &mut ChildStdin, value: &Value, what: &'static str) -> Result<()> {
2851    let mut line = serde_json::to_string(value).map_err(|e| Error::Json {
2852        message: format!("failed to serialize duplex {what}"),
2853        source: e,
2854    })?;
2855    line.push('\n');
2856    stdin
2857        .write_all(line.as_bytes())
2858        .await
2859        .map_err(|e| Error::Io {
2860            message: format!("failed to write {what} to duplex stdin"),
2861            source: e,
2862            working_dir: None,
2863        })?;
2864    stdin.flush().await.map_err(|e| Error::Io {
2865        message: "failed to flush duplex stdin".to_string(),
2866        source: e,
2867        working_dir: None,
2868    })?;
2869    Ok(())
2870}
2871
2872#[cfg(test)]
2873mod tests {
2874    use super::*;
2875    use serde_json::json;
2876
2877    fn handle(
2878        msg: Value,
2879        pending: &mut PendingTurn,
2880        events_tx: &broadcast::Sender<InboundEvent>,
2881    ) -> InboundAction {
2882        handle_inbound(msg, pending, events_tx, None)
2883    }
2884
2885    #[test]
2886    fn build_args_default_includes_required_flags() {
2887        let args = DuplexOptions::default().build_args();
2888        assert!(args.contains(&"--print".to_string()));
2889        assert!(args.contains(&"--verbose".to_string()));
2890        assert!(
2891            args.windows(2)
2892                .any(|w| w == ["--output-format", "stream-json"])
2893        );
2894        assert!(
2895            args.windows(2)
2896                .any(|w| w == ["--input-format", "stream-json"])
2897        );
2898    }
2899
2900    #[test]
2901    fn build_args_includes_model() {
2902        let args = DuplexOptions::default().model("haiku").build_args();
2903        assert!(args.windows(2).any(|w| w == ["--model", "haiku"]));
2904    }
2905
2906    #[test]
2907    fn build_args_includes_system_prompts() {
2908        let args = DuplexOptions::default()
2909            .system_prompt("be concise")
2910            .append_system_prompt("also polite")
2911            .build_args();
2912        assert!(
2913            args.windows(2)
2914                .any(|w| w == ["--system-prompt", "be concise"])
2915        );
2916        assert!(
2917            args.windows(2)
2918                .any(|w| w == ["--append-system-prompt", "also polite"])
2919        );
2920    }
2921
2922    #[test]
2923    fn build_args_appends_raw_args_last() {
2924        let args = DuplexOptions::default()
2925            .arg("--add-dir")
2926            .arg("/tmp/foo")
2927            .build_args();
2928        // Last two entries should be the additional args, in order.
2929        assert_eq!(&args[args.len() - 2..], &["--add-dir", "/tmp/foo"]);
2930    }
2931
2932    // ─── to_command_string / spawn parity (#703) ───
2933
2934    fn preview_claude() -> Claude {
2935        Claude::builder()
2936            .binary("/usr/local/bin/claude")
2937            .build()
2938            .unwrap()
2939    }
2940
2941    #[test]
2942    fn spawn_command_args_prepends_global_args() {
2943        let claude = Claude::builder()
2944            .binary("/usr/local/bin/claude")
2945            .arg("--debug")
2946            .build()
2947            .unwrap();
2948        let args = DuplexOptions::default()
2949            .model("haiku")
2950            .spawn_command_args(&claude);
2951        assert_eq!(args[0], "--debug");
2952        assert_eq!(args[1], "--print");
2953        assert!(args.windows(2).any(|w| w == ["--model", "haiku"]));
2954    }
2955
2956    #[test]
2957    fn to_command_string_is_binary_plus_spawn_args() {
2958        // The preview must be exactly the binary plus the args the
2959        // spawn path assembles (no arg here needs quoting).
2960        let claude = Claude::builder()
2961            .binary("/usr/local/bin/claude")
2962            .arg("--debug")
2963            .build()
2964            .unwrap();
2965        let opts = DuplexOptions::default().agent("reviewer");
2966        let expected = format!(
2967            "/usr/local/bin/claude {}",
2968            opts.spawn_command_args(&claude).join(" ")
2969        );
2970        assert_eq!(opts.to_command_string(&claude), expected);
2971    }
2972
2973    #[test]
2974    fn to_command_string_includes_persona_flags() {
2975        // Repro from #703: a spawn configured with an agent, allowed
2976        // tools, and setting sources must show all three in the echo.
2977        let command_str = DuplexOptions::default()
2978            .agent("reviewer")
2979            .allowed_tool("Read")
2980            .allowed_tool("Bash(git:*)")
2981            .setting_sources("project")
2982            .to_command_string(&preview_claude());
2983        assert!(command_str.starts_with("/usr/local/bin/claude"));
2984        assert!(command_str.contains("--agent reviewer"));
2985        assert!(command_str.contains("--allowed-tools 'Read,Bash(git:*)'"));
2986        assert!(command_str.contains("--setting-sources project"));
2987    }
2988
2989    #[test]
2990    fn to_command_string_quotes_args_with_spaces() {
2991        let command_str = DuplexOptions::default()
2992            .system_prompt("be concise")
2993            .to_command_string(&preview_claude());
2994        assert!(command_str.contains("--system-prompt 'be concise'"));
2995    }
2996
2997    #[test]
2998    fn to_command_string_does_not_consume_options() {
2999        // Borrowing preview: the same options remain usable after the
3000        // echo (previewed first, then handed to spawn).
3001        let claude = preview_claude();
3002        let opts = DuplexOptions::default().model("haiku");
3003        let first = opts.to_command_string(&claude);
3004        let second = opts.to_command_string(&claude);
3005        assert_eq!(first, second);
3006    }
3007
3008    #[test]
3009    fn build_args_includes_resume_when_set() {
3010        let args = DuplexOptions::default().resume("abc-123").build_args();
3011        assert!(args.windows(2).any(|w| w == ["--resume", "abc-123"]));
3012    }
3013
3014    #[test]
3015    fn build_args_omits_resume_by_default() {
3016        let args = DuplexOptions::default().build_args();
3017        assert!(
3018            !args.iter().any(|a| a == "--resume"),
3019            "--resume should not appear without an explicit resume(...) call; got {args:?}"
3020        );
3021    }
3022
3023    #[test]
3024    fn build_args_includes_continue_when_set() {
3025        let args = DuplexOptions::default().continue_session().build_args();
3026        assert!(args.iter().any(|a| a == "--continue"));
3027    }
3028
3029    #[test]
3030    fn build_args_omits_continue_by_default() {
3031        let args = DuplexOptions::default().build_args();
3032        assert!(!args.iter().any(|a| a == "--continue"));
3033    }
3034
3035    #[test]
3036    fn build_args_includes_worktree_flag_without_name() {
3037        let args = DuplexOptions::default().worktree(None::<&str>).build_args();
3038        assert!(args.iter().any(|a| a == "--worktree"));
3039        // No name means no positional follows --worktree.
3040        let pos = args.iter().position(|a| a == "--worktree").unwrap();
3041        assert!(
3042            args.get(pos + 1).is_none_or(|a| a.starts_with("--")),
3043            "--worktree without a name should not be followed by a positional; got {args:?}"
3044        );
3045    }
3046
3047    #[test]
3048    fn build_args_includes_worktree_flag_with_name() {
3049        let args = DuplexOptions::default()
3050            .worktree(Some("agent-xyz"))
3051            .build_args();
3052        let pos = args.iter().position(|a| a == "--worktree").unwrap();
3053        assert_eq!(args.get(pos + 1).map(String::as_str), Some("agent-xyz"));
3054    }
3055
3056    #[test]
3057    fn build_args_omits_worktree_by_default() {
3058        let args = DuplexOptions::default().build_args();
3059        assert!(
3060            !args.iter().any(|a| a == "--worktree"),
3061            "--worktree should not appear without an explicit worktree(...) call; got {args:?}"
3062        );
3063    }
3064
3065    #[test]
3066    fn worktree_lands_before_additional_args() {
3067        // Same `--` ordering bug class as resume.
3068        let args = DuplexOptions::default()
3069            .worktree(Some("foo"))
3070            .arg("--")
3071            .arg("trailing")
3072            .build_args();
3073        let wt_pos = args.iter().position(|a| a == "--worktree").unwrap();
3074        let dash_dash_pos = args.iter().position(|a| a == "--").unwrap();
3075        assert!(
3076            wt_pos < dash_dash_pos,
3077            "--worktree must precede `--` separator; got {args:?}"
3078        );
3079    }
3080
3081    #[test]
3082    fn build_args_includes_agent_when_set() {
3083        let args = DuplexOptions::default().agent("rust-qa").build_args();
3084        assert!(
3085            args.windows(2).any(|w| w == ["--agent", "rust-qa"]),
3086            "missing --agent rust-qa in {args:?}"
3087        );
3088    }
3089
3090    #[test]
3091    fn build_args_omits_agent_by_default() {
3092        let args = DuplexOptions::default().build_args();
3093        assert!(
3094            !args.iter().any(|a| a == "--agent"),
3095            "--agent should not appear without an explicit agent(...) call; got {args:?}"
3096        );
3097    }
3098
3099    #[test]
3100    fn build_args_includes_agents_json_when_set() {
3101        let json = r#"{"reviewer":{"description":"r","prompt":"p"}}"#;
3102        let args = DuplexOptions::default().agents_json(json).build_args();
3103        let pos = args.iter().position(|a| a == "--agents").unwrap();
3104        assert_eq!(args.get(pos + 1).map(String::as_str), Some(json));
3105    }
3106
3107    #[test]
3108    fn build_args_omits_agents_json_by_default() {
3109        let args = DuplexOptions::default().build_args();
3110        assert!(!args.iter().any(|a| a == "--agents"));
3111    }
3112
3113    #[test]
3114    fn agent_and_agents_json_compose() {
3115        let json = r#"{"reviewer":{"description":"r","prompt":"p"}}"#;
3116        let args = DuplexOptions::default()
3117            .agents_json(json)
3118            .agent("reviewer")
3119            .build_args();
3120        // Both flags present.
3121        assert!(args.iter().any(|a| a == "--agents"));
3122        assert!(args.iter().any(|a| a == "--agent"));
3123    }
3124
3125    #[test]
3126    fn agent_lands_before_additional_args() {
3127        let args = DuplexOptions::default()
3128            .agent("rust-qa")
3129            .arg("--")
3130            .arg("trailing")
3131            .build_args();
3132        let agent_pos = args.iter().position(|a| a == "--agent").unwrap();
3133        let dash_dash_pos = args.iter().position(|a| a == "--").unwrap();
3134        assert!(
3135            agent_pos < dash_dash_pos,
3136            "--agent must precede `--` separator; got {args:?}"
3137        );
3138    }
3139
3140    #[test]
3141    fn agents_json_lands_before_additional_args() {
3142        let args = DuplexOptions::default()
3143            .agents_json("{}")
3144            .arg("--")
3145            .arg("trailing")
3146            .build_args();
3147        let agents_pos = args.iter().position(|a| a == "--agents").unwrap();
3148        let dash_dash_pos = args.iter().position(|a| a == "--").unwrap();
3149        assert!(
3150            agents_pos < dash_dash_pos,
3151            "--agents must precede `--` separator; got {args:?}"
3152        );
3153    }
3154
3155    // -- QueryCommand knob-set parity (#672) -------------------------
3156
3157    #[test]
3158    fn build_args_includes_session_id() {
3159        let args = DuplexOptions::default().session_id("sid-9").build_args();
3160        assert!(args.windows(2).any(|w| w == ["--session-id", "sid-9"]));
3161    }
3162
3163    #[test]
3164    fn build_args_includes_setting_sources() {
3165        let args = DuplexOptions::default()
3166            .setting_sources("user,project")
3167            .build_args();
3168        assert!(
3169            args.windows(2)
3170                .any(|w| w == ["--setting-sources", "user,project"]),
3171            "got {args:?}"
3172        );
3173    }
3174
3175    #[test]
3176    fn build_args_omits_setting_sources_by_default() {
3177        let args = DuplexOptions::default().build_args();
3178        assert!(!args.iter().any(|a| a == "--setting-sources"));
3179    }
3180
3181    #[test]
3182    fn build_args_hermetic_emits_full_seal() {
3183        let args = DuplexOptions::default().hermetic().build_args();
3184        assert!(
3185            args.windows(2)
3186                .any(|w| w[0] == "--setting-sources" && w[1].is_empty()),
3187            "got {args:?}"
3188        );
3189        assert!(args.iter().any(|a| a == "--strict-mcp-config"));
3190        assert!(
3191            args.iter()
3192                .any(|a| a == "--exclude-dynamic-system-prompt-sections")
3193        );
3194        // A hermetic seal must never imply --bare.
3195        assert!(!args.iter().any(|a| a == "--bare"));
3196    }
3197
3198    #[test]
3199    fn build_args_hermetic_scoped_project_keeps_user() {
3200        let args = DuplexOptions::default()
3201            .hermetic_scoped(HermeticScope::Project)
3202            .build_args();
3203        assert!(args.windows(2).any(|w| w == ["--setting-sources", "user"]));
3204        assert!(args.iter().any(|a| a == "--strict-mcp-config"));
3205    }
3206
3207    #[test]
3208    fn build_args_includes_json_schema() {
3209        let schema = r#"{"type":"object"}"#;
3210        let args = DuplexOptions::default().json_schema(schema).build_args();
3211        assert!(args.windows(2).any(|w| w == ["--json-schema", schema]));
3212    }
3213
3214    #[test]
3215    fn build_args_joins_allowed_tools_comma_separated() {
3216        let args = DuplexOptions::default()
3217            .allowed_tools(["Read", "Bash(git log:*)"])
3218            .allowed_tool("Write")
3219            .build_args();
3220        assert!(
3221            args.windows(2)
3222                .any(|w| w == ["--allowed-tools", "Read,Bash(git log:*),Write"]),
3223            "missing joined --allowed-tools in {args:?}"
3224        );
3225    }
3226
3227    #[test]
3228    fn build_args_joins_disallowed_tools_comma_separated() {
3229        let args = DuplexOptions::default()
3230            .disallowed_tools(["WebSearch"])
3231            .disallowed_tool("WebFetch")
3232            .build_args();
3233        assert!(
3234            args.windows(2)
3235                .any(|w| w == ["--disallowed-tools", "WebSearch,WebFetch"]),
3236            "missing joined --disallowed-tools in {args:?}"
3237        );
3238    }
3239
3240    #[test]
3241    fn build_args_includes_caps() {
3242        let args = DuplexOptions::default()
3243            .max_turns(4)
3244            .max_budget_usd(0.25)
3245            .build_args();
3246        assert!(args.windows(2).any(|w| w == ["--max-turns", "4"]));
3247        assert!(args.windows(2).any(|w| w == ["--max-budget-usd", "0.25"]));
3248    }
3249
3250    #[test]
3251    fn build_args_includes_fallback_model_and_effort() {
3252        let args = DuplexOptions::default()
3253            .fallback_model("haiku")
3254            .effort(Effort::Low)
3255            .build_args();
3256        assert!(args.windows(2).any(|w| w == ["--fallback-model", "haiku"]));
3257        assert!(args.windows(2).any(|w| w == ["--effort", "low"]));
3258    }
3259
3260    #[test]
3261    fn build_args_repeats_add_dir_and_mcp_config() {
3262        let args = DuplexOptions::default()
3263            .add_dir("/a")
3264            .add_dir("/b")
3265            .mcp_config("x.json")
3266            .strict_mcp_config()
3267            .build_args();
3268        assert!(args.windows(2).any(|w| w == ["--add-dir", "/a"]));
3269        assert!(args.windows(2).any(|w| w == ["--add-dir", "/b"]));
3270        assert!(args.windows(2).any(|w| w == ["--mcp-config", "x.json"]));
3271        assert!(args.iter().any(|a| a == "--strict-mcp-config"));
3272    }
3273
3274    #[test]
3275    fn build_args_includes_no_session_persistence() {
3276        let args = DuplexOptions::default()
3277            .no_session_persistence()
3278            .build_args();
3279        assert!(args.iter().any(|a| a == "--no-session-persistence"));
3280    }
3281
3282    // ─── #690: parity builders promoted from QueryCommand ───
3283
3284    #[test]
3285    fn build_args_joins_tools_comma_separated() {
3286        let args = DuplexOptions::default()
3287            .tools(["Bash", "Read", "Edit"])
3288            .build_args();
3289        assert!(
3290            args.windows(2).any(|w| w == ["--tools", "Bash,Read,Edit"]),
3291            "missing joined --tools in {args:?}"
3292        );
3293    }
3294
3295    #[test]
3296    fn build_args_repeats_file_per_spec() {
3297        let args = DuplexOptions::default()
3298            .file("file_a:doc.txt")
3299            .file("file_b:notes.md")
3300            .build_args();
3301        assert_eq!(args.iter().filter(|a| *a == "--file").count(), 2);
3302        assert!(args.iter().any(|a| a == "file_a:doc.txt"));
3303        assert!(args.iter().any(|a| a == "file_b:notes.md"));
3304    }
3305
3306    #[test]
3307    fn build_args_includes_settings() {
3308        let args = DuplexOptions::default()
3309            .settings("/tmp/settings.json")
3310            .build_args();
3311        assert!(
3312            args.windows(2)
3313                .any(|w| w == ["--settings", "/tmp/settings.json"])
3314        );
3315    }
3316
3317    #[test]
3318    fn build_args_includes_fork_session() {
3319        let args = DuplexOptions::default().fork_session().build_args();
3320        assert!(args.iter().any(|a| a == "--fork-session"));
3321    }
3322
3323    #[test]
3324    fn build_args_includes_debug_filter_and_file() {
3325        let args = DuplexOptions::default()
3326            .debug_filter("api,hooks")
3327            .debug_file("/tmp/debug.log")
3328            .build_args();
3329        assert!(args.windows(2).any(|w| w == ["--debug", "api,hooks"]));
3330        assert!(
3331            args.windows(2)
3332                .any(|w| w == ["--debug-file", "/tmp/debug.log"])
3333        );
3334    }
3335
3336    #[test]
3337    fn build_args_includes_betas() {
3338        let args = DuplexOptions::default().betas("feature-x").build_args();
3339        assert!(args.windows(2).any(|w| w == ["--betas", "feature-x"]));
3340    }
3341
3342    #[test]
3343    fn build_args_repeats_plugin_dir_and_url() {
3344        let args = DuplexOptions::default()
3345            .plugin_dir("/plugins/a")
3346            .plugin_dir("/plugins/b")
3347            .plugin_url("https://example.com/p.zip")
3348            .build_args();
3349        assert_eq!(args.iter().filter(|a| *a == "--plugin-dir").count(), 2);
3350        assert!(
3351            args.windows(2)
3352                .any(|w| w == ["--plugin-url", "https://example.com/p.zip"])
3353        );
3354    }
3355
3356    #[test]
3357    fn build_args_includes_bare_family_bool_flags() {
3358        let args = DuplexOptions::default()
3359            .tmux()
3360            .bare()
3361            .safe_mode()
3362            .disable_slash_commands()
3363            .include_hook_events()
3364            .exclude_dynamic_system_prompt_sections()
3365            .build_args();
3366        for flag in [
3367            "--tmux",
3368            "--bare",
3369            "--safe-mode",
3370            "--disable-slash-commands",
3371            "--include-hook-events",
3372            "--exclude-dynamic-system-prompt-sections",
3373        ] {
3374            assert!(args.iter().any(|a| a == flag), "missing {flag} in {args:?}");
3375        }
3376    }
3377
3378    #[test]
3379    fn build_args_includes_name() {
3380        let args = DuplexOptions::default().name("my session").build_args();
3381        assert!(args.windows(2).any(|w| w == ["--name", "my session"]));
3382    }
3383
3384    #[test]
3385    fn build_args_omits_promoted_parity_flags_by_default() {
3386        let args = DuplexOptions::default().build_args();
3387        for flag in [
3388            "--tools",
3389            "--file",
3390            "--settings",
3391            "--fork-session",
3392            "--debug",
3393            "--debug-file",
3394            "--betas",
3395            "--plugin-dir",
3396            "--plugin-url",
3397            "--tmux",
3398            "--bare",
3399            "--safe-mode",
3400            "--disable-slash-commands",
3401            "--include-hook-events",
3402            "--exclude-dynamic-system-prompt-sections",
3403            "--name",
3404        ] {
3405            assert!(
3406                !args.iter().any(|a| a == flag),
3407                "{flag} should be absent by default; got {args:?}"
3408            );
3409        }
3410    }
3411
3412    #[test]
3413    fn parity_flags_land_before_additional_args() {
3414        // Same `--` ordering bug class as resume/agent.
3415        let args = DuplexOptions::default()
3416            .max_turns(2)
3417            .json_schema("{}")
3418            .arg("--")
3419            .arg("trailing")
3420            .build_args();
3421        let dash_dash_pos = args.iter().position(|a| a == "--").unwrap();
3422        for flag in ["--max-turns", "--json-schema"] {
3423            let pos = args.iter().position(|a| a == flag).unwrap();
3424            assert!(
3425                pos < dash_dash_pos,
3426                "{flag} must precede `--` separator; got {args:?}"
3427            );
3428        }
3429    }
3430
3431    #[test]
3432    fn build_args_omits_parity_flags_by_default() {
3433        let args = DuplexOptions::default().build_args();
3434        for flag in [
3435            "--session-id",
3436            "--json-schema",
3437            "--allowed-tools",
3438            "--disallowed-tools",
3439            "--max-turns",
3440            "--max-budget-usd",
3441            "--fallback-model",
3442            "--effort",
3443            "--add-dir",
3444            "--mcp-config",
3445            "--strict-mcp-config",
3446            "--no-session-persistence",
3447        ] {
3448            assert!(
3449                !args.iter().any(|a| a == flag),
3450                "{flag} should not appear by default; got {args:?}"
3451            );
3452        }
3453    }
3454
3455    #[test]
3456    fn resume_lands_before_additional_args() {
3457        // Catches the same class of bug as QueryCommand::execute_json
3458        // had: a flag appended after the user-supplied raw args (which
3459        // typically include `--`) gets eaten as a positional. Resume
3460        // must precede any caller-injected `arg(...)`.
3461        let args = DuplexOptions::default()
3462            .resume("xyz")
3463            .arg("--")
3464            .arg("trailing")
3465            .build_args();
3466        let resume_pos = args.iter().position(|a| a == "--resume").unwrap();
3467        let dash_dash_pos = args.iter().position(|a| a == "--").unwrap();
3468        assert!(
3469            resume_pos < dash_dash_pos,
3470            "--resume must precede `--` separator; got {args:?}"
3471        );
3472    }
3473
3474    #[test]
3475    fn turn_result_accessors_pull_from_result() {
3476        let r = TurnResult {
3477            result: json!({
3478                "type": "result",
3479                "result": "hello",
3480                "session_id": "sess-123",
3481                "total_cost_usd": 0.0042,
3482                "duration_ms": 1234_u64,
3483            }),
3484            events: vec![],
3485        };
3486        assert_eq!(r.result_text(), Some("hello"));
3487        assert_eq!(r.session_id(), Some("sess-123"));
3488        assert_eq!(r.total_cost_usd(), Some(0.0042));
3489        assert_eq!(r.duration_ms(), Some(1234));
3490    }
3491
3492    #[test]
3493    fn turn_result_total_cost_falls_back_to_legacy_field() {
3494        let r = TurnResult {
3495            result: json!({ "cost_usd": 0.5 }),
3496            events: vec![],
3497        };
3498        assert_eq!(r.total_cost_usd(), Some(0.5));
3499    }
3500
3501    #[test]
3502    fn turn_result_accessors_return_none_when_missing() {
3503        let r = TurnResult {
3504            result: json!({}),
3505            events: vec![],
3506        };
3507        assert_eq!(r.result_text(), None);
3508        assert_eq!(r.session_id(), None);
3509        assert_eq!(r.total_cost_usd(), None);
3510        assert_eq!(r.duration_ms(), None);
3511        assert_eq!(r.subtype(), None);
3512        assert!(!r.is_error());
3513    }
3514
3515    #[test]
3516    fn turn_result_subtype_and_is_error_for_interrupted_turn() {
3517        let r = TurnResult {
3518            result: json!({
3519                "type": "result",
3520                "subtype": "error_during_execution",
3521                "is_error": true,
3522                "total_cost_usd": 0.0004,
3523            }),
3524            events: vec![],
3525        };
3526        assert_eq!(r.subtype(), Some("error_during_execution"));
3527        assert!(r.is_error());
3528        assert_eq!(r.result_text(), None);
3529    }
3530
3531    #[test]
3532    fn turn_cost_delta_tracks_previous_total() {
3533        let mut prev = None;
3534        assert_eq!(turn_cost_delta(&mut prev, Some(0.25)), Some(0.25));
3535        assert_eq!(turn_cost_delta(&mut prev, Some(0.75)), Some(0.5));
3536        // No total: no cost, baseline unchanged.
3537        assert_eq!(turn_cost_delta(&mut prev, None), None);
3538        assert_eq!(prev, Some(0.75));
3539        // Falling total: clamped at zero, becomes the new baseline.
3540        assert_eq!(turn_cost_delta(&mut prev, Some(0.5)), Some(0.0));
3541        assert_eq!(turn_cost_delta(&mut prev, Some(1.0)), Some(0.5));
3542    }
3543
3544    #[test]
3545    fn handle_inbound_appends_non_result_to_pending_events() {
3546        let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
3547        let (events_tx, _events_rx) = broadcast::channel(16);
3548        let mut pending = Some((tx, Vec::new()));
3549        handle(
3550            json!({ "type": "assistant", "message": {} }),
3551            &mut pending,
3552            &events_tx,
3553        );
3554        let (_, events) = pending.as_ref().unwrap();
3555        assert_eq!(events.len(), 1);
3556        assert_eq!(
3557            events[0].get("type").and_then(Value::as_str),
3558            Some("assistant")
3559        );
3560    }
3561
3562    #[test]
3563    fn handle_inbound_resolves_pending_on_result() {
3564        let (tx, rx) = oneshot::channel::<Result<TurnResult>>();
3565        let (events_tx, _events_rx) = broadcast::channel(16);
3566        let mut pending = Some((tx, vec![json!({ "type": "assistant" })]));
3567        handle(
3568            json!({ "type": "result", "result": "ok" }),
3569            &mut pending,
3570            &events_tx,
3571        );
3572        assert!(pending.is_none());
3573        let received = rx.blocking_recv().unwrap().unwrap();
3574        assert_eq!(received.result_text(), Some("ok"));
3575        assert_eq!(received.events.len(), 1);
3576    }
3577
3578    #[test]
3579    fn handle_inbound_drops_orphans_without_pending_turn() {
3580        let (events_tx, _events_rx) = broadcast::channel(16);
3581        let mut pending: Option<(oneshot::Sender<Result<TurnResult>>, Vec<Value>)> = None;
3582        handle(json!({ "type": "assistant" }), &mut pending, &events_tx);
3583        handle(
3584            json!({ "type": "result", "result": "ok" }),
3585            &mut pending,
3586            &events_tx,
3587        );
3588        assert!(pending.is_none());
3589    }
3590
3591    #[test]
3592    fn handle_inbound_broadcasts_classified_event() {
3593        let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
3594        let (events_tx, mut events_rx) = broadcast::channel(16);
3595        let mut pending = Some((tx, Vec::new()));
3596        handle(
3597            json!({ "type": "assistant", "message": { "role": "assistant" } }),
3598            &mut pending,
3599            &events_tx,
3600        );
3601        let event = events_rx.try_recv().expect("classified event broadcast");
3602        assert!(matches!(event, InboundEvent::Assistant(_)));
3603    }
3604
3605    #[test]
3606    fn handle_inbound_does_not_broadcast_result() {
3607        let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
3608        let (events_tx, mut events_rx) = broadcast::channel(16);
3609        let mut pending = Some((tx, Vec::new()));
3610        handle(
3611            json!({ "type": "result", "result": "ok" }),
3612            &mut pending,
3613            &events_tx,
3614        );
3615        // Result is not broadcast -- it lands in TurnResult.result.
3616        assert!(events_rx.try_recv().is_err());
3617    }
3618
3619    #[test]
3620    fn classify_system_init_pulls_session_id() {
3621        let v = json!({
3622            "type": "system",
3623            "subtype": "init",
3624            "session_id": "sess-abc",
3625        });
3626        match classify(&v) {
3627            InboundEvent::SystemInit { session_id } => assert_eq!(session_id, "sess-abc"),
3628            other => panic!("expected SystemInit, got {other:?}"),
3629        }
3630    }
3631
3632    #[test]
3633    fn classify_system_without_init_subtype_is_other() {
3634        let v = json!({ "type": "system", "subtype": "compaction" });
3635        assert!(matches!(classify(&v), InboundEvent::Other(_)));
3636    }
3637
3638    #[test]
3639    fn classify_system_init_without_session_id_is_other() {
3640        let v = json!({ "type": "system", "subtype": "init" });
3641        assert!(matches!(classify(&v), InboundEvent::Other(_)));
3642    }
3643
3644    #[test]
3645    fn classify_assistant_stream_event_user() {
3646        assert!(matches!(
3647            classify(&json!({ "type": "assistant" })),
3648            InboundEvent::Assistant(_)
3649        ));
3650        assert!(matches!(
3651            classify(&json!({ "type": "stream_event" })),
3652            InboundEvent::StreamEvent(_)
3653        ));
3654        assert!(matches!(
3655            classify(&json!({ "type": "user" })),
3656            InboundEvent::User(_)
3657        ));
3658    }
3659
3660    #[test]
3661    fn classify_unknown_type_is_other() {
3662        assert!(matches!(
3663            classify(&json!({ "type": "control_request" })),
3664            InboundEvent::Other(_)
3665        ));
3666        assert!(matches!(
3667            classify(&json!({ "type": "future_thing" })),
3668            InboundEvent::Other(_)
3669        ));
3670        assert!(matches!(classify(&json!({})), InboundEvent::Other(_)));
3671    }
3672
3673    #[test]
3674    fn build_args_does_not_emit_subscriber_capacity_flag() {
3675        // subscriber_capacity is runtime config, not a CLI arg.
3676        let args = DuplexOptions::default()
3677            .subscriber_capacity(64)
3678            .build_args();
3679        assert!(!args.iter().any(|a| a.contains("subscriber")));
3680        assert!(!args.iter().any(|a| a.contains("capacity")));
3681    }
3682
3683    #[test]
3684    fn build_args_includes_permission_prompt_tool_when_handler_set() {
3685        let handler = PermissionHandler::new(|_req| async move {
3686            PermissionDecision::Allow {
3687                updated_input: None,
3688            }
3689        });
3690        let args = DuplexOptions::default().on_permission(handler).build_args();
3691        assert!(
3692            args.windows(2)
3693                .any(|w| w == ["--permission-prompt-tool", "stdio"])
3694        );
3695    }
3696
3697    #[test]
3698    fn build_args_omits_permission_prompt_tool_without_handler() {
3699        let args = DuplexOptions::default().build_args();
3700        assert!(!args.iter().any(|a| a == "--permission-prompt-tool"));
3701    }
3702
3703    #[test]
3704    fn build_args_emits_permission_mode_flag() {
3705        let args = DuplexOptions::default()
3706            .permission_mode(PermissionMode::AcceptEdits)
3707            .build_args();
3708        assert!(
3709            args.windows(2)
3710                .any(|w| w == ["--permission-mode", "acceptEdits"]),
3711            "missing --permission-mode acceptEdits in {args:?}"
3712        );
3713    }
3714
3715    #[test]
3716    fn build_args_emits_plan_mode() {
3717        let args = DuplexOptions::default()
3718            .permission_mode(PermissionMode::Plan)
3719            .build_args();
3720        assert!(args.windows(2).any(|w| w == ["--permission-mode", "plan"]));
3721    }
3722
3723    #[test]
3724    fn build_args_omits_permission_mode_by_default() {
3725        let args = DuplexOptions::default().build_args();
3726        assert!(!args.iter().any(|a| a == "--permission-mode"));
3727    }
3728
3729    #[test]
3730    fn build_args_emits_dangerously_skip_permissions_flag() {
3731        let args = DuplexOptions::default()
3732            .dangerously_skip_permissions()
3733            .build_args();
3734        assert!(args.iter().any(|a| a == "--dangerously-skip-permissions"));
3735    }
3736
3737    #[test]
3738    fn build_args_omits_dangerously_skip_by_default() {
3739        let args = DuplexOptions::default().build_args();
3740        assert!(!args.iter().any(|a| a == "--dangerously-skip-permissions"));
3741    }
3742
3743    #[test]
3744    fn parse_permission_request_extracts_fields() {
3745        let msg = json!({
3746            "type": "control_request",
3747            "request_id": "req-1",
3748            "request": {
3749                "subtype": "can_use_tool",
3750                "tool_name": "Bash",
3751                "input": { "command": "ls" }
3752            }
3753        });
3754        let req = parse_permission_request(&msg).expect("permission request");
3755        assert_eq!(req.request_id, "req-1");
3756        assert_eq!(req.tool_name, "Bash");
3757        assert_eq!(req.input, json!({ "command": "ls" }));
3758        assert_eq!(
3759            req.raw.get("subtype").and_then(Value::as_str),
3760            Some("can_use_tool")
3761        );
3762    }
3763
3764    #[test]
3765    fn parse_permission_request_returns_none_when_missing_request_id() {
3766        let msg = json!({
3767            "type": "control_request",
3768            "request": {
3769                "subtype": "can_use_tool",
3770                "tool_name": "Bash",
3771            }
3772        });
3773        assert!(parse_permission_request(&msg).is_none());
3774    }
3775
3776    #[test]
3777    fn parse_permission_request_returns_none_when_missing_tool_name() {
3778        let msg = json!({
3779            "type": "control_request",
3780            "request_id": "req-1",
3781            "request": { "subtype": "can_use_tool" }
3782        });
3783        assert!(parse_permission_request(&msg).is_none());
3784    }
3785
3786    #[test]
3787    fn parse_permission_request_handles_missing_input() {
3788        let msg = json!({
3789            "type": "control_request",
3790            "request_id": "req-1",
3791            "request": {
3792                "subtype": "can_use_tool",
3793                "tool_name": "Bash",
3794            }
3795        });
3796        let req = parse_permission_request(&msg).expect("request");
3797        assert_eq!(req.input, Value::Null);
3798    }
3799
3800    #[test]
3801    fn handle_inbound_returns_permission_for_can_use_tool() {
3802        let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
3803        let (events_tx, _events_rx) = broadcast::channel(16);
3804        let mut pending = Some((tx, Vec::new()));
3805        let action = handle(
3806            json!({
3807                "type": "control_request",
3808                "request_id": "req-1",
3809                "request": {
3810                    "subtype": "can_use_tool",
3811                    "tool_name": "Bash",
3812                    "input": { "command": "ls" }
3813                }
3814            }),
3815            &mut pending,
3816            &events_tx,
3817        );
3818        match action {
3819            InboundAction::Permission(req) => {
3820                assert_eq!(req.request_id, "req-1");
3821                assert_eq!(req.tool_name, "Bash");
3822            }
3823            InboundAction::None
3824            | InboundAction::ControlResponse { .. }
3825            | InboundAction::EventLimit(_) => {
3826                panic!("expected Permission action");
3827            }
3828        }
3829        // Event should also be accumulated in the pending turn.
3830        let (_, events) = pending.as_ref().unwrap();
3831        assert_eq!(events.len(), 1);
3832    }
3833
3834    #[test]
3835    fn handle_inbound_treats_unknown_control_request_as_other() {
3836        let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
3837        let (events_tx, mut events_rx) = broadcast::channel(16);
3838        let mut pending = Some((tx, Vec::new()));
3839        let action = handle(
3840            json!({
3841                "type": "control_request",
3842                "request_id": "req-2",
3843                "request": { "subtype": "future_subtype" }
3844            }),
3845            &mut pending,
3846            &events_tx,
3847        );
3848        assert!(matches!(action, InboundAction::None));
3849        let event = events_rx.try_recv().expect("broadcast");
3850        assert!(matches!(event, InboundEvent::Other(_)));
3851    }
3852
3853    #[tokio::test]
3854    async fn permission_handler_invokes_closure_async() {
3855        let handler = PermissionHandler::new(|req| async move {
3856            if req.tool_name == "Bash" {
3857                PermissionDecision::Deny {
3858                    message: "no bash".into(),
3859                }
3860            } else {
3861                PermissionDecision::Allow {
3862                    updated_input: None,
3863                }
3864            }
3865        });
3866        let req = PermissionRequest {
3867            request_id: "r1".into(),
3868            tool_name: "Bash".into(),
3869            input: Value::Null,
3870            raw: Value::Null,
3871        };
3872        match handler.invoke(req).await {
3873            PermissionDecision::Deny { message } => assert_eq!(message, "no bash"),
3874            other => panic!("expected Deny, got {other:?}"),
3875        }
3876    }
3877
3878    #[test]
3879    fn parse_control_response_extracts_success() {
3880        let msg = json!({
3881            "type": "control_response",
3882            "response": {
3883                "request_id": "interrupt-1",
3884                "subtype": "success",
3885                "response": {}
3886            }
3887        });
3888        let (id, outcome) = parse_control_response(&msg).expect("parsed");
3889        assert_eq!(id, "interrupt-1");
3890        assert!(outcome.is_ok());
3891    }
3892
3893    #[test]
3894    fn parse_control_response_extracts_error_with_message() {
3895        let msg = json!({
3896            "type": "control_response",
3897            "response": {
3898                "request_id": "interrupt-2",
3899                "subtype": "error",
3900                "error": "no turn in flight"
3901            }
3902        });
3903        let (id, outcome) = parse_control_response(&msg).expect("parsed");
3904        assert_eq!(id, "interrupt-2");
3905        match outcome {
3906            Err(Error::DuplexControlFailed { message }) => {
3907                assert_eq!(message, "no turn in flight");
3908            }
3909            other => panic!("expected DuplexControlFailed, got {other:?}"),
3910        }
3911    }
3912
3913    #[test]
3914    fn parse_control_response_returns_none_on_missing_request_id() {
3915        let msg = json!({
3916            "type": "control_response",
3917            "response": { "subtype": "success" }
3918        });
3919        assert!(parse_control_response(&msg).is_none());
3920    }
3921
3922    #[test]
3923    fn parse_control_response_returns_none_on_unknown_subtype() {
3924        let msg = json!({
3925            "type": "control_response",
3926            "response": { "request_id": "x", "subtype": "future_subtype" }
3927        });
3928        assert!(parse_control_response(&msg).is_none());
3929    }
3930
3931    #[test]
3932    fn handle_inbound_returns_control_response_action() {
3933        let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
3934        let (events_tx, _events_rx) = broadcast::channel(16);
3935        let mut pending = Some((tx, Vec::new()));
3936        let action = handle(
3937            json!({
3938                "type": "control_response",
3939                "response": {
3940                    "request_id": "interrupt-1",
3941                    "subtype": "success",
3942                    "response": {}
3943                }
3944            }),
3945            &mut pending,
3946            &events_tx,
3947        );
3948        match action {
3949            InboundAction::ControlResponse {
3950                request_id,
3951                outcome,
3952            } => {
3953                assert_eq!(request_id, "interrupt-1");
3954                assert!(outcome.is_ok());
3955            }
3956            InboundAction::None | InboundAction::Permission(_) | InboundAction::EventLimit(_) => {
3957                panic!("expected ControlResponse action");
3958            }
3959        }
3960    }
3961
3962    #[test]
3963    fn handle_inbound_treats_malformed_control_response_as_other() {
3964        let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
3965        let (events_tx, mut events_rx) = broadcast::channel(16);
3966        let mut pending = Some((tx, Vec::new()));
3967        let action = handle(
3968            json!({
3969                "type": "control_response",
3970                "response": { "subtype": "success" }
3971            }),
3972            &mut pending,
3973            &events_tx,
3974        );
3975        assert!(matches!(action, InboundAction::None));
3976        let event = events_rx.try_recv().expect("broadcast");
3977        assert!(matches!(event, InboundEvent::Other(_)));
3978    }
3979
3980    #[tokio::test]
3981    async fn permission_handler_clones_arc() {
3982        let handler = PermissionHandler::new(|_req| async move {
3983            PermissionDecision::Allow {
3984                updated_input: None,
3985            }
3986        });
3987        let cloned = handler.clone();
3988        let req = PermissionRequest {
3989            request_id: "r1".into(),
3990            tool_name: "Read".into(),
3991            input: Value::Null,
3992            raw: Value::Null,
3993        };
3994        // Both handles invoke the same underlying closure.
3995        let _ = handler.invoke(req.clone()).await;
3996        let _ = cloned.invoke(req).await;
3997    }
3998
3999    /// Build a `DuplexSession` whose channels are wired up but whose
4000    /// background task is a no-op. Tests can drive the watch state
4001    /// machine via the returned `exit_tx` and observe the public
4002    /// accessors. The fake task idles on a oneshot so it stays alive
4003    /// for the life of the test (no JoinHandle::abort handshake
4004    /// needed).
4005    fn fake_session(
4006        initial: SessionExitStatus,
4007    ) -> (
4008        DuplexSession,
4009        watch::Sender<SessionExitStatus>,
4010        oneshot::Sender<()>,
4011    ) {
4012        let (outbound_tx, outbound_rx) = mpsc::unbounded_channel::<OutboundMsg>();
4013        let (events_tx, _events_rx) = broadcast::channel::<InboundEvent>(16);
4014        let (exit_tx, exit_rx) = watch::channel(initial);
4015        let (stop_tx, stop_rx) = oneshot::channel::<()>();
4016
4017        let join = tokio::spawn(async move {
4018            let _outbound_rx = outbound_rx;
4019            let _ = stop_rx.await;
4020            Ok::<(), Error>(())
4021        });
4022
4023        let session = DuplexSession {
4024            outbound_tx,
4025            events_tx,
4026            exit_rx,
4027            join,
4028        };
4029        (session, exit_tx, stop_tx)
4030    }
4031
4032    #[tokio::test]
4033    async fn is_alive_true_while_running() {
4034        let (session, _exit_tx, _stop) = fake_session(SessionExitStatus::Running);
4035        assert!(session.is_alive());
4036    }
4037
4038    #[tokio::test]
4039    async fn is_alive_false_after_completed() {
4040        let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
4041        exit_tx.send(SessionExitStatus::Completed).unwrap();
4042        assert!(!session.is_alive());
4043    }
4044
4045    #[tokio::test]
4046    async fn is_alive_false_after_failed() {
4047        let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
4048        exit_tx
4049            .send(SessionExitStatus::Failed("boom".into()))
4050            .unwrap();
4051        assert!(!session.is_alive());
4052    }
4053
4054    #[tokio::test]
4055    async fn exit_status_reports_running_initially() {
4056        let (session, _exit_tx, _stop) = fake_session(SessionExitStatus::Running);
4057        assert!(matches!(session.exit_status(), SessionExitStatus::Running));
4058    }
4059
4060    #[tokio::test]
4061    async fn exit_status_reflects_completed() {
4062        let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
4063        exit_tx.send(SessionExitStatus::Completed).unwrap();
4064        assert!(matches!(
4065            session.exit_status(),
4066            SessionExitStatus::Completed
4067        ));
4068    }
4069
4070    #[tokio::test]
4071    async fn exit_status_reflects_failed_with_message() {
4072        let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
4073        exit_tx
4074            .send(SessionExitStatus::Failed("oh no".into()))
4075            .unwrap();
4076        match session.exit_status() {
4077            SessionExitStatus::Failed(msg) => assert_eq!(msg, "oh no"),
4078            other => panic!("expected Failed, got {other:?}"),
4079        }
4080    }
4081
4082    #[tokio::test]
4083    async fn wait_for_exit_returns_immediately_when_already_terminal() {
4084        let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
4085        exit_tx.send(SessionExitStatus::Completed).unwrap();
4086        let status = tokio::time::timeout(Duration::from_secs(1), session.wait_for_exit())
4087            .await
4088            .expect("wait_for_exit should not block when already terminal");
4089        assert!(matches!(status, SessionExitStatus::Completed));
4090    }
4091
4092    #[tokio::test]
4093    async fn wait_for_exit_blocks_until_state_transitions() {
4094        let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
4095
4096        let waiter = async { session.wait_for_exit().await };
4097        let driver = async {
4098            tokio::time::sleep(Duration::from_millis(20)).await;
4099            exit_tx.send(SessionExitStatus::Completed).unwrap();
4100        };
4101        let (status, ()) = tokio::join!(waiter, driver);
4102        assert!(matches!(status, SessionExitStatus::Completed));
4103    }
4104
4105    #[tokio::test]
4106    async fn wait_for_exit_supports_multiple_observers() {
4107        let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
4108
4109        let waiter1 = async { session.wait_for_exit().await };
4110        let waiter2 = async { session.wait_for_exit().await };
4111        let driver = async {
4112            tokio::time::sleep(Duration::from_millis(20)).await;
4113            exit_tx
4114                .send(SessionExitStatus::Failed("crash".into()))
4115                .unwrap();
4116        };
4117        let (s1, s2, ()) = tokio::join!(waiter1, waiter2, driver);
4118        match s1 {
4119            SessionExitStatus::Failed(msg) => assert_eq!(msg, "crash"),
4120            other => panic!("waiter1 expected Failed, got {other:?}"),
4121        }
4122        match s2 {
4123            SessionExitStatus::Failed(msg) => assert_eq!(msg, "crash"),
4124            other => panic!("waiter2 expected Failed, got {other:?}"),
4125        }
4126    }
4127
4128    #[tokio::test]
4129    async fn wait_for_exit_returns_last_value_when_sender_dropped() {
4130        // Defensive: if exit_tx is dropped without ever publishing a
4131        // terminal value, wait_for_exit should fall back to the last
4132        // observed state rather than hang.
4133        let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
4134        let waiter = async { session.wait_for_exit().await };
4135        let driver = async {
4136            tokio::time::sleep(Duration::from_millis(20)).await;
4137            drop(exit_tx);
4138        };
4139        let (status, ()) = tokio::time::timeout(Duration::from_secs(1), async {
4140            tokio::join!(waiter, driver)
4141        })
4142        .await
4143        .expect("wait_for_exit must not hang when sender is dropped");
4144        assert!(matches!(status, SessionExitStatus::Running));
4145    }
4146
4147    #[tokio::test]
4148    async fn stderr_reader_retries_interruptions_and_retains_bytes_on_failure() {
4149        struct Reader(u8);
4150        impl tokio::io::AsyncRead for Reader {
4151            fn poll_read(
4152                mut self: std::pin::Pin<&mut Self>,
4153                _: &mut std::task::Context<'_>,
4154                buf: &mut tokio::io::ReadBuf<'_>,
4155            ) -> std::task::Poll<std::io::Result<()>> {
4156                self.0 += 1;
4157                std::task::Poll::Ready(match self.0 {
4158                    1 => Err(std::io::ErrorKind::Interrupted.into()),
4159                    2 => {
4160                        buf.put_slice(b"retained");
4161                        Ok(())
4162                    }
4163                    _ => Err(std::io::Error::other("stderr read fault")),
4164                })
4165            }
4166        }
4167        let mut tail = VecDeque::new();
4168        let error = read_stderr_tail(&mut Reader(0), &mut tail)
4169            .await
4170            .unwrap_err();
4171        assert_eq!(error.to_string(), "stderr read fault");
4172        assert_eq!(tail.into_iter().collect::<Vec<_>>(), b"retained");
4173    }
4174
4175    #[cfg(unix)]
4176    #[test]
4177    fn stderr_read_failure_keeps_native_exit_evidence_and_diagnostics() {
4178        for code in [0, 7] {
4179            let status = std::process::Command::new("/bin/sh")
4180                .args(["-c", &format!("exit {code}")])
4181                .status()
4182                .unwrap();
4183            let result = resident_outcome(
4184                Ok((status, false)),
4185                Some(Err(std::io::Error::other("stderr read fault"))),
4186                "retained diagnostics".into(),
4187            );
4188            match result {
4189                Err(Error::DuplexProcessFailed {
4190                    status,
4191                    stderr,
4192                    stderr_read_error,
4193                    ..
4194                }) => {
4195                    assert_eq!(status.code(), Some(7));
4196                    assert_eq!(stderr, "retained diagnostics");
4197                    assert_eq!(stderr_read_error.unwrap().to_string(), "stderr read fault");
4198                }
4199                Err(Error::Io {
4200                    message, source, ..
4201                }) if code == 0 => {
4202                    assert!(message.contains("retained diagnostics"));
4203                    assert_eq!(source.to_string(), "stderr read fault");
4204                }
4205                other => panic!("expected retained exit/read evidence, got {other:?}"),
4206            }
4207        }
4208    }
4209    #[test]
4210    fn compatible_headless_flags_are_explicit_and_do_not_activate_bypass() {
4211        let defaults = DuplexOptions::default().build_args();
4212        assert!(
4213            !defaults.iter().any(|arg| arg == "--forward-subagent-text"
4214                || arg == "--allow-dangerously-skip-permissions")
4215        );
4216        let args = DuplexOptions::default()
4217            .forward_subagent_text()
4218            .allow_dangerously_skip_permissions()
4219            .build_args();
4220        assert!(args.iter().any(|arg| arg == "--forward-subagent-text"));
4221        assert!(
4222            args.iter()
4223                .any(|arg| arg == "--allow-dangerously-skip-permissions")
4224        );
4225        assert!(
4226            !args
4227                .iter()
4228                .any(|arg| arg == "--dangerously-skip-permissions" || arg == "bypassPermissions")
4229        );
4230    }
4231
4232    // -- partial messages and host-side policies (#838, #839, #840) --
4233
4234    #[test]
4235    fn partial_messages_are_opt_in() {
4236        let defaults = DuplexOptions::default().build_args();
4237        assert!(!defaults.iter().any(|a| a == "--include-partial-messages"));
4238        let args = DuplexOptions::default()
4239            .include_partial_messages()
4240            .arg("--")
4241            .build_args();
4242        let flag = args
4243            .iter()
4244            .position(|a| a == "--include-partial-messages")
4245            .expect("opt-in emits the flag");
4246        let dash_dash = args.iter().position(|a| a == "--").unwrap();
4247        assert!(flag < dash_dash, "{args:?}");
4248    }
4249
4250    #[test]
4251    fn policies_are_unlimited_by_default_and_emit_no_flags() {
4252        let defaults = DuplexOptions::default();
4253        assert!(defaults.policy.max_frame_bytes.is_none());
4254        assert!(defaults.policy.max_turn_bytes.is_none());
4255        assert!(defaults.policy.max_turn_events.is_none());
4256        assert!(defaults.policy.turn_timeout.is_none());
4257        assert!(defaults.policy.control_timeout.is_none());
4258        let configured = DuplexOptions::default()
4259            .max_frame_bytes(1)
4260            .max_turn_bytes(2)
4261            .max_turn_events(3)
4262            .turn_timeout(Duration::from_secs(4))
4263            .control_timeout(Duration::from_secs(5));
4264        assert_eq!(configured.build_args(), defaults.build_args());
4265    }
4266
4267    #[test]
4268    fn oversized_deadline_is_treated_as_unbounded() {
4269        assert!(OpDeadline::start(DuplexOperation::Turn, Some(Duration::MAX)).is_none());
4270        assert!(OpDeadline::start(DuplexOperation::Turn, None).is_none());
4271    }
4272
4273    async fn frames_of(
4274        input: &[u8],
4275        max_frame: Option<usize>,
4276        turn: &mut TurnBytes,
4277    ) -> (Vec<Vec<u8>>, Option<PolicyFailure>) {
4278        let mut reader = FrameReader::new(input, max_frame);
4279        let mut frames = Vec::new();
4280        loop {
4281            match reader.next_frame(turn).await {
4282                Ok(FrameRead::Frame(frame)) => frames.push(frame),
4283                Ok(FrameRead::Eof) => return (frames, None),
4284                Err(FrameError::Limit(failure)) => return (frames, Some(failure)),
4285                Err(FrameError::Io(error)) => panic!("unexpected io error: {error}"),
4286            }
4287        }
4288    }
4289
4290    #[tokio::test]
4291    async fn frame_reader_strips_terminators_and_keeps_a_final_partial_line() {
4292        let mut turn = TurnBytes::default();
4293        let (frames, failure) = frames_of(b"a\r\n\nb\nc", None, &mut turn).await;
4294        assert!(failure.is_none());
4295        assert_eq!(
4296            frames,
4297            vec![b"a".to_vec(), Vec::new(), b"b".to_vec(), b"c".to_vec()]
4298        );
4299    }
4300
4301    #[tokio::test]
4302    async fn frame_limit_trips_before_an_unterminated_line_is_extended() {
4303        let input = vec![b'x'; 1 << 20];
4304        let mut reader = FrameReader::new(&input[..], Some(1024));
4305        let mut turn = TurnBytes::default();
4306        match reader.next_frame(&mut turn).await {
4307            Err(FrameError::Limit(PolicyFailure::Limit { limit, max })) => {
4308                assert_eq!(limit, DuplexLimit::FrameBytes);
4309                assert_eq!(max, 1024);
4310            }
4311            _ => panic!("expected a frame limit failure"),
4312        }
4313        assert!(reader.partial.len() <= 1024, "{}", reader.partial.len());
4314        assert!(
4315            reader.partial.capacity() <= 2048,
4316            "{}",
4317            reader.partial.capacity()
4318        );
4319    }
4320
4321    #[tokio::test]
4322    async fn frame_limit_counts_the_newline() {
4323        let mut turn = TurnBytes::default();
4324        let (frames, failure) = frames_of(b"abc\n", Some(4), &mut turn).await;
4325        assert_eq!(frames, vec![b"abc".to_vec()]);
4326        assert!(failure.is_none());
4327        let (_, failure) = frames_of(b"abcd\n", Some(4), &mut turn).await;
4328        assert!(matches!(
4329            failure,
4330            Some(PolicyFailure::Limit {
4331                limit: DuplexLimit::FrameBytes,
4332                ..
4333            })
4334        ));
4335    }
4336
4337    #[tokio::test]
4338    async fn turn_bytes_count_newlines_blank_and_malformed_lines_only_while_active() {
4339        let mut idle = TurnBytes {
4340            limit: Some(3),
4341            used: None,
4342        };
4343        let (frames, failure) = frames_of(b"\n\n\n\nnot json\n", None, &mut idle).await;
4344        assert_eq!(frames.len(), 5);
4345        assert!(failure.is_none(), "idle bytes must not count");
4346
4347        let mut active = TurnBytes {
4348            limit: Some(10),
4349            used: None,
4350        };
4351        active.start();
4352        let (frames, failure) = frames_of(b"\n\n\n\nnot json\n", None, &mut active).await;
4353        assert_eq!(frames.len(), 4, "the malformed line would reach 13 bytes");
4354        assert!(matches!(
4355            failure,
4356            Some(PolicyFailure::Limit {
4357                limit: DuplexLimit::TurnBytes,
4358                max: 10
4359            })
4360        ));
4361        active.stop();
4362        active.start();
4363        assert_eq!(active.used, Some(0), "the budget resets per turn");
4364    }
4365
4366    #[tokio::test]
4367    async fn frame_reader_keeps_partial_bytes_across_a_cancelled_read() {
4368        let (mut writer, reader) = tokio::io::duplex(64);
4369        let mut frames = FrameReader::new(reader, None);
4370        let mut turn = TurnBytes::default();
4371        writer.write_all(br#"{"type":"#).await.unwrap();
4372        let cancelled =
4373            tokio::time::timeout(Duration::from_millis(20), frames.next_frame(&mut turn)).await;
4374        assert!(cancelled.is_err(), "no complete frame is available yet");
4375        writer.write_all(b"\"assistant\"}\n").await.unwrap();
4376        match frames.next_frame(&mut turn).await {
4377            Ok(FrameRead::Frame(frame)) => assert_eq!(frame, br#"{"type":"assistant"}"#),
4378            _ => panic!("expected the reassembled frame"),
4379        }
4380    }
4381
4382    #[test]
4383    fn event_limit_stops_retention_and_broadcast_but_not_results_or_acks() {
4384        let (tx, rx) = oneshot::channel::<Result<TurnResult>>();
4385        let (events_tx, mut events_rx) = broadcast::channel(16);
4386        let mut pending = Some((tx, Vec::new()));
4387        let limit = Some(1);
4388        assert!(matches!(
4389            handle_inbound(
4390                json!({"type": "assistant"}),
4391                &mut pending,
4392                &events_tx,
4393                limit
4394            ),
4395            InboundAction::None
4396        ));
4397        assert!(matches!(
4398            handle_inbound(
4399                json!({"type": "assistant"}),
4400                &mut pending,
4401                &events_tx,
4402                limit
4403            ),
4404            InboundAction::EventLimit(1)
4405        ));
4406        let ack = json!({
4407            "type": "control_response",
4408            "response": {"request_id": "interrupt-1", "subtype": "success"}
4409        });
4410        assert!(matches!(
4411            handle_inbound(ack, &mut pending, &events_tx, limit),
4412            InboundAction::ControlResponse { .. }
4413        ));
4414        assert!(events_rx.try_recv().is_ok());
4415        assert!(events_rx.try_recv().is_err(), "overflow must not broadcast");
4416        assert!(matches!(
4417            handle_inbound(json!({"type": "result"}), &mut pending, &events_tx, limit),
4418            InboundAction::None
4419        ));
4420        assert_eq!(rx.blocking_recv().unwrap().unwrap().events.len(), 1);
4421    }
4422
4423    #[test]
4424    fn policy_failures_render_distinguishable_errors() {
4425        let limit = PolicyFailure::Limit {
4426            limit: DuplexLimit::TurnEvents,
4427            max: 7,
4428        }
4429        .error(true);
4430        assert!(matches!(
4431            limit,
4432            Error::DuplexLimitExceeded {
4433                limit: DuplexLimit::TurnEvents,
4434                max: 7,
4435                process_settled: true,
4436                ..
4437            }
4438        ));
4439        let deadline = PolicyFailure::Deadline {
4440            operation: DuplexOperation::Control,
4441            timeout: Duration::from_millis(5),
4442        }
4443        .error(false);
4444        assert!(matches!(
4445            deadline,
4446            Error::DuplexDeadlineExceeded {
4447                operation: DuplexOperation::Control,
4448                process_settled: false,
4449                ..
4450            }
4451        ));
4452        assert!(deadline.to_string().contains("control request deadline"));
4453    }
4454
4455    #[tokio::test]
4456    async fn turn_budget_counts_a_partial_frame_started_while_idle() {
4457        let (mut writer, reader) = tokio::io::duplex(128);
4458        let mut frames = FrameReader::new(reader, None);
4459        let mut turn = TurnBytes {
4460            limit: Some(20),
4461            used: None,
4462        };
4463        let prefix = br#"{"type":"assistant","text":"a long prefix"#;
4464        writer.write_all(prefix).await.unwrap();
4465        tokio::select! {
4466            biased;
4467            _ = frames.next_frame(&mut turn) => panic!("frame is incomplete"),
4468            _ = tokio::task::yield_now() => {},
4469        }
4470        assert_eq!(frames.partial, prefix);
4471        turn.start();
4472        writer.write_all(b"\"}\n").await.unwrap();
4473        assert!(
4474            matches!(
4475                frames.next_frame(&mut turn).await,
4476                Err(FrameError::Limit(PolicyFailure::Limit {
4477                    limit: DuplexLimit::TurnBytes,
4478                    ..
4479                }))
4480            ),
4481            "the retained frame exceeds the turn budget even though its prefix was read while idle"
4482        );
4483    }
4484}