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//! [`QueryCommand`]: crate::QueryCommand
53//! [`Session`]: crate::session::Session
54//! [`Conversation`]: crate::conversation::Conversation
55//! [`BudgetTracker`]: crate::budget::BudgetTracker
56//!
57//! # Example
58//!
59//! ```no_run
60//! use claude_wrapper::Claude;
61//! use claude_wrapper::duplex::{DuplexOptions, DuplexSession};
62//!
63//! # async fn example() -> claude_wrapper::Result<()> {
64//! let claude = Claude::builder().build()?;
65//! let session = DuplexSession::spawn(
66//!     &claude,
67//!     DuplexOptions::default().model("haiku"),
68//! ).await?;
69//!
70//! let turn = session.send("hello").await?;
71//! if let Some(text) = turn.result_text() {
72//!     println!("{text}");
73//! }
74//!
75//! session.close().await?;
76//! # Ok(())
77//! # }
78//! ```
79//!
80//! # Subscribers
81//!
82//! For event-driven UIs that want to react to assistant tokens,
83//! tool-use blocks, or system events as they arrive, call
84//! [`DuplexSession::subscribe`] before issuing a [`DuplexSession::send`].
85//! Each receiver gets its own buffered view of the event stream;
86//! slow consumers see [`tokio::sync::broadcast::error::RecvError::Lagged`]
87//! rather than blocking the session task.
88//!
89//! ```no_run
90//! use claude_wrapper::Claude;
91//! use claude_wrapper::duplex::{DuplexOptions, DuplexSession, InboundEvent};
92//!
93//! # async fn example() -> claude_wrapper::Result<()> {
94//! let claude = Claude::builder().build()?;
95//! let session = DuplexSession::spawn(&claude, DuplexOptions::default()).await?;
96//!
97//! let mut rx = session.subscribe();
98//! let _turn = session.send("hello").await?;
99//!
100//! while let Ok(event) = rx.try_recv() {
101//!     match event {
102//!         InboundEvent::SystemInit { session_id } => {
103//!             println!("session id: {session_id}");
104//!         }
105//!         InboundEvent::Assistant(_) => {
106//!             // partial or complete assistant message
107//!         }
108//!         _ => {}
109//!     }
110//! }
111//!
112//! session.close().await?;
113//! # Ok(())
114//! # }
115//! ```
116//!
117//! For interleaved (concurrent) event handling while a turn is in
118//! flight, drive `rx.recv()` and the `send()` future together via
119//! `tokio::select!`. Pin the send future and use a block scope so
120//! its borrow of the session ends before [`DuplexSession::close`].
121//!
122//! # Mid-turn permission decisions
123//!
124//! Configure a [`PermissionHandler`] at spawn time to answer the
125//! CLI's permission prompts in-flight. The session writes
126//! `--permission-prompt-tool stdio` automatically when a handler is
127//! set, so the CLI emits `control_request` messages for tool use
128//! over the duplex channel rather than blocking on a TUI prompt.
129//!
130//! ```no_run
131//! use claude_wrapper::Claude;
132//! use claude_wrapper::duplex::{
133//!     DuplexOptions, DuplexSession, PermissionDecision, PermissionHandler,
134//! };
135//!
136//! # async fn example() -> claude_wrapper::Result<()> {
137//! let handler = PermissionHandler::new(|req| async move {
138//!     if req.tool_name == "Bash" {
139//!         PermissionDecision::Deny { message: "bash is denied".into() }
140//!     } else {
141//!         PermissionDecision::Allow { updated_input: None }
142//!     }
143//! });
144//!
145//! let claude = Claude::builder().build()?;
146//! let session = DuplexSession::spawn(
147//!     &claude,
148//!     DuplexOptions::default().on_permission(handler),
149//! ).await?;
150//! # Ok(())
151//! # }
152//! ```
153//!
154//! For human-in-the-loop UIs, return [`PermissionDecision::Defer`]
155//! from the handler, capture the [`PermissionRequest::request_id`],
156//! and answer later via [`DuplexSession::respond_to_permission`].
157//!
158//! The callback is the CLI's approval fallback, so an already-approved tool
159//! does not necessarily reach it. For tools that must ask, configure an explicit
160//! ask rule in [`DuplexOptions::settings`], for example
161//! `{"permissions":{"ask":["Bash"]}}`. Do not also auto-approve that action.
162//! Native denial and deferred-denial regressions exercise the resident transport
163//! at the supported 2.1.220 and 2.1.303 endpoints. They do not add SDK hook setup
164//! or change the CLI's permission evaluation rules. See the
165//! [official permission guide](https://code.claude.com/docs/en/agent-sdk/user-input).
166//!
167//! # Mid-turn interrupt
168//!
169//! [`DuplexSession::interrupt`] sends a clean
170//! `control_request {subtype: "interrupt"}` to the CLI. The CLI
171//! stops generating, closes the in-flight turn (`send().await`
172//! resolves with the truncated [`TurnResult`]), and answers our
173//! interrupt with a `control_response`. Use this instead of dropping
174//! the session or killing the child when you want to cancel one
175//! turn but keep the conversation going.
176//!
177//! ```no_run
178//! use std::time::Duration;
179//! use claude_wrapper::Claude;
180//! use claude_wrapper::duplex::{DuplexOptions, DuplexSession};
181//!
182//! # async fn example() -> claude_wrapper::Result<()> {
183//! let claude = Claude::builder().build()?;
184//! let session = DuplexSession::spawn(&claude, DuplexOptions::default()).await?;
185//!
186//! let send_fut = session.send("write a long essay about rust");
187//! let interrupt_fut = async {
188//!     tokio::time::sleep(Duration::from_millis(500)).await;
189//!     session.interrupt().await
190//! };
191//!
192//! let (turn, interrupt_result) = tokio::join!(send_fut, interrupt_fut);
193//! let _truncated = turn?;
194//! interrupt_result?;
195//! # Ok(())
196//! # }
197//! ```
198//!
199//! # Phased rollout
200//!
201//! This module rolled out in four PRs tracked in
202//! <https://github.com/joshrotenberg/claude-wrapper/issues/561>:
203//! `spawn`/`send`/`close` (PR 1), `subscribe` (PR 2), mid-turn
204//! permission handling (PR 3), and `interrupt` (PR 4, this one).
205
206use std::collections::{HashMap, VecDeque};
207use std::future::Future;
208use std::pin::Pin;
209use std::process::{ExitStatus, Stdio};
210use std::sync::Arc;
211use std::time::Duration;
212
213use serde_json::Value;
214use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
215use tokio::process::{Child, ChildStdin, ChildStdout, Command};
216use tokio::sync::{broadcast, mpsc, oneshot, watch};
217use tokio::task::JoinHandle;
218use tracing::{Instrument, debug, warn};
219
220use crate::Claude;
221use crate::command::spawn_args::{SharedSpawnArgs, shell_quote};
222use crate::error::{Error, Result};
223use crate::tool_pattern::ToolPattern;
224use crate::types::{Effort, HermeticScope, PermissionMode};
225
226/// Default capacity of the per-session [`broadcast::Sender`] backing
227/// [`DuplexSession::subscribe`].
228///
229/// Override per-session via [`DuplexOptions::subscriber_capacity`].
230pub const DEFAULT_SUBSCRIBER_CAPACITY: usize = 256;
231
232/// A mid-turn permission prompt from the CLI for a single tool
233/// invocation.
234///
235/// Forwarded to the [`PermissionHandler`] registered via
236/// [`DuplexOptions::on_permission`]. Capture
237/// [`Self::request_id`] inside your handler if you intend to return
238/// [`PermissionDecision::Defer`] and answer later via
239/// [`DuplexSession::respond_to_permission`].
240#[derive(Debug, Clone)]
241pub struct PermissionRequest {
242    /// CLI-assigned correlation id. Pass this to
243    /// [`DuplexSession::respond_to_permission`] when deferring.
244    pub request_id: String,
245    /// The tool the model wants to use (e.g. `"Bash"`, `"Edit"`).
246    pub tool_name: String,
247    /// The tool's `input` payload as the model produced it.
248    pub input: Value,
249    /// The full `request` object as sent by the CLI, for fields not
250    /// promoted to typed accessors.
251    pub raw: Value,
252}
253
254/// The decision returned from a [`PermissionHandler`] (or passed to
255/// [`DuplexSession::respond_to_permission`] for deferred decisions).
256///
257/// `Allow` and `Deny` both write a control response to the CLI
258/// immediately. `Defer` causes the run loop to skip writing a
259/// response; the caller is then expected to invoke
260/// [`DuplexSession::respond_to_permission`] later. Passing `Defer`
261/// to `respond_to_permission` is a no-op.
262#[derive(Debug, Clone)]
263pub enum PermissionDecision {
264    /// Allow the tool to run, optionally with rewritten input.
265    Allow {
266        /// Replace the model's input with this object before running
267        /// the tool. `None` keeps the original input.
268        updated_input: Option<Value>,
269    },
270    /// Deny the tool. The `message` is surfaced to the model.
271    Deny {
272        /// Human-readable explanation given back to the model.
273        message: String,
274    },
275    /// Decision pending; the caller will supply it later via
276    /// [`DuplexSession::respond_to_permission`].
277    Defer,
278}
279
280type PermissionFuture = Pin<Box<dyn Future<Output = PermissionDecision> + Send + 'static>>;
281type PermissionFn = dyn Fn(PermissionRequest) -> PermissionFuture + Send + Sync + 'static;
282
283/// A user-supplied async callback invoked when the CLI requests
284/// permission to use a tool.
285///
286/// Construct with [`Self::new`], passing an `async fn` or
287/// async-block closure. Cheap to clone (`Arc` under the hood).
288///
289/// The handler runs inline on the duplex session's task. The CLI is
290/// blocked on the response while the handler runs, so awaiting an
291/// async policy check (DB lookup, remote call) is fine. If the
292/// decision needs human input on a different timescale, return
293/// [`PermissionDecision::Defer`] and answer via
294/// [`DuplexSession::respond_to_permission`] when ready.
295#[derive(Clone)]
296pub struct PermissionHandler {
297    inner: Arc<PermissionFn>,
298}
299
300impl PermissionHandler {
301    /// Wrap an async closure as a permission handler.
302    ///
303    /// # Example
304    ///
305    /// ```
306    /// use claude_wrapper::duplex::{PermissionDecision, PermissionHandler};
307    ///
308    /// let _handler = PermissionHandler::new(|req| async move {
309    ///     if req.tool_name == "Bash" {
310    ///         PermissionDecision::Deny { message: "no bash".into() }
311    ///     } else {
312    ///         PermissionDecision::Allow { updated_input: None }
313    ///     }
314    /// });
315    /// ```
316    pub fn new<F, Fut>(f: F) -> Self
317    where
318        F: Fn(PermissionRequest) -> Fut + Send + Sync + 'static,
319        Fut: Future<Output = PermissionDecision> + Send + 'static,
320    {
321        Self {
322            inner: Arc::new(move |req| Box::pin(f(req))),
323        }
324    }
325
326    fn invoke(&self, req: PermissionRequest) -> PermissionFuture {
327        (self.inner)(req)
328    }
329}
330
331impl std::fmt::Debug for PermissionHandler {
332    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
333        f.debug_struct("PermissionHandler").finish_non_exhaustive()
334    }
335}
336
337/// Configuration for [`DuplexSession::spawn`].
338///
339/// Builder methods cover the spawn-time options shared with
340/// [`QueryCommand`](crate::QueryCommand); the flag emission lives on a
341/// common internal `SharedSpawnArgs`, so the oneshot and duplex paths
342/// cannot drift on how a knob is rendered. The spawn call always
343/// includes
344/// `--print --verbose --input-format stream-json --output-format stream-json`
345/// regardless of these options.
346///
347/// A few `QueryCommand` knobs are intentionally not surfaced here
348/// because they only make sense for a oneshot run or are owned by the
349/// duplex transport itself:
350///
351/// - Transport is fixed: `output_format`, `input_format`,
352///   `include_partial_messages`, `verbose`, and `prompt_via_stdin` are
353///   pinned by the duplex spawn and not configurable.
354/// - `retry_policy` reruns a whole oneshot invocation; a duplex session
355///   holds one child open across turns, so there is nothing to retry at
356///   this layer.
357/// - `brief` and `from_pr` shape a single oneshot run (SendUserMessage
358///   for one-turn agent-to-user replies, resume-from-PR startup); a
359///   duplex host drives turns and session selection itself.
360/// - `prompt_suggestions` and `replay_user_messages` shape stdin/stream
361///   echoing; the duplex layer owns its own stream plumbing.
362///
363/// Use [`Self::arg`] if you need one of these on a duplex spawn anyway.
364#[derive(Debug, Default, Clone)]
365pub struct DuplexOptions {
366    // Spawn-time knobs shared with QueryCommand; the flag emission
367    // lives on SharedSpawnArgs so the two builders cannot drift.
368    shared: SharedSpawnArgs,
369    additional_args: Vec<String>,
370    subscriber_capacity: Option<usize>,
371    on_permission: Option<PermissionHandler>,
372}
373
374impl DuplexOptions {
375    /// Set the model for this session (`--model`).
376    #[must_use]
377    pub fn model(mut self, model: impl Into<String>) -> Self {
378        self.shared.model = Some(model.into());
379        self
380    }
381
382    /// Set the system prompt for this session (`--system-prompt`).
383    #[must_use]
384    pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
385        self.shared.system_prompt = Some(prompt.into());
386        self
387    }
388
389    /// Append to the default system prompt (`--append-system-prompt`).
390    #[must_use]
391    pub fn append_system_prompt(mut self, prompt: impl Into<String>) -> Self {
392        self.shared.append_system_prompt = Some(prompt.into());
393        self
394    }
395
396    /// Resume a prior session by id (`--resume <session_id>`).
397    ///
398    /// Mirrors [`QueryCommand::resume`](crate::QueryCommand::resume)
399    /// for the duplex path. The spawned `claude` process picks up the
400    /// conversation that produced `session_id` and continues it; turns
401    /// sent through [`DuplexSession::send`] append to the existing
402    /// history rather than starting fresh.
403    ///
404    /// Use case: a host (IDE, MCP server, agent backend) wants to
405    /// upgrade a passive on-disk session to a live duplex one --
406    /// pulls the `session_id` out of the existing JSONL log, opens a
407    /// duplex session here, and the next turn extends the same
408    /// conversation.
409    ///
410    /// `resume` and [`Self::continue_session`] are mutually exclusive
411    /// at the CLI; passing both lets the CLI decide (it errors today).
412    #[must_use]
413    pub fn resume(mut self, session_id: impl Into<String>) -> Self {
414        self.shared.resume = Some(session_id.into());
415        self
416    }
417
418    /// Continue the most recent session in the current working
419    /// directory (`--continue`).
420    ///
421    /// Mirrors [`QueryCommand::continue_session`](crate::QueryCommand::continue_session)
422    /// for the duplex path. Use [`Self::resume`] to pick a specific
423    /// session id; use this when "the last one" is what you want.
424    #[must_use]
425    pub fn continue_session(mut self) -> Self {
426        self.shared.continue_session = true;
427        self
428    }
429
430    /// Run this session in a fresh git worktree (`--worktree [name]`).
431    ///
432    /// `name` is the optional worktree name (the CLI auto-generates
433    /// one if omitted). Calling this method always enables the
434    /// worktree flag, with or without a name.
435    ///
436    /// Use case: an agent host wants the chat's writes isolated from
437    /// the current working tree -- the chat opens with a fresh
438    /// worktree, mutations land there, and the host can inspect or
439    /// merge later.
440    #[must_use]
441    pub fn worktree(mut self, name: Option<impl Into<String>>) -> Self {
442        self.shared.worktree = true;
443        if let Some(n) = name {
444            self.shared.worktree_name = Some(n.into());
445        }
446        self
447    }
448
449    /// Pin the session to a named subagent (`--agent <name>`).
450    ///
451    /// `name` is resolved by the CLI in this order: inline
452    /// definitions from [`Self::agents_json`], then user-level
453    /// `~/.claude/agents/<name>.md` files, then project-level dirs
454    /// loaded by the active `--setting-sources`.
455    ///
456    /// **Caveat**: as of Claude Code 2.1.143, the CLI silently
457    /// ignores an unknown `name` and falls back to the default
458    /// behavior -- no warning, no error. Callers that want a hard
459    /// "agent must exist" semantics should validate the name out of
460    /// band (e.g. via [`crate::artifacts::AgentsRoot::get`]) before
461    /// passing it here.
462    #[must_use]
463    pub fn agent(mut self, name: impl Into<String>) -> Self {
464        self.shared.agent = Some(name.into());
465        self
466    }
467
468    /// Inline subagent definitions for this session
469    /// (`--agents <json>`).
470    ///
471    /// `json` is a JSON object keyed by agent name, with each value
472    /// carrying at least `description` and `prompt`. Inline
473    /// definitions take precedence over on-disk
474    /// `~/.claude/agents/*.md` of the same name. Pass [`Self::agent`]
475    /// to select which one to use as the session's persona.
476    ///
477    /// Example: `{"reviewer": {"description": "Reviews code",
478    /// "prompt": "You are a code reviewer"}}`.
479    #[must_use]
480    pub fn agents_json(mut self, json: impl Into<String>) -> Self {
481        self.shared.agents_json = Some(json.into());
482        self
483    }
484
485    /// Set the permission mode for this session
486    /// (`--permission-mode <mode>`).
487    ///
488    /// Mirrors [`QueryCommand::permission_mode`](crate::QueryCommand::permission_mode)
489    /// for the duplex path. The default mode (when this method isn't
490    /// called) drops to the CLI's interactive prompt for every
491    /// tool-use approval, which is broken for non-interactive duplex
492    /// sessions -- nothing answers the prompts and the session stalls
493    /// or fails. Call this with [`PermissionMode::AcceptEdits`] for
494    /// the "edit files autonomously" pattern, [`PermissionMode::Plan`]
495    /// for read-only planning, etc.
496    ///
497    /// Bypass mode is a footgun; reach for [`Self::dangerously_skip_permissions`]
498    /// (or, for stricter discipline, [`crate::dangerous::DangerousClient`])
499    /// when you really need it.
500    #[must_use]
501    pub fn permission_mode(mut self, mode: PermissionMode) -> Self {
502        self.shared.permission_mode = Some(mode);
503        self
504    }
505
506    /// Forward nested subagent text and thinking as stream-json messages
507    /// (`--forward-subagent-text`). Parent tool-use IDs remain in the raw events.
508    /// Only meaningful with stream-json output; duplex sessions already use it.
509    #[must_use]
510    pub fn forward_subagent_text(mut self) -> Self {
511        self.shared.forward_subagent_text = true;
512        self
513    }
514
515    /// Make bypass mode available without activating it
516    /// (`--allow-dangerously-skip-permissions`). This does not select a bypass
517    /// permission mode or disable permission checks by itself.
518    #[must_use]
519    pub fn allow_dangerously_skip_permissions(mut self) -> Self {
520        self.shared.allow_dangerously_skip_permissions = true;
521        self
522    }
523
524    /// Pass `--dangerously-skip-permissions` to the spawned session.
525    ///
526    /// Bypasses ALL permission checks -- file edits, bash, network,
527    /// the lot. Use only when you know the session runs in a trusted
528    /// sandbox (a fresh worktree, a container, etc.). For most "run
529    /// autonomously" cases you want [`Self::permission_mode`] with
530    /// [`PermissionMode::AcceptEdits`] instead.
531    #[must_use]
532    pub fn dangerously_skip_permissions(mut self) -> Self {
533        self.shared.dangerously_skip_permissions = true;
534        self
535    }
536
537    /// Start a new session under a caller-chosen id
538    /// (`--session-id <uuid>`).
539    ///
540    /// Mirrors [`QueryCommand::session_id`](crate::QueryCommand::session_id)
541    /// for the duplex path. Unlike [`Self::resume`] (pick up an
542    /// existing session) or [`Self::continue_session`] (pick up the
543    /// most recent one), this mints a fresh session whose id the host
544    /// knows up front -- useful when the host indexes sessions
545    /// externally before the first turn completes.
546    #[must_use]
547    pub fn session_id(mut self, id: impl Into<String>) -> Self {
548        self.shared.session_id = Some(id.into());
549        self
550    }
551
552    /// Set a JSON schema for structured output validation
553    /// (`--json-schema <schema>`).
554    ///
555    /// Mirrors [`QueryCommand::json_schema`](crate::QueryCommand::json_schema)
556    /// for the duplex path. `schema` is the inline JSON of the schema;
557    /// the turn's closing result message carries the validated
558    /// `structured_output`.
559    #[must_use]
560    pub fn json_schema(mut self, schema: impl Into<String>) -> Self {
561        self.shared.json_schema = Some(schema.into());
562        self
563    }
564
565    /// Add allowed tool patterns (`--allowed-tools`).
566    ///
567    /// Mirrors [`QueryCommand::allowed_tools`](crate::QueryCommand::allowed_tools)
568    /// for the duplex path: accepts anything convertible into
569    /// [`ToolPattern`], including bare strings (e.g. `"Bash"`,
570    /// `"Bash(git log:*)"`, `"mcp__my-server__*"`), and joins them
571    /// into the comma-separated form the CLI expects.
572    #[must_use]
573    pub fn allowed_tools<I, T>(mut self, tools: I) -> Self
574    where
575        I: IntoIterator<Item = T>,
576        T: Into<ToolPattern>,
577    {
578        self.shared
579            .allowed_tools
580            .extend(tools.into_iter().map(Into::into));
581        self
582    }
583
584    /// Add a single allowed tool pattern.
585    #[must_use]
586    pub fn allowed_tool(mut self, tool: impl Into<ToolPattern>) -> Self {
587        self.shared.allowed_tools.push(tool.into());
588        self
589    }
590
591    /// Add disallowed tool patterns (`--disallowed-tools`).
592    #[must_use]
593    pub fn disallowed_tools<I, T>(mut self, tools: I) -> Self
594    where
595        I: IntoIterator<Item = T>,
596        T: Into<ToolPattern>,
597    {
598        self.shared
599            .disallowed_tools
600            .extend(tools.into_iter().map(Into::into));
601        self
602    }
603
604    /// Add a single disallowed tool pattern.
605    #[must_use]
606    pub fn disallowed_tool(mut self, tool: impl Into<ToolPattern>) -> Self {
607        self.shared.disallowed_tools.push(tool.into());
608        self
609    }
610
611    /// Cap the number of agentic turns (`--max-turns <n>`).
612    ///
613    /// The cap applies per turn sent through [`DuplexSession::send`];
614    /// a turn that exhausts it closes with an `error_max_turns`
615    /// result rather than an assistant reply.
616    #[must_use]
617    pub fn max_turns(mut self, turns: u32) -> Self {
618        self.shared.max_turns = Some(turns);
619        self
620    }
621
622    /// Cap claude's own spend for the session
623    /// (`--max-budget-usd <usd>`).
624    ///
625    /// This is the CLI's cap, checked post-hoc after each API call,
626    /// so a session can overspend before tripping. It is distinct
627    /// from the wrapper's [`BudgetTracker`](crate::budget::BudgetTracker)
628    /// ceiling, which gates dispatch host-side -- attach one via
629    /// [`Conversation::with_budget`](crate::conversation::Conversation::with_budget)
630    /// to stop a duplex conversation before the next turn is sent.
631    #[must_use]
632    pub fn max_budget_usd(mut self, budget: f64) -> Self {
633        self.shared.max_budget_usd = Some(budget);
634        self
635    }
636
637    /// Set a fallback model for when the primary is overloaded
638    /// (`--fallback-model <model>`).
639    #[must_use]
640    pub fn fallback_model(mut self, model: impl Into<String>) -> Self {
641        self.shared.fallback_model = Some(model.into());
642        self
643    }
644
645    /// Set the reasoning effort level (`--effort <level>`).
646    #[must_use]
647    pub fn effort(mut self, effort: Effort) -> Self {
648        self.shared.effort = Some(effort);
649        self
650    }
651
652    /// Add an additional directory for tool access
653    /// (`--add-dir <dir>`, repeatable).
654    #[must_use]
655    pub fn add_dir(mut self, dir: impl Into<String>) -> Self {
656        self.shared.add_dir.push(dir.into());
657        self
658    }
659
660    /// Add an MCP config file path (`--mcp-config <path>`,
661    /// repeatable).
662    ///
663    /// Pair with [`crate::McpConfigBuilder`] to generate the file.
664    #[must_use]
665    pub fn mcp_config(mut self, path: impl Into<String>) -> Self {
666        self.shared.mcp_config.push(path.into());
667        self
668    }
669
670    /// Only use MCP servers from `--mcp-config` files, ignoring the
671    /// user- and project-level MCP configuration
672    /// (`--strict-mcp-config`).
673    #[must_use]
674    pub fn strict_mcp_config(mut self) -> Self {
675        self.shared.strict_mcp_config = true;
676        self
677    }
678
679    /// Comma-separated list of setting sources the CLI loads, for example
680    /// `"user,project,local"` (`--setting-sources`). Pass an empty string to
681    /// load none, sealing the session's promptspace against ambient project
682    /// config (agents, skills, `CLAUDE.md`). Mirrors
683    /// [`QueryCommand::setting_sources`](crate::QueryCommand::setting_sources).
684    #[must_use]
685    pub fn setting_sources(mut self, sources: impl Into<String>) -> Self {
686        self.shared.setting_sources = Some(sources.into());
687        self
688    }
689
690    /// Seal the ambient `~/.claude` config for a reproducible session
691    /// ([`HermeticScope::Full`]).
692    ///
693    /// Sets `--setting-sources ""`, `--strict-mcp-config`, and
694    /// `--exclude-dynamic-system-prompt-sections`. This gives a warm
695    /// duplex session a clean seal without the [`Self::arg`] escape
696    /// hatch. Mirrors
697    /// [`QueryCommand::hermetic`](crate::QueryCommand::hermetic).
698    ///
699    /// This is not [`Self::bare`]: a hermetic seal leaves OAuth and
700    /// keychain auth working, whereas `--bare` forces API-key billing.
701    /// A later [`Self::setting_sources`] call overrides the seal scope.
702    #[must_use]
703    pub fn hermetic(mut self) -> Self {
704        self.shared.apply_hermetic(HermeticScope::Full);
705        self
706    }
707
708    /// Seal the ambient `~/.claude` config at an explicit
709    /// [`HermeticScope`].
710    ///
711    /// See [`Self::hermetic`] for the flag set. Mirrors
712    /// [`QueryCommand::hermetic_scoped`](crate::QueryCommand::hermetic_scoped).
713    #[must_use]
714    pub fn hermetic_scoped(mut self, scope: HermeticScope) -> Self {
715        self.shared.apply_hermetic(scope);
716        self
717    }
718
719    /// Do not persist the session to on-disk history
720    /// (`--no-session-persistence`).
721    #[must_use]
722    pub fn no_session_persistence(mut self) -> Self {
723        self.shared.no_session_persistence = true;
724        self
725    }
726
727    /// Set the list of available built-in tools (`--tools`).
728    ///
729    /// Use `""` to disable all tools, `"default"` for all tools, or
730    /// specific tool names like `["Bash", "Edit", "Read"]`. This is
731    /// distinct from [`Self::allowed_tools`], which controls tool
732    /// permissions rather than which built-ins load. Mirrors
733    /// [`QueryCommand::tools`](crate::QueryCommand::tools).
734    #[must_use]
735    pub fn tools(mut self, tools: impl IntoIterator<Item = impl Into<String>>) -> Self {
736        self.shared.tools.extend(tools.into_iter().map(Into::into));
737        self
738    }
739
740    /// Add a file resource to download at startup (`--file`).
741    ///
742    /// Format: `file_id:relative_path` (e.g. `file_abc:doc.txt`).
743    /// Repeatable. Mirrors [`QueryCommand::file`](crate::QueryCommand::file).
744    #[must_use]
745    pub fn file(mut self, spec: impl Into<String>) -> Self {
746        self.shared.file.push(spec.into());
747        self
748    }
749
750    /// Path to a settings JSON file or a JSON string (`--settings`).
751    ///
752    /// Mirrors [`QueryCommand::settings`](crate::QueryCommand::settings).
753    #[must_use]
754    pub fn settings(mut self, settings: impl Into<String>) -> Self {
755        self.shared.settings = Some(settings.into());
756        self
757    }
758
759    /// When resuming, create a new session id instead of reusing the
760    /// original (`--fork-session`).
761    ///
762    /// Only meaningful alongside [`Self::resume`] or
763    /// [`Self::continue_session`]. Mirrors
764    /// [`QueryCommand::fork_session`](crate::QueryCommand::fork_session).
765    #[must_use]
766    pub fn fork_session(mut self) -> Self {
767        self.shared.fork_session = true;
768        self
769    }
770
771    /// Enable debug logging with an optional filter, e.g. `"api,hooks"`
772    /// (`--debug`). Mirrors
773    /// [`QueryCommand::debug_filter`](crate::QueryCommand::debug_filter).
774    #[must_use]
775    pub fn debug_filter(mut self, filter: impl Into<String>) -> Self {
776        self.shared.debug_filter = Some(filter.into());
777        self
778    }
779
780    /// Write debug logs to the given file path (`--debug-file`).
781    ///
782    /// Mirrors [`QueryCommand::debug_file`](crate::QueryCommand::debug_file).
783    #[must_use]
784    pub fn debug_file(mut self, path: impl Into<String>) -> Self {
785        self.shared.debug_file = Some(path.into());
786        self
787    }
788
789    /// Beta feature headers for API key authentication (`--betas`).
790    ///
791    /// Mirrors [`QueryCommand::betas`](crate::QueryCommand::betas).
792    #[must_use]
793    pub fn betas(mut self, betas: impl Into<String>) -> Self {
794        self.shared.betas = Some(betas.into());
795        self
796    }
797
798    /// Load plugins from the given directory for this session
799    /// (`--plugin-dir`). Repeatable. Mirrors
800    /// [`QueryCommand::plugin_dir`](crate::QueryCommand::plugin_dir).
801    #[must_use]
802    pub fn plugin_dir(mut self, dir: impl Into<String>) -> Self {
803        self.shared.plugin_dirs.push(dir.into());
804        self
805    }
806
807    /// Fetch a plugin `.zip` from a URL for this session only
808    /// (`--plugin-url`). Repeatable. Mirrors
809    /// [`QueryCommand::plugin_url`](crate::QueryCommand::plugin_url).
810    #[must_use]
811    pub fn plugin_url(mut self, url: impl Into<String>) -> Self {
812        self.shared.plugin_urls.push(url.into());
813        self
814    }
815
816    /// Create a tmux session for the worktree (`--tmux`).
817    ///
818    /// Mirrors [`QueryCommand::tmux`](crate::QueryCommand::tmux).
819    #[must_use]
820    pub fn tmux(mut self) -> Self {
821        self.shared.tmux = true;
822        self
823    }
824
825    /// Run in minimal mode (`--bare`).
826    ///
827    /// Skips hooks, LSP, plugin sync, attribution, auto-memory,
828    /// background prefetches, keychain reads, and `CLAUDE.md`
829    /// auto-discovery. Anthropic auth is restricted to
830    /// `ANTHROPIC_API_KEY` or `apiKeyHelper`; OAuth and keychain are
831    /// never read. Mirrors [`QueryCommand::bare`](crate::QueryCommand::bare).
832    #[must_use]
833    pub fn bare(mut self) -> Self {
834        self.shared.bare = true;
835        self
836    }
837
838    /// Start with all customizations disabled (`--safe-mode`).
839    ///
840    /// Disables `CLAUDE.md`, skills, plugins, hooks, MCP servers,
841    /// custom commands and agents, and output styles for
842    /// troubleshooting. Mirrors
843    /// [`QueryCommand::safe_mode`](crate::QueryCommand::safe_mode).
844    #[must_use]
845    pub fn safe_mode(mut self) -> Self {
846        self.shared.safe_mode = true;
847        self
848    }
849
850    /// Disable all slash-command skills (`--disable-slash-commands`).
851    ///
852    /// Mirrors
853    /// [`QueryCommand::disable_slash_commands`](crate::QueryCommand::disable_slash_commands).
854    #[must_use]
855    pub fn disable_slash_commands(mut self) -> Self {
856        self.shared.disable_slash_commands = true;
857        self
858    }
859
860    /// Include every hook lifecycle event in the stream-json output
861    /// (`--include-hook-events`).
862    ///
863    /// Duplex sessions always run in stream-json, so this takes effect
864    /// without extra configuration. Mirrors
865    /// [`QueryCommand::include_hook_events`](crate::QueryCommand::include_hook_events).
866    #[must_use]
867    pub fn include_hook_events(mut self) -> Self {
868        self.shared.include_hook_events = true;
869        self
870    }
871
872    /// Move per-machine sections (cwd, env info, memory paths, git
873    /// status) out of the system prompt and into the first user
874    /// message (`--exclude-dynamic-system-prompt-sections`).
875    ///
876    /// Improves cross-user prompt-cache reuse. Only applies with the
877    /// default system prompt; ignored with [`Self::system_prompt`].
878    /// Mirrors
879    /// [`QueryCommand::exclude_dynamic_system_prompt_sections`](crate::QueryCommand::exclude_dynamic_system_prompt_sections).
880    #[must_use]
881    pub fn exclude_dynamic_system_prompt_sections(mut self) -> Self {
882        self.shared.exclude_dynamic_system_prompt_sections = true;
883        self
884    }
885
886    /// Set a display name for this session (`--name`). Shown in the
887    /// `/resume` picker and terminal title. Mirrors
888    /// [`QueryCommand::name`](crate::QueryCommand::name).
889    #[must_use]
890    pub fn name(mut self, name: impl Into<String>) -> Self {
891        self.shared.name = Some(name.into());
892        self
893    }
894
895    /// Add a raw argument to the spawn command line.
896    ///
897    /// Escape hatch for flags not covered by the dedicated builder
898    /// methods.
899    #[must_use]
900    pub fn arg(mut self, arg: impl Into<String>) -> Self {
901        self.additional_args.push(arg.into());
902        self
903    }
904
905    /// Set the per-session [`broadcast::Sender`] capacity backing
906    /// [`DuplexSession::subscribe`].
907    ///
908    /// Defaults to [`DEFAULT_SUBSCRIBER_CAPACITY`] (256). Larger
909    /// values give slow subscribers more room before they
910    /// [`Lagged`](tokio::sync::broadcast::error::RecvError::Lagged);
911    /// smaller values reclaim memory if you do not subscribe.
912    #[must_use]
913    pub fn subscriber_capacity(mut self, capacity: usize) -> Self {
914        self.subscriber_capacity = Some(capacity);
915        self
916    }
917
918    /// Register a [`PermissionHandler`] to answer the CLI's tool-use
919    /// permission prompts in-flight.
920    ///
921    /// When set, the spawn command line includes
922    /// `--permission-prompt-tool stdio`, which configures the CLI to
923    /// emit `control_request` messages for tool use over the duplex
924    /// channel rather than blocking on a TUI prompt.
925    ///
926    /// Without a handler, the session does not pass
927    /// `--permission-prompt-tool` and the CLI applies its default
928    /// permission policy (driven by `--permission-mode`).
929    ///
930    /// Runs only when the CLI's permission rules request a decision. Allowed
931    /// tools or permissive modes may resolve an action without calling it.
932    /// Configure an explicit ask rule through [`Self::settings`] for actions
933    /// that should reach the callback. The resident transport keeps stdin open;
934    /// native denial and deferred-denial are tested at both supported endpoints.
935    #[must_use]
936    pub fn on_permission(mut self, handler: PermissionHandler) -> Self {
937        self.on_permission = Some(handler);
938        self
939    }
940
941    fn build_args(&self) -> Vec<String> {
942        let mut args = vec![
943            "--print".to_string(),
944            "--verbose".to_string(),
945            "--output-format".to_string(),
946            "stream-json".to_string(),
947            "--input-format".to_string(),
948            "stream-json".to_string(),
949        ];
950
951        self.shared.append_to(&mut args);
952
953        if self.on_permission.is_some() {
954            args.push("--permission-prompt-tool".to_string());
955            args.push("stdio".to_string());
956        }
957        args.extend(self.additional_args.iter().cloned());
958
959        args
960    }
961
962    /// Assemble the exact argv [`DuplexSession::spawn`] passes to the
963    /// CLI binary: the client's global args followed by this option
964    /// set's flags. Single assembly path shared by the spawn and
965    /// [`Self::to_command_string`], so the preview cannot drift from
966    /// what actually runs.
967    fn spawn_command_args(&self, claude: &Claude) -> Vec<String> {
968        let mut args = claude.global_args.clone();
969        args.extend(self.build_args());
970        args
971    }
972
973    /// Return the full spawn command as a string that could be run in
974    /// a shell.
975    ///
976    /// The duplex analog of
977    /// [`QueryCommand::to_command_string`](crate::QueryCommand::to_command_string):
978    /// the binary path from the [`Claude`] client plus the exact
979    /// arguments [`DuplexSession::spawn`] would pass for these
980    /// options, including the client's global args. Both share one
981    /// args-assembly path, so this preview always matches the real
982    /// spawn. Arguments containing spaces or special shell characters
983    /// are shell-quoted.
984    ///
985    /// # Example
986    ///
987    /// ```no_run
988    /// use claude_wrapper::{Claude, DuplexOptions};
989    ///
990    /// # fn example() -> claude_wrapper::Result<()> {
991    /// let claude = Claude::builder().build()?;
992    ///
993    /// let opts = DuplexOptions::default()
994    ///     .agent("reviewer")
995    ///     .setting_sources("project");
996    ///
997    /// println!("Would spawn: {}", opts.to_command_string(&claude));
998    /// # Ok(())
999    /// # }
1000    /// ```
1001    #[must_use]
1002    pub fn to_command_string(&self, claude: &Claude) -> String {
1003        let args = self.spawn_command_args(claude);
1004        let quoted_args = args.iter().map(|arg| shell_quote(arg)).collect::<Vec<_>>();
1005        format!("{} {}", claude.binary().display(), quoted_args.join(" "))
1006    }
1007}
1008
1009/// The result of one turn through a [`DuplexSession`].
1010///
1011/// `result` is the raw JSON of the `{"type": "result", ...}` message
1012/// that closed the turn. `events` carries every other message
1013/// received during the turn (system, assistant, stream_event, user)
1014/// in arrival order, with the closing `result` excluded.
1015#[derive(Debug, Clone)]
1016pub struct TurnResult {
1017    /// The raw `{"type": "result", ...}` message that ended the turn.
1018    pub result: Value,
1019    /// Every other message received during the turn, in order.
1020    pub events: Vec<Value>,
1021}
1022
1023impl TurnResult {
1024    /// Extract `result.result` as a string, if present.
1025    #[must_use]
1026    pub fn result_text(&self) -> Option<&str> {
1027        self.result.get("result").and_then(Value::as_str)
1028    }
1029
1030    /// Extract `result.session_id`, if present.
1031    #[must_use]
1032    pub fn session_id(&self) -> Option<&str> {
1033        self.result.get("session_id").and_then(Value::as_str)
1034    }
1035
1036    /// Extract `total_cost_usd` (preferred) or the legacy `cost_usd`
1037    /// field, if either is present.
1038    ///
1039    /// This is the CLI's running total for the session, not the cost
1040    /// of this turn: each `result` message reports everything spent
1041    /// so far, earlier turns included. When the session was spawned
1042    /// with [`DuplexOptions::resume`] or
1043    /// [`DuplexOptions::continue_session`], the CLI restores the
1044    /// resumed session's total, so the figure also covers spend from
1045    /// before this process started.
1046    ///
1047    /// A turn's own cost is the change from the previous turn's
1048    /// total. [`Conversation`](crate::conversation::Conversation)
1049    /// records that change for each turn and keeps the cumulative
1050    /// figure.
1051    #[must_use]
1052    pub fn total_cost_usd(&self) -> Option<f64> {
1053        self.result
1054            .get("total_cost_usd")
1055            .or_else(|| self.result.get("cost_usd"))
1056            .and_then(Value::as_f64)
1057    }
1058
1059    /// Extract `duration_ms`, if present.
1060    #[must_use]
1061    pub fn duration_ms(&self) -> Option<u64> {
1062        self.result.get("duration_ms").and_then(Value::as_u64)
1063    }
1064
1065    /// Extract `result.subtype`, if present: `"success"` for a turn
1066    /// that completed, or an error subtype such as
1067    /// `"error_during_execution"` for an interrupted turn.
1068    #[must_use]
1069    pub fn subtype(&self) -> Option<&str> {
1070        self.result.get("subtype").and_then(Value::as_str)
1071    }
1072
1073    /// Extract `result.is_error`. Returns `false` when the field is
1074    /// absent.
1075    #[must_use]
1076    pub fn is_error(&self) -> bool {
1077        self.result
1078            .get("is_error")
1079            .and_then(Value::as_bool)
1080            .unwrap_or(false)
1081    }
1082}
1083
1084/// Turn a reported running total into the cost of the turn that
1085/// reported it.
1086///
1087/// The CLI reports `total_cost_usd` as the running total for the
1088/// session, so a turn costs the difference from the previous total,
1089/// clamped at zero. `prev_total` is updated to `total`. A turn that
1090/// reports no total returns `None` and leaves `prev_total` alone.
1091pub(crate) fn turn_cost_delta(prev_total: &mut Option<f64>, total: Option<f64>) -> Option<f64> {
1092    let total = total?;
1093    let prev = prev_total.replace(total).unwrap_or(0.0);
1094    Some((total - prev).max(0.0))
1095}
1096
1097/// A classified inbound event broadcast to [`DuplexSession::subscribe`]
1098/// receivers.
1099///
1100/// Every non-`result` message coming back from the CLI is broadcast as
1101/// one of these variants. The closing `{"type": "result"}` message is
1102/// not broadcast; it resolves the in-flight [`DuplexSession::send`]
1103/// future and lands in [`TurnResult::result`].
1104///
1105/// Subscribers see the same set of events that accumulate in
1106/// [`TurnResult::events`], in the same order, just classified. Adding
1107/// a typed accessor for a new event type later (e.g. promoting a
1108/// `system` subtype into its own variant) is non-breaking against the
1109/// `Other` fallback.
1110#[derive(Debug, Clone)]
1111pub enum InboundEvent {
1112    /// First `{"type": "system", "subtype": "init"}` event for the
1113    /// session. Carries the CLI-assigned `session_id`.
1114    SystemInit {
1115        /// The CLI-assigned session id, useful for logging or
1116        /// future resume support.
1117        session_id: String,
1118    },
1119    /// `{"type": "assistant", ...}` -- either a complete assistant
1120    /// message or, in stream-json mode, a partial chunk.
1121    Assistant(Value),
1122    /// `{"type": "stream_event", ...}` -- low-level streaming event
1123    /// emitted while a turn is in progress.
1124    StreamEvent(Value),
1125    /// `{"type": "user", ...}` -- typically a tool result echo from
1126    /// the CLI side.
1127    User(Value),
1128    /// Any other event type, including non-`init` `system` events
1129    /// and any message types not yet recognised by this enum.
1130    Other(Value),
1131}
1132
1133fn classify(msg: &Value) -> InboundEvent {
1134    match msg.get("type").and_then(Value::as_str) {
1135        Some("system") => {
1136            if msg.get("subtype").and_then(Value::as_str) == Some("init")
1137                && let Some(id) = msg.get("session_id").and_then(Value::as_str)
1138            {
1139                return InboundEvent::SystemInit {
1140                    session_id: id.to_string(),
1141                };
1142            }
1143            InboundEvent::Other(msg.clone())
1144        }
1145        Some("assistant") => InboundEvent::Assistant(msg.clone()),
1146        Some("stream_event") => InboundEvent::StreamEvent(msg.clone()),
1147        Some("user") => InboundEvent::User(msg.clone()),
1148        _ => InboundEvent::Other(msg.clone()),
1149    }
1150}
1151
1152/// Liveness state of a [`DuplexSession`]'s background task.
1153///
1154/// Surfaced through [`DuplexSession::is_alive`],
1155/// [`DuplexSession::exit_status`], and
1156/// [`DuplexSession::wait_for_exit`] for service-shaped hosts that
1157/// want non-consuming visibility into whether a session is still
1158/// usable. The closing [`DuplexSession::close`] still returns the
1159/// full [`Result`] for the one caller that consumes the session.
1160///
1161/// `Failed` carries a `String` rather than the full
1162/// [`Error`] because the underlying watch channel requires `Clone`
1163/// and `Error` is not `Clone` (its `Io` variant wraps a non-`Clone`
1164/// `std::io::Error`). The full error remains available via
1165/// [`DuplexSession::close`].
1166#[derive(Debug, Clone)]
1167pub enum SessionExitStatus {
1168    /// The session task is still running.
1169    Running,
1170    /// The child was reaped with a zero exit status and no I/O error.
1171    Completed,
1172    /// The session task ended with an error. Carries the error's
1173    /// `Display` rendering.
1174    Failed(String),
1175}
1176
1177/// A long-lived `claude` subprocess in stream-json duplex mode.
1178///
1179/// Owns a background task that holds the child open, writes user
1180/// messages to its stdin, and reads NDJSON events from its stdout.
1181/// One turn at a time: calling [`Self::send`] while another turn is
1182/// in flight returns [`Error::DuplexTurnInFlight`].
1183///
1184/// See the [module docs](crate::duplex) for the full design.
1185#[derive(Debug)]
1186pub struct DuplexSession {
1187    outbound_tx: mpsc::UnboundedSender<OutboundMsg>,
1188    events_tx: broadcast::Sender<InboundEvent>,
1189    exit_rx: watch::Receiver<SessionExitStatus>,
1190    join: JoinHandle<Result<()>>,
1191}
1192
1193#[derive(Debug)]
1194enum OutboundMsg {
1195    Send {
1196        prompt: String,
1197        reply: oneshot::Sender<Result<TurnResult>>,
1198    },
1199    PermissionResponse {
1200        request_id: String,
1201        decision: PermissionDecision,
1202    },
1203    Interrupt {
1204        reply: oneshot::Sender<Result<()>>,
1205    },
1206}
1207
1208impl DuplexSession {
1209    /// Spawn a fresh `claude` subprocess in duplex mode.
1210    ///
1211    /// The child is started with
1212    /// `--print --verbose --input-format stream-json --output-format stream-json`
1213    /// plus any options applied via `opts`. The session task takes
1214    /// ownership of the child; dropping the returned handle (or
1215    /// calling [`Self::close`]) shuts the task down.
1216    pub async fn spawn(claude: &Claude, opts: DuplexOptions) -> Result<Self> {
1217        let capacity = opts
1218            .subscriber_capacity
1219            .unwrap_or(DEFAULT_SUBSCRIBER_CAPACITY);
1220        let permission_handler = opts.on_permission.clone();
1221
1222        let command_args = opts.spawn_command_args(claude);
1223
1224        debug!(
1225            arg_count = command_args.len(),
1226            "spawning duplex claude session"
1227        );
1228
1229        let mut cmd = Command::new(&claude.binary);
1230        cmd.args(&command_args)
1231            .stdin(Stdio::piped())
1232            .stdout(Stdio::piped())
1233            .stderr(Stdio::piped())
1234            .kill_on_drop(true);
1235        crate::exec::apply_child_environment(cmd.as_std_mut(), claude.clear_env, &claude.env);
1236        // Own process group (Unix) so shutdown can signal the whole
1237        // group, not just the direct child (see exec::GroupKillGuard).
1238        // Opt out via ClaudeBuilder::process_group.
1239        crate::exec::apply_process_group(&mut cmd, claude.process_group);
1240
1241        if let Some(ref dir) = claude.working_dir {
1242            cmd.current_dir(dir);
1243        }
1244
1245        let mut child = crate::exec::spawn_retrying_txtbsy(&mut cmd)
1246            .await
1247            .map_err(|e| Error::Io {
1248                message: format!("failed to spawn claude: {e}"),
1249                source: e,
1250                working_dir: claude.working_dir.clone(),
1251            })?;
1252        let group =
1253            crate::exec::arm_and_notify(claude.process_group, child.id(), claude.on_spawn.as_ref());
1254
1255        let stdin = child.stdin.take().expect("stdin was piped");
1256        let stdout = child.stdout.take().expect("stdout was piped");
1257
1258        let (outbound_tx, outbound_rx) = mpsc::unbounded_channel();
1259        let (events_tx, _initial_rx) = broadcast::channel(capacity);
1260        let (exit_tx, exit_rx) = watch::channel(SessionExitStatus::Running);
1261
1262        // A session-lifetime span, entered inside the spawned task. The
1263        // run loop interleaves events from every turn on one stdout
1264        // stream, so without this its lines ("dropping orphan result
1265        // event", parse failures, shutdown-budget kills) cannot be
1266        // attributed to a session at all when several are open.
1267        //
1268        // Hosts correlate spans using subscriber-owned context; native IDs
1269        // and configured model strings stay in explicit events/results.
1270        let session_span = tracing::debug_span!(
1271            "claude.session",
1272            model_configured = opts.shared.model.is_some(),
1273            permission_mode = opts
1274                .shared
1275                .permission_mode
1276                .as_ref()
1277                .map(|m| m.as_arg())
1278                .unwrap_or("default"),
1279            resumed = opts.shared.resume.is_some(),
1280            turns = tracing::field::Empty,
1281            exit = tracing::field::Empty,
1282        );
1283        let join = tokio::spawn(
1284            run_session(
1285                child,
1286                group,
1287                claude.kill_grace,
1288                claude.termination_signal,
1289                stdin,
1290                stdout,
1291                outbound_rx,
1292                events_tx.clone(),
1293                permission_handler,
1294                exit_tx,
1295            )
1296            .instrument(session_span),
1297        );
1298
1299        Ok(Self {
1300            outbound_tx,
1301            events_tx,
1302            exit_rx,
1303            join,
1304        })
1305    }
1306
1307    /// Send one user message and await the closing result event.
1308    ///
1309    /// Returns [`Error::DuplexTurnInFlight`] if another turn is
1310    /// already pending, and [`Error::DuplexClosed`] if the session
1311    /// task has already exited.
1312    /// `DuplexClosed` may arrive before final diagnostic collection and
1313    /// exit-state publication. Await [`Self::wait_for_exit`] for settled
1314    /// process evidence instead of relying on an immediate snapshot.
1315    ///
1316    /// The returned [`TurnResult`] holds this turn's events only, but
1317    /// its [`TurnResult::total_cost_usd`] is the session's running
1318    /// total. For per-turn and cumulative cost, history, and a budget
1319    /// ceiling, send through a
1320    /// [`Conversation`](crate::conversation::Conversation) instead.
1321    pub async fn send(&self, prompt: impl Into<String>) -> Result<TurnResult> {
1322        let (reply_tx, reply_rx) = oneshot::channel();
1323        self.outbound_tx
1324            .send(OutboundMsg::Send {
1325                prompt: prompt.into(),
1326                reply: reply_tx,
1327            })
1328            .map_err(|_| Error::DuplexClosed)?;
1329        reply_rx.await.map_err(|_| Error::DuplexClosed)?
1330    }
1331
1332    /// Subscribe to the session's classified inbound event stream.
1333    ///
1334    /// Returns a [`broadcast::Receiver<InboundEvent>`] that receives
1335    /// every non-`result` event as it arrives. Each subscriber gets
1336    /// its own buffered view; subscribers added later miss earlier
1337    /// events. Slow subscribers see
1338    /// [`RecvError::Lagged`](tokio::sync::broadcast::error::RecvError::Lagged)
1339    /// rather than blocking the session task.
1340    ///
1341    /// Subscribers see the same events that accumulate in
1342    /// [`TurnResult::events`], in the same order.
1343    ///
1344    /// # Example
1345    ///
1346    /// ```no_run
1347    /// use claude_wrapper::Claude;
1348    /// use claude_wrapper::duplex::{DuplexOptions, DuplexSession, InboundEvent};
1349    ///
1350    /// # async fn example() -> claude_wrapper::Result<()> {
1351    /// let claude = Claude::builder().build()?;
1352    /// let session = DuplexSession::spawn(&claude, DuplexOptions::default()).await?;
1353    /// let mut rx = session.subscribe();
1354    ///
1355    /// // Subscribe before send so we receive every event.
1356    /// let _turn = session.send("hello").await?;
1357    ///
1358    /// while let Ok(event) = rx.try_recv() {
1359    ///     if let InboundEvent::SystemInit { session_id } = event {
1360    ///         println!("session id: {session_id}");
1361    ///     }
1362    /// }
1363    /// # Ok(())
1364    /// # }
1365    /// ```
1366    #[must_use]
1367    pub fn subscribe(&self) -> broadcast::Receiver<InboundEvent> {
1368        self.events_tx.subscribe()
1369    }
1370
1371    /// Cheap, non-blocking liveness check.
1372    ///
1373    /// Returns `true` while the session task is running, `false` once
1374    /// it has exited (whether normally or with an error). Multiple
1375    /// concurrent callers are allowed, and the call does not consume
1376    /// the session: [`Self::close`] still works after polling.
1377    ///
1378    /// Reads the latest value from a `tokio::sync::watch` channel
1379    /// updated from inside the session task, so it never blocks and
1380    /// reflects state set just before the task returns.
1381    #[must_use]
1382    pub fn is_alive(&self) -> bool {
1383        matches!(*self.exit_rx.borrow(), SessionExitStatus::Running)
1384    }
1385
1386    /// Snapshot the session task's [`SessionExitStatus`].
1387    ///
1388    /// Returns [`SessionExitStatus::Running`] while the task is still
1389    /// alive, [`SessionExitStatus::Completed`] after a clean exit, or
1390    /// [`SessionExitStatus::Failed`] with the underlying error
1391    /// rendered to a string.
1392    ///
1393    /// Like [`Self::is_alive`], this is a cheap non-blocking read.
1394    #[must_use]
1395    pub fn exit_status(&self) -> SessionExitStatus {
1396        self.exit_rx.borrow().clone()
1397    }
1398
1399    /// Block until the session task transitions out of
1400    /// [`SessionExitStatus::Running`] and return the terminal status.
1401    ///
1402    /// Returns immediately if the task has already exited. Multiple
1403    /// concurrent callers are supported (each gets its own receiver
1404    /// clone), and the call does not consume the session.
1405    ///
1406    /// If the underlying watch sender is dropped without ever
1407    /// publishing a terminal state -- which should not happen in
1408    /// practice, but is treated defensively -- this returns the last
1409    /// observed value.
1410    pub async fn wait_for_exit(&self) -> SessionExitStatus {
1411        let mut rx = self.exit_rx.clone();
1412        loop {
1413            {
1414                let value = rx.borrow_and_update();
1415                if !matches!(*value, SessionExitStatus::Running) {
1416                    return value.clone();
1417                }
1418            }
1419            if rx.changed().await.is_err() {
1420                return rx.borrow().clone();
1421            }
1422        }
1423    }
1424
1425    /// Answer a deferred permission request from a different task.
1426    ///
1427    /// Use this after the [`PermissionHandler`] returned
1428    /// [`PermissionDecision::Defer`] for the matching `request_id`.
1429    /// Passing `decision = PermissionDecision::Defer` here is a
1430    /// no-op (logged at `warn`); pass `Allow` or `Deny`.
1431    ///
1432    /// Returns [`Error::DuplexClosed`] if the session task has
1433    /// already exited.
1434    ///
1435    /// # Example
1436    ///
1437    /// ```no_run
1438    /// use claude_wrapper::Claude;
1439    /// use claude_wrapper::duplex::{
1440    ///     DuplexOptions, DuplexSession, PermissionDecision, PermissionHandler,
1441    /// };
1442    /// use tokio::sync::mpsc;
1443    ///
1444    /// # async fn example() -> claude_wrapper::Result<()> {
1445    /// // Forward request_ids out to a UI thread; answer asynchronously.
1446    /// let (tx, _rx) = mpsc::unbounded_channel::<String>();
1447    /// let handler = PermissionHandler::new(move |req| {
1448    ///     let tx = tx.clone();
1449    ///     async move {
1450    ///         let _ = tx.send(req.request_id);
1451    ///         PermissionDecision::Defer
1452    ///     }
1453    /// });
1454    ///
1455    /// let claude = Claude::builder().build()?;
1456    /// let session = DuplexSession::spawn(
1457    ///     &claude,
1458    ///     DuplexOptions::default().on_permission(handler),
1459    /// ).await?;
1460    ///
1461    /// // ...later, from the UI thread:
1462    /// session.respond_to_permission(
1463    ///     "req-abc",
1464    ///     PermissionDecision::Allow { updated_input: None },
1465    /// )?;
1466    /// # Ok(())
1467    /// # }
1468    /// ```
1469    pub fn respond_to_permission(
1470        &self,
1471        request_id: impl Into<String>,
1472        decision: PermissionDecision,
1473    ) -> Result<()> {
1474        if matches!(decision, PermissionDecision::Defer) {
1475            warn!("respond_to_permission called with Defer; ignoring");
1476            return Ok(());
1477        }
1478        self.outbound_tx
1479            .send(OutboundMsg::PermissionResponse {
1480                request_id: request_id.into(),
1481                decision,
1482            })
1483            .map_err(|_| Error::DuplexClosed)?;
1484        Ok(())
1485    }
1486
1487    /// Send a clean interrupt to the CLI and wait for its
1488    /// acknowledgment.
1489    ///
1490    /// Writes a `control_request {subtype: "interrupt"}` and resolves
1491    /// when the matching `control_response` comes back. The
1492    /// in-flight turn (if any) closes shortly after with a truncated
1493    /// [`TurnResult`] -- the [`DuplexSession::send`] future for it
1494    /// resolves independently. Either ordering is possible; await
1495    /// both via `tokio::join!` if you care about both outcomes.
1496    ///
1497    /// Returns:
1498    /// - `Ok(())` when the CLI acknowledges with `subtype: "success"`.
1499    /// - [`Error::DuplexControlFailed`] when the CLI answers with an
1500    ///   error payload.
1501    /// - [`Error::DuplexClosed`] if the session task exited before
1502    ///   the response arrived.
1503    ///
1504    /// # Example
1505    ///
1506    /// ```no_run
1507    /// use std::time::Duration;
1508    /// use claude_wrapper::Claude;
1509    /// use claude_wrapper::duplex::{DuplexOptions, DuplexSession};
1510    ///
1511    /// # async fn example() -> claude_wrapper::Result<()> {
1512    /// let claude = Claude::builder().build()?;
1513    /// let session = DuplexSession::spawn(&claude, DuplexOptions::default()).await?;
1514    ///
1515    /// let send_fut = session.send("a question that triggers tool use");
1516    /// let interrupt_fut = async {
1517    ///     tokio::time::sleep(Duration::from_millis(250)).await;
1518    ///     session.interrupt().await
1519    /// };
1520    ///
1521    /// let (turn, interrupt) = tokio::join!(send_fut, interrupt_fut);
1522    /// let _truncated = turn?;
1523    /// interrupt?;
1524    /// # Ok(())
1525    /// # }
1526    /// ```
1527    pub async fn interrupt(&self) -> Result<()> {
1528        let (reply_tx, reply_rx) = oneshot::channel();
1529        self.outbound_tx
1530            .send(OutboundMsg::Interrupt { reply: reply_tx })
1531            .map_err(|_| Error::DuplexClosed)?;
1532        reply_rx.await.map_err(|_| Error::DuplexClosed)?
1533    }
1534
1535    /// Close the session and wait for the underlying task to exit.
1536    ///
1537    /// Drops the outbound channel sender, which the session task
1538    /// observes as `recv() -> None`, then closes stdin and reaps the
1539    /// child.
1540    ///
1541    /// Returns an error for a nonzero child exit, even if an earlier
1542    /// turn already delivered a successful result. Failed native exits
1543    /// return [`Error::DuplexProcessFailed`] with the native status, a
1544    /// bounded tail of stderr, and whether the shutdown budget expired.
1545    /// Semantic turn failures remain in [`TurnResult::result`].
1546    pub async fn close(self) -> Result<()> {
1547        drop(self.outbound_tx);
1548        drop(self.events_tx);
1549        match self.join.await {
1550            Ok(result) => result,
1551            Err(e) if e.is_cancelled() => Ok(()),
1552            Err(e) => Err(Error::Io {
1553                message: format!("duplex session task panicked: {e}"),
1554                source: std::io::Error::other(e.to_string()),
1555                working_dir: None,
1556            }),
1557        }
1558    }
1559}
1560
1561/// Time budget for the graceful child shutdown after the run loop
1562/// exits. If the child is still alive after this deadline, apply the
1563/// configured stop policy so close() does not hang on the subprocess.
1564const SHUTDOWN_BUDGET: Duration = Duration::from_secs(5);
1565
1566// Keep only the most recent diagnostic bytes; a resident process may run
1567// indefinitely and stderr is not a protocol line stream.
1568const STDERR_TAIL_BYTES: usize = 64 * 1024;
1569const STDERR_SETTLE_BUDGET: Duration = Duration::from_millis(200);
1570
1571async fn read_stderr_tail<R: tokio::io::AsyncRead + Unpin>(
1572    reader: &mut R,
1573    tail: &mut VecDeque<u8>,
1574) -> std::io::Result<()> {
1575    let mut chunk = [0u8; 8192];
1576    loop {
1577        let read = match reader.read(&mut chunk).await {
1578            Ok(0) => return Ok(()),
1579            Ok(read) => read,
1580            Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
1581            Err(error) => return Err(error),
1582        };
1583        let excess = (tail.len() + read).saturating_sub(STDERR_TAIL_BYTES);
1584        tail.drain(..excess);
1585        tail.extend(&chunk[..read]);
1586    }
1587}
1588
1589#[allow(clippy::too_many_arguments)]
1590async fn run_session(
1591    mut child: Child,
1592    group: crate::exec::GroupKillGuard,
1593    kill_grace: Option<Duration>,
1594    termination_signal: crate::TerminationSignal,
1595    stdin: ChildStdin,
1596    stdout: ChildStdout,
1597    outbound_rx: mpsc::UnboundedReceiver<OutboundMsg>,
1598    events_tx: broadcast::Sender<InboundEvent>,
1599    permission_handler: Option<PermissionHandler>,
1600    exit_tx: watch::Sender<SessionExitStatus>,
1601) -> Result<()> {
1602    let mut stderr = child.stderr.take().expect("stderr was piped");
1603    let mut tail = VecDeque::new();
1604    let (stderr_failed_tx, stderr_failed_rx) = oneshot::channel();
1605    // These futures share this scope. No detached reader task or unbounded
1606    // channel can survive session settlement or a dropped session task.
1607    let (status, stderr_result) = {
1608        let drain_stderr = async {
1609            let result = read_stderr_tail(&mut stderr, &mut tail).await;
1610            if result.is_err() {
1611                let _ = stderr_failed_tx.send(());
1612            }
1613            result
1614        };
1615        let session = run_session_child(
1616            child,
1617            group,
1618            kill_grace,
1619            termination_signal,
1620            stdin,
1621            stdout,
1622            outbound_rx,
1623            events_tx,
1624            permission_handler,
1625            stderr_failed_rx,
1626        );
1627        tokio::pin!(drain_stderr);
1628        tokio::pin!(session);
1629        tokio::select! {
1630            status = &mut session => {
1631                // A descendant can keep stderr open after the leader exits.
1632                // Retain available diagnostics without waiting forever for EOF.
1633                let read = tokio::time::timeout(
1634                    STDERR_SETTLE_BUDGET, &mut drain_stderr,
1635                ).await.ok();
1636                (status, read)
1637            }
1638            read = &mut drain_stderr => (session.await, Some(read)),
1639        }
1640    };
1641    let stderr = String::from_utf8_lossy(&tail.into_iter().collect::<Vec<_>>()).into_owned();
1642    let result = resident_outcome(status, stderr_result, stderr);
1643    let final_state = match &result {
1644        Ok(()) => SessionExitStatus::Completed,
1645        Err(error) => SessionExitStatus::Failed(error.to_string()),
1646    };
1647    tracing::Span::current().record(
1648        "exit",
1649        if result.is_ok() {
1650            "completed"
1651        } else {
1652            "failed"
1653        },
1654    );
1655    let _ = exit_tx.send(final_state);
1656    result
1657}
1658
1659fn resident_outcome(
1660    status: Result<(ExitStatus, bool)>,
1661    stderr_result: Option<std::io::Result<()>>,
1662    stderr: String,
1663) -> Result<()> {
1664    match (status, stderr_result) {
1665        (Err(error), _) => Err(error),
1666        (Ok((status, shutdown_budget_exceeded)), read) if !status.success() => {
1667            Err(Error::DuplexProcessFailed {
1668                status,
1669                shutdown_budget_exceeded,
1670                stderr,
1671                stderr_read_error: read.and_then(std::result::Result::err),
1672            })
1673        }
1674        (Ok(_), Some(Err(source))) => Err(Error::Io {
1675            message: format!("failed to read duplex stderr; retained diagnostics: {stderr}"),
1676            source,
1677            working_dir: None,
1678        }),
1679        (Ok(_), _) => Ok(()),
1680    }
1681}
1682
1683#[allow(clippy::too_many_arguments)]
1684async fn run_session_child(
1685    mut child: Child,
1686    mut group: crate::exec::GroupKillGuard,
1687    kill_grace: Option<Duration>,
1688    termination_signal: crate::TerminationSignal,
1689    mut stdin: ChildStdin,
1690    stdout: ChildStdout,
1691    mut outbound_rx: mpsc::UnboundedReceiver<OutboundMsg>,
1692    events_tx: broadcast::Sender<InboundEvent>,
1693    permission_handler: Option<PermissionHandler>,
1694    mut stderr_failed: oneshot::Receiver<()>,
1695) -> Result<(ExitStatus, bool)> {
1696    let mut lines = BufReader::new(stdout).lines();
1697    let mut pending: Option<(oneshot::Sender<Result<TurnResult>>, Vec<Value>)> = None;
1698    let mut pending_control: HashMap<String, oneshot::Sender<Result<()>>> = HashMap::new();
1699    let mut next_control_id: u64 = 0;
1700    let mut stream_err: Option<Error> = None;
1701    let mut stderr_failure_open = true;
1702    // The session span is this task's own span (see DuplexSession::spawn).
1703    let session_span = tracing::Span::current();
1704    let mut turns: u64 = 0;
1705    let mut turn_span: Option<tracing::Span> = None;
1706    let mut turn_started: Option<std::time::Instant> = None;
1707    // The CLI reports a running total on each result; the turn span
1708    // records the change from the previous one.
1709    let mut last_total_cost: Option<f64> = None;
1710
1711    loop {
1712        tokio::select! {
1713            biased;
1714
1715            line = lines.next_line() => match line {
1716                Ok(Some(l)) => {
1717                    if l.trim().is_empty() {
1718                        continue;
1719                    }
1720                    let parsed = match serde_json::from_str::<Value>(&l) {
1721                        Ok(v) => v,
1722                        Err(e) => {
1723                            debug!(line_bytes = l.len(), json_error = ?e.classify(), "failed to parse duplex event, skipping");
1724                            continue;
1725                        }
1726                    };
1727                    // Peek at the semantic outcome before handle_inbound consumes it.
1728                    if let Some("result") = parsed.get("type").and_then(Value::as_str) {
1729                        let total = parsed
1730                            .get("total_cost_usd")
1731                            .or_else(|| parsed.get("cost_usd"))
1732                            .and_then(Value::as_f64);
1733                        let cost = turn_cost_delta(&mut last_total_cost, total);
1734                        if let Some(span) = turn_span.take() {
1735                            span.record(
1736                                "is_error",
1737                                parsed.get("is_error").and_then(Value::as_bool).unwrap_or(false),
1738                            );
1739                            if let Some(sub) = parsed.get("subtype").and_then(Value::as_str) {
1740                                let label = match sub {
1741                                    "success" => "success",
1742                                    "error_during_execution" => "error_during_execution",
1743                                    "error_max_turns" => "error_max_turns",
1744                                    "error_max_budget_usd" => "error_max_budget_usd",
1745                                    _ => "other",
1746                                };
1747                                span.record("subtype", label);
1748                            }
1749                            if let (Some(c), Some(t)) = (cost, total) {
1750                                span.record("cost_usd", c);
1751                                span.record("total_cost_usd", t);
1752                            }
1753                            if let Some(started) = turn_started.take() {
1754                                span.record(
1755                                    "duration_ms",
1756                                    started.elapsed().as_millis() as u64,
1757                                );
1758                            }
1759                        }
1760                    }
1761                    match handle_inbound(parsed, &mut pending, &events_tx) {
1762                        InboundAction::None => {}
1763                        InboundAction::Permission(req) => {
1764                            let request_id = req.request_id.clone();
1765                            let decision = match permission_handler.as_ref() {
1766                                Some(h) => h.invoke(req).await,
1767                                None => {
1768                                    warn!("received can_use_tool with no permission handler; auto-denying");
1769                                    PermissionDecision::Deny {
1770                                        message:
1771                                            "no permission handler configured on duplex session"
1772                                                .into(),
1773                                    }
1774                                }
1775                            };
1776                            if matches!(decision, PermissionDecision::Defer) {
1777                                debug!(
1778                                    "permission handler deferred; waiting for respond_to_permission"
1779                                );
1780                            } else if write_permission_response(&mut stdin, &request_id, &decision).await.is_err() {
1781                                warn!("failed to write permission response");
1782                            }
1783                        }
1784                        InboundAction::ControlResponse { request_id, outcome } => {
1785                            if let Some(reply) = pending_control.remove(&request_id) {
1786                                let _ = reply.send(outcome);
1787                            } else {
1788                                debug!(
1789                                    "received control_response with no pending request"
1790                                );
1791                            }
1792                        }
1793                    }
1794                }
1795                Ok(None) => break,
1796                Err(e) => {
1797                    stream_err = Some(Error::Io {
1798                        message: "failed to read duplex stdout".to_string(),
1799                        source: e,
1800                        working_dir: None,
1801                    });
1802                    break;
1803                }
1804            },
1805
1806            failure = &mut stderr_failed, if stderr_failure_open => {
1807                stderr_failure_open = false;
1808                if failure.is_ok() {
1809                    break;
1810                }
1811            },
1812
1813            msg = outbound_rx.recv() => match msg {
1814                Some(OutboundMsg::Send { prompt, reply }) => {
1815                    if pending.is_some() {
1816                        let _ = reply.send(Err(Error::DuplexTurnInFlight));
1817                        continue;
1818                    }
1819                    if let Err(e) = write_user(&mut stdin, &prompt).await {
1820                        let _ = reply.send(Err(e));
1821                        continue;
1822                    }
1823                    turns += 1;
1824                    // One span per turn, so the events a turn produces are
1825                    // attributable to it rather than to the session as a
1826                    // whole. Outcome fields are recorded when the result
1827                    // event arrives above.
1828                    turn_span = Some(tracing::debug_span!(
1829                        parent: &session_span,
1830                        "claude.turn",
1831                        turn = turns,
1832                        is_error = tracing::field::Empty,
1833                        subtype = tracing::field::Empty,
1834                        cost_usd = tracing::field::Empty,
1835                        total_cost_usd = tracing::field::Empty,
1836                        duration_ms = tracing::field::Empty,
1837                    ));
1838                    turn_started = Some(std::time::Instant::now());
1839                    pending = Some((reply, Vec::new()));
1840                }
1841                Some(OutboundMsg::PermissionResponse { request_id, decision }) => {
1842                    if write_permission_response(&mut stdin, &request_id, &decision).await.is_err() {
1843                        warn!("failed to write deferred permission response");
1844                    }
1845                }
1846                Some(OutboundMsg::Interrupt { reply }) => {
1847                    next_control_id += 1;
1848                    let request_id = format!("interrupt-{next_control_id}");
1849                    if let Err(e) =
1850                        write_control_request(&mut stdin, &request_id, "interrupt").await
1851                    {
1852                        let _ = reply.send(Err(e));
1853                        continue;
1854                    }
1855                    pending_control.insert(request_id, reply);
1856                }
1857                None => break,
1858            },
1859        }
1860    }
1861
1862    drop(stdin);
1863    let mut stdout = lines.into_inner();
1864    // Stderr is drained by the enclosing future, including while this
1865    // loop awaits a permission handler or settles the child.
1866    let mut unused_stderr = tokio::io::empty();
1867    let waited = crate::exec::drain_until(
1868        tokio::time::timeout(SHUTDOWN_BUDGET, child.wait()),
1869        &mut stdout,
1870        &mut unused_stderr,
1871    )
1872    .await;
1873    let status = match waited {
1874        Ok(Ok(status)) => {
1875            group.disarm();
1876            Ok((status, false))
1877        }
1878        Ok(Err(source)) => {
1879            let error = Error::Io {
1880                message: "failed to wait for duplex child".into(),
1881                source,
1882                working_dir: None,
1883            };
1884            match crate::exec::drain_until(
1885                crate::exec::stop_and_reap(
1886                    &mut child,
1887                    &mut group,
1888                    kill_grace,
1889                    termination_signal,
1890                    None,
1891                ),
1892                &mut stdout,
1893                &mut unused_stderr,
1894            )
1895            .await
1896            {
1897                Ok(()) => Err(error),
1898                Err(cleanup) => Err(cleanup),
1899            }
1900        }
1901        Err(_) => {
1902            warn!("duplex child did not exit within shutdown budget; stopping");
1903            match crate::exec::drain_until(
1904                crate::exec::stop_and_reap(
1905                    &mut child,
1906                    &mut group,
1907                    kill_grace,
1908                    termination_signal,
1909                    None,
1910                ),
1911                &mut stdout,
1912                &mut unused_stderr,
1913            )
1914            .await
1915            {
1916                Ok(()) => child
1917                    .wait()
1918                    .await
1919                    .map(|status| (status, true))
1920                    .map_err(|source| Error::Io {
1921                        message: "failed to wait for stopped duplex child".into(),
1922                        source,
1923                        working_dir: None,
1924                    }),
1925                Err(error) => Err(error),
1926            }
1927        }
1928    };
1929
1930    if let Some((reply, _)) = pending.take() {
1931        let _ = reply.send(Err(Error::DuplexClosed));
1932    }
1933    for (_, reply) in pending_control.drain() {
1934        let _ = reply.send(Err(Error::DuplexClosed));
1935    }
1936
1937    session_span.record("turns", turns);
1938    match stream_err {
1939        Some(error) => Err(error),
1940        None => status,
1941    }
1942}
1943
1944/// Action returned from [`handle_inbound`] for the run loop to act
1945/// on after the side-effects (broadcast, accumulate, resolve) are
1946/// done.
1947enum InboundAction {
1948    /// No further action -- side-effects were all handled inline.
1949    None,
1950    /// A `control_request {subtype: "can_use_tool"}` was received and
1951    /// needs the [`PermissionHandler`] invoked. The run loop awaits
1952    /// the handler and writes the response.
1953    Permission(PermissionRequest),
1954    /// A `control_response` matching one of our outbound
1955    /// `control_request`s arrived. The run loop matches `request_id`
1956    /// against its `pending_control` table and resolves the
1957    /// corresponding oneshot.
1958    ControlResponse {
1959        request_id: String,
1960        outcome: Result<()>,
1961    },
1962}
1963
1964fn handle_inbound(
1965    msg: Value,
1966    pending: &mut Option<(oneshot::Sender<Result<TurnResult>>, Vec<Value>)>,
1967    events_tx: &broadcast::Sender<InboundEvent>,
1968) -> InboundAction {
1969    match msg.get("type").and_then(Value::as_str) {
1970        Some("result") => {
1971            if let Some((reply, events)) = pending.take() {
1972                let _ = reply.send(Ok(TurnResult {
1973                    result: msg,
1974                    events,
1975                }));
1976            } else {
1977                debug!("dropping orphan result event with no pending turn");
1978            }
1979            InboundAction::None
1980        }
1981        Some("control_request") => {
1982            // can_use_tool flows through the permission handler;
1983            // anything else is logged + accumulated as Other for now.
1984            if msg
1985                .get("request")
1986                .and_then(|r| r.get("subtype"))
1987                .and_then(Value::as_str)
1988                == Some("can_use_tool")
1989                && let Some(req) = parse_permission_request(&msg)
1990            {
1991                if let Some((_, events)) = pending.as_mut() {
1992                    events.push(msg);
1993                }
1994                return InboundAction::Permission(req);
1995            }
1996            debug!("received unhandled control_request; treating as Other");
1997            let _ = events_tx.send(InboundEvent::Other(msg.clone()));
1998            if let Some((_, events)) = pending.as_mut() {
1999                events.push(msg);
2000            }
2001            InboundAction::None
2002        }
2003        Some("control_response") => {
2004            if let Some((request_id, outcome)) = parse_control_response(&msg) {
2005                return InboundAction::ControlResponse {
2006                    request_id,
2007                    outcome,
2008                };
2009            }
2010            debug!("received malformed control_response; treating as Other");
2011            let _ = events_tx.send(InboundEvent::Other(msg.clone()));
2012            if let Some((_, events)) = pending.as_mut() {
2013                events.push(msg);
2014            }
2015            InboundAction::None
2016        }
2017        _ => {
2018            // Broadcast a classified copy. Send error means no
2019            // subscribers, which is fine -- subscribers are optional.
2020            let _ = events_tx.send(classify(&msg));
2021
2022            if let Some((_, events)) = pending.as_mut() {
2023                events.push(msg);
2024            } else {
2025                debug!("dropping inbound event with no pending turn");
2026            }
2027            InboundAction::None
2028        }
2029    }
2030}
2031
2032fn parse_permission_request(msg: &Value) -> Option<PermissionRequest> {
2033    let request_id = msg.get("request_id").and_then(Value::as_str)?;
2034    let request = msg.get("request")?;
2035    let tool_name = request.get("tool_name").and_then(Value::as_str)?;
2036    let input = request.get("input").cloned().unwrap_or(Value::Null);
2037    Some(PermissionRequest {
2038        request_id: request_id.to_string(),
2039        tool_name: tool_name.to_string(),
2040        input,
2041        raw: request.clone(),
2042    })
2043}
2044
2045/// Pull `(request_id, outcome)` out of a `control_response` envelope.
2046///
2047/// Returns `None` if `request_id` is missing or the subtype is
2048/// unrecognised. `Some((id, Ok(())))` for `subtype: "success"`,
2049/// `Some((id, Err(DuplexControlFailed)))` for `subtype: "error"`.
2050fn parse_control_response(msg: &Value) -> Option<(String, Result<()>)> {
2051    let response = msg.get("response")?;
2052    let request_id = response.get("request_id").and_then(Value::as_str)?;
2053    let outcome = match response.get("subtype").and_then(Value::as_str) {
2054        Some("success") => Ok(()),
2055        Some("error") => {
2056            let message = response
2057                .get("error")
2058                .and_then(Value::as_str)
2059                .unwrap_or("unknown control_response error")
2060                .to_string();
2061            Err(Error::DuplexControlFailed { message })
2062        }
2063        _ => return None,
2064    };
2065    Some((request_id.to_string(), outcome))
2066}
2067
2068async fn write_user(stdin: &mut ChildStdin, prompt: &str) -> Result<()> {
2069    let user_msg = serde_json::json!({
2070        "type": "user",
2071        "message": {
2072            "role": "user",
2073            "content": prompt,
2074        },
2075        "parent_tool_use_id": null,
2076    });
2077    write_line(stdin, &user_msg, "user message").await
2078}
2079
2080async fn write_control_request(
2081    stdin: &mut ChildStdin,
2082    request_id: &str,
2083    subtype: &str,
2084) -> Result<()> {
2085    let envelope = serde_json::json!({
2086        "type": "control_request",
2087        "request_id": request_id,
2088        "request": { "subtype": subtype },
2089    });
2090    write_line(stdin, &envelope, "control_request").await
2091}
2092
2093async fn write_permission_response(
2094    stdin: &mut ChildStdin,
2095    request_id: &str,
2096    decision: &PermissionDecision,
2097) -> Result<()> {
2098    let inner = match decision {
2099        PermissionDecision::Allow { updated_input } => {
2100            let mut obj = serde_json::Map::new();
2101            obj.insert("behavior".to_string(), Value::String("allow".to_string()));
2102            if let Some(input) = updated_input {
2103                obj.insert("updatedInput".to_string(), input.clone());
2104            }
2105            Value::Object(obj)
2106        }
2107        PermissionDecision::Deny { message } => serde_json::json!({
2108            "behavior": "deny",
2109            "message": message,
2110        }),
2111        PermissionDecision::Defer => {
2112            // Caller path is supposed to filter this; defensive guard.
2113            return Ok(());
2114        }
2115    };
2116    let envelope = serde_json::json!({
2117        "type": "control_response",
2118        "response": {
2119            "request_id": request_id,
2120            "subtype": "success",
2121            "response": inner,
2122        },
2123    });
2124    write_line(stdin, &envelope, "control_response").await
2125}
2126
2127async fn write_line(stdin: &mut ChildStdin, value: &Value, what: &'static str) -> Result<()> {
2128    let mut line = serde_json::to_string(value).map_err(|e| Error::Json {
2129        message: format!("failed to serialize duplex {what}"),
2130        source: e,
2131    })?;
2132    line.push('\n');
2133    stdin
2134        .write_all(line.as_bytes())
2135        .await
2136        .map_err(|e| Error::Io {
2137            message: format!("failed to write {what} to duplex stdin"),
2138            source: e,
2139            working_dir: None,
2140        })?;
2141    stdin.flush().await.map_err(|e| Error::Io {
2142        message: "failed to flush duplex stdin".to_string(),
2143        source: e,
2144        working_dir: None,
2145    })?;
2146    Ok(())
2147}
2148
2149#[cfg(test)]
2150mod tests {
2151    use super::*;
2152    use serde_json::json;
2153
2154    #[test]
2155    fn build_args_default_includes_required_flags() {
2156        let args = DuplexOptions::default().build_args();
2157        assert!(args.contains(&"--print".to_string()));
2158        assert!(args.contains(&"--verbose".to_string()));
2159        assert!(
2160            args.windows(2)
2161                .any(|w| w == ["--output-format", "stream-json"])
2162        );
2163        assert!(
2164            args.windows(2)
2165                .any(|w| w == ["--input-format", "stream-json"])
2166        );
2167    }
2168
2169    #[test]
2170    fn build_args_includes_model() {
2171        let args = DuplexOptions::default().model("haiku").build_args();
2172        assert!(args.windows(2).any(|w| w == ["--model", "haiku"]));
2173    }
2174
2175    #[test]
2176    fn build_args_includes_system_prompts() {
2177        let args = DuplexOptions::default()
2178            .system_prompt("be concise")
2179            .append_system_prompt("also polite")
2180            .build_args();
2181        assert!(
2182            args.windows(2)
2183                .any(|w| w == ["--system-prompt", "be concise"])
2184        );
2185        assert!(
2186            args.windows(2)
2187                .any(|w| w == ["--append-system-prompt", "also polite"])
2188        );
2189    }
2190
2191    #[test]
2192    fn build_args_appends_raw_args_last() {
2193        let args = DuplexOptions::default()
2194            .arg("--add-dir")
2195            .arg("/tmp/foo")
2196            .build_args();
2197        // Last two entries should be the additional args, in order.
2198        assert_eq!(&args[args.len() - 2..], &["--add-dir", "/tmp/foo"]);
2199    }
2200
2201    // ─── to_command_string / spawn parity (#703) ───
2202
2203    fn preview_claude() -> Claude {
2204        Claude::builder()
2205            .binary("/usr/local/bin/claude")
2206            .build()
2207            .unwrap()
2208    }
2209
2210    #[test]
2211    fn spawn_command_args_prepends_global_args() {
2212        let claude = Claude::builder()
2213            .binary("/usr/local/bin/claude")
2214            .arg("--debug")
2215            .build()
2216            .unwrap();
2217        let args = DuplexOptions::default()
2218            .model("haiku")
2219            .spawn_command_args(&claude);
2220        assert_eq!(args[0], "--debug");
2221        assert_eq!(args[1], "--print");
2222        assert!(args.windows(2).any(|w| w == ["--model", "haiku"]));
2223    }
2224
2225    #[test]
2226    fn to_command_string_is_binary_plus_spawn_args() {
2227        // The preview must be exactly the binary plus the args the
2228        // spawn path assembles (no arg here needs quoting).
2229        let claude = Claude::builder()
2230            .binary("/usr/local/bin/claude")
2231            .arg("--debug")
2232            .build()
2233            .unwrap();
2234        let opts = DuplexOptions::default().agent("reviewer");
2235        let expected = format!(
2236            "/usr/local/bin/claude {}",
2237            opts.spawn_command_args(&claude).join(" ")
2238        );
2239        assert_eq!(opts.to_command_string(&claude), expected);
2240    }
2241
2242    #[test]
2243    fn to_command_string_includes_persona_flags() {
2244        // Repro from #703: a spawn configured with an agent, allowed
2245        // tools, and setting sources must show all three in the echo.
2246        let command_str = DuplexOptions::default()
2247            .agent("reviewer")
2248            .allowed_tool("Read")
2249            .allowed_tool("Bash(git:*)")
2250            .setting_sources("project")
2251            .to_command_string(&preview_claude());
2252        assert!(command_str.starts_with("/usr/local/bin/claude"));
2253        assert!(command_str.contains("--agent reviewer"));
2254        assert!(command_str.contains("--allowed-tools 'Read,Bash(git:*)'"));
2255        assert!(command_str.contains("--setting-sources project"));
2256    }
2257
2258    #[test]
2259    fn to_command_string_quotes_args_with_spaces() {
2260        let command_str = DuplexOptions::default()
2261            .system_prompt("be concise")
2262            .to_command_string(&preview_claude());
2263        assert!(command_str.contains("--system-prompt 'be concise'"));
2264    }
2265
2266    #[test]
2267    fn to_command_string_does_not_consume_options() {
2268        // Borrowing preview: the same options remain usable after the
2269        // echo (previewed first, then handed to spawn).
2270        let claude = preview_claude();
2271        let opts = DuplexOptions::default().model("haiku");
2272        let first = opts.to_command_string(&claude);
2273        let second = opts.to_command_string(&claude);
2274        assert_eq!(first, second);
2275    }
2276
2277    #[test]
2278    fn build_args_includes_resume_when_set() {
2279        let args = DuplexOptions::default().resume("abc-123").build_args();
2280        assert!(args.windows(2).any(|w| w == ["--resume", "abc-123"]));
2281    }
2282
2283    #[test]
2284    fn build_args_omits_resume_by_default() {
2285        let args = DuplexOptions::default().build_args();
2286        assert!(
2287            !args.iter().any(|a| a == "--resume"),
2288            "--resume should not appear without an explicit resume(...) call; got {args:?}"
2289        );
2290    }
2291
2292    #[test]
2293    fn build_args_includes_continue_when_set() {
2294        let args = DuplexOptions::default().continue_session().build_args();
2295        assert!(args.iter().any(|a| a == "--continue"));
2296    }
2297
2298    #[test]
2299    fn build_args_omits_continue_by_default() {
2300        let args = DuplexOptions::default().build_args();
2301        assert!(!args.iter().any(|a| a == "--continue"));
2302    }
2303
2304    #[test]
2305    fn build_args_includes_worktree_flag_without_name() {
2306        let args = DuplexOptions::default().worktree(None::<&str>).build_args();
2307        assert!(args.iter().any(|a| a == "--worktree"));
2308        // No name means no positional follows --worktree.
2309        let pos = args.iter().position(|a| a == "--worktree").unwrap();
2310        assert!(
2311            args.get(pos + 1).is_none_or(|a| a.starts_with("--")),
2312            "--worktree without a name should not be followed by a positional; got {args:?}"
2313        );
2314    }
2315
2316    #[test]
2317    fn build_args_includes_worktree_flag_with_name() {
2318        let args = DuplexOptions::default()
2319            .worktree(Some("agent-xyz"))
2320            .build_args();
2321        let pos = args.iter().position(|a| a == "--worktree").unwrap();
2322        assert_eq!(args.get(pos + 1).map(String::as_str), Some("agent-xyz"));
2323    }
2324
2325    #[test]
2326    fn build_args_omits_worktree_by_default() {
2327        let args = DuplexOptions::default().build_args();
2328        assert!(
2329            !args.iter().any(|a| a == "--worktree"),
2330            "--worktree should not appear without an explicit worktree(...) call; got {args:?}"
2331        );
2332    }
2333
2334    #[test]
2335    fn worktree_lands_before_additional_args() {
2336        // Same `--` ordering bug class as resume.
2337        let args = DuplexOptions::default()
2338            .worktree(Some("foo"))
2339            .arg("--")
2340            .arg("trailing")
2341            .build_args();
2342        let wt_pos = args.iter().position(|a| a == "--worktree").unwrap();
2343        let dash_dash_pos = args.iter().position(|a| a == "--").unwrap();
2344        assert!(
2345            wt_pos < dash_dash_pos,
2346            "--worktree must precede `--` separator; got {args:?}"
2347        );
2348    }
2349
2350    #[test]
2351    fn build_args_includes_agent_when_set() {
2352        let args = DuplexOptions::default().agent("rust-qa").build_args();
2353        assert!(
2354            args.windows(2).any(|w| w == ["--agent", "rust-qa"]),
2355            "missing --agent rust-qa in {args:?}"
2356        );
2357    }
2358
2359    #[test]
2360    fn build_args_omits_agent_by_default() {
2361        let args = DuplexOptions::default().build_args();
2362        assert!(
2363            !args.iter().any(|a| a == "--agent"),
2364            "--agent should not appear without an explicit agent(...) call; got {args:?}"
2365        );
2366    }
2367
2368    #[test]
2369    fn build_args_includes_agents_json_when_set() {
2370        let json = r#"{"reviewer":{"description":"r","prompt":"p"}}"#;
2371        let args = DuplexOptions::default().agents_json(json).build_args();
2372        let pos = args.iter().position(|a| a == "--agents").unwrap();
2373        assert_eq!(args.get(pos + 1).map(String::as_str), Some(json));
2374    }
2375
2376    #[test]
2377    fn build_args_omits_agents_json_by_default() {
2378        let args = DuplexOptions::default().build_args();
2379        assert!(!args.iter().any(|a| a == "--agents"));
2380    }
2381
2382    #[test]
2383    fn agent_and_agents_json_compose() {
2384        let json = r#"{"reviewer":{"description":"r","prompt":"p"}}"#;
2385        let args = DuplexOptions::default()
2386            .agents_json(json)
2387            .agent("reviewer")
2388            .build_args();
2389        // Both flags present.
2390        assert!(args.iter().any(|a| a == "--agents"));
2391        assert!(args.iter().any(|a| a == "--agent"));
2392    }
2393
2394    #[test]
2395    fn agent_lands_before_additional_args() {
2396        let args = DuplexOptions::default()
2397            .agent("rust-qa")
2398            .arg("--")
2399            .arg("trailing")
2400            .build_args();
2401        let agent_pos = args.iter().position(|a| a == "--agent").unwrap();
2402        let dash_dash_pos = args.iter().position(|a| a == "--").unwrap();
2403        assert!(
2404            agent_pos < dash_dash_pos,
2405            "--agent must precede `--` separator; got {args:?}"
2406        );
2407    }
2408
2409    #[test]
2410    fn agents_json_lands_before_additional_args() {
2411        let args = DuplexOptions::default()
2412            .agents_json("{}")
2413            .arg("--")
2414            .arg("trailing")
2415            .build_args();
2416        let agents_pos = args.iter().position(|a| a == "--agents").unwrap();
2417        let dash_dash_pos = args.iter().position(|a| a == "--").unwrap();
2418        assert!(
2419            agents_pos < dash_dash_pos,
2420            "--agents must precede `--` separator; got {args:?}"
2421        );
2422    }
2423
2424    // -- QueryCommand knob-set parity (#672) -------------------------
2425
2426    #[test]
2427    fn build_args_includes_session_id() {
2428        let args = DuplexOptions::default().session_id("sid-9").build_args();
2429        assert!(args.windows(2).any(|w| w == ["--session-id", "sid-9"]));
2430    }
2431
2432    #[test]
2433    fn build_args_includes_setting_sources() {
2434        let args = DuplexOptions::default()
2435            .setting_sources("user,project")
2436            .build_args();
2437        assert!(
2438            args.windows(2)
2439                .any(|w| w == ["--setting-sources", "user,project"]),
2440            "got {args:?}"
2441        );
2442    }
2443
2444    #[test]
2445    fn build_args_omits_setting_sources_by_default() {
2446        let args = DuplexOptions::default().build_args();
2447        assert!(!args.iter().any(|a| a == "--setting-sources"));
2448    }
2449
2450    #[test]
2451    fn build_args_hermetic_emits_full_seal() {
2452        let args = DuplexOptions::default().hermetic().build_args();
2453        assert!(
2454            args.windows(2)
2455                .any(|w| w[0] == "--setting-sources" && w[1].is_empty()),
2456            "got {args:?}"
2457        );
2458        assert!(args.iter().any(|a| a == "--strict-mcp-config"));
2459        assert!(
2460            args.iter()
2461                .any(|a| a == "--exclude-dynamic-system-prompt-sections")
2462        );
2463        // A hermetic seal must never imply --bare.
2464        assert!(!args.iter().any(|a| a == "--bare"));
2465    }
2466
2467    #[test]
2468    fn build_args_hermetic_scoped_project_keeps_user() {
2469        let args = DuplexOptions::default()
2470            .hermetic_scoped(HermeticScope::Project)
2471            .build_args();
2472        assert!(args.windows(2).any(|w| w == ["--setting-sources", "user"]));
2473        assert!(args.iter().any(|a| a == "--strict-mcp-config"));
2474    }
2475
2476    #[test]
2477    fn build_args_includes_json_schema() {
2478        let schema = r#"{"type":"object"}"#;
2479        let args = DuplexOptions::default().json_schema(schema).build_args();
2480        assert!(args.windows(2).any(|w| w == ["--json-schema", schema]));
2481    }
2482
2483    #[test]
2484    fn build_args_joins_allowed_tools_comma_separated() {
2485        let args = DuplexOptions::default()
2486            .allowed_tools(["Read", "Bash(git log:*)"])
2487            .allowed_tool("Write")
2488            .build_args();
2489        assert!(
2490            args.windows(2)
2491                .any(|w| w == ["--allowed-tools", "Read,Bash(git log:*),Write"]),
2492            "missing joined --allowed-tools in {args:?}"
2493        );
2494    }
2495
2496    #[test]
2497    fn build_args_joins_disallowed_tools_comma_separated() {
2498        let args = DuplexOptions::default()
2499            .disallowed_tools(["WebSearch"])
2500            .disallowed_tool("WebFetch")
2501            .build_args();
2502        assert!(
2503            args.windows(2)
2504                .any(|w| w == ["--disallowed-tools", "WebSearch,WebFetch"]),
2505            "missing joined --disallowed-tools in {args:?}"
2506        );
2507    }
2508
2509    #[test]
2510    fn build_args_includes_caps() {
2511        let args = DuplexOptions::default()
2512            .max_turns(4)
2513            .max_budget_usd(0.25)
2514            .build_args();
2515        assert!(args.windows(2).any(|w| w == ["--max-turns", "4"]));
2516        assert!(args.windows(2).any(|w| w == ["--max-budget-usd", "0.25"]));
2517    }
2518
2519    #[test]
2520    fn build_args_includes_fallback_model_and_effort() {
2521        let args = DuplexOptions::default()
2522            .fallback_model("haiku")
2523            .effort(Effort::Low)
2524            .build_args();
2525        assert!(args.windows(2).any(|w| w == ["--fallback-model", "haiku"]));
2526        assert!(args.windows(2).any(|w| w == ["--effort", "low"]));
2527    }
2528
2529    #[test]
2530    fn build_args_repeats_add_dir_and_mcp_config() {
2531        let args = DuplexOptions::default()
2532            .add_dir("/a")
2533            .add_dir("/b")
2534            .mcp_config("x.json")
2535            .strict_mcp_config()
2536            .build_args();
2537        assert!(args.windows(2).any(|w| w == ["--add-dir", "/a"]));
2538        assert!(args.windows(2).any(|w| w == ["--add-dir", "/b"]));
2539        assert!(args.windows(2).any(|w| w == ["--mcp-config", "x.json"]));
2540        assert!(args.iter().any(|a| a == "--strict-mcp-config"));
2541    }
2542
2543    #[test]
2544    fn build_args_includes_no_session_persistence() {
2545        let args = DuplexOptions::default()
2546            .no_session_persistence()
2547            .build_args();
2548        assert!(args.iter().any(|a| a == "--no-session-persistence"));
2549    }
2550
2551    // ─── #690: parity builders promoted from QueryCommand ───
2552
2553    #[test]
2554    fn build_args_joins_tools_comma_separated() {
2555        let args = DuplexOptions::default()
2556            .tools(["Bash", "Read", "Edit"])
2557            .build_args();
2558        assert!(
2559            args.windows(2).any(|w| w == ["--tools", "Bash,Read,Edit"]),
2560            "missing joined --tools in {args:?}"
2561        );
2562    }
2563
2564    #[test]
2565    fn build_args_repeats_file_per_spec() {
2566        let args = DuplexOptions::default()
2567            .file("file_a:doc.txt")
2568            .file("file_b:notes.md")
2569            .build_args();
2570        assert_eq!(args.iter().filter(|a| *a == "--file").count(), 2);
2571        assert!(args.iter().any(|a| a == "file_a:doc.txt"));
2572        assert!(args.iter().any(|a| a == "file_b:notes.md"));
2573    }
2574
2575    #[test]
2576    fn build_args_includes_settings() {
2577        let args = DuplexOptions::default()
2578            .settings("/tmp/settings.json")
2579            .build_args();
2580        assert!(
2581            args.windows(2)
2582                .any(|w| w == ["--settings", "/tmp/settings.json"])
2583        );
2584    }
2585
2586    #[test]
2587    fn build_args_includes_fork_session() {
2588        let args = DuplexOptions::default().fork_session().build_args();
2589        assert!(args.iter().any(|a| a == "--fork-session"));
2590    }
2591
2592    #[test]
2593    fn build_args_includes_debug_filter_and_file() {
2594        let args = DuplexOptions::default()
2595            .debug_filter("api,hooks")
2596            .debug_file("/tmp/debug.log")
2597            .build_args();
2598        assert!(args.windows(2).any(|w| w == ["--debug", "api,hooks"]));
2599        assert!(
2600            args.windows(2)
2601                .any(|w| w == ["--debug-file", "/tmp/debug.log"])
2602        );
2603    }
2604
2605    #[test]
2606    fn build_args_includes_betas() {
2607        let args = DuplexOptions::default().betas("feature-x").build_args();
2608        assert!(args.windows(2).any(|w| w == ["--betas", "feature-x"]));
2609    }
2610
2611    #[test]
2612    fn build_args_repeats_plugin_dir_and_url() {
2613        let args = DuplexOptions::default()
2614            .plugin_dir("/plugins/a")
2615            .plugin_dir("/plugins/b")
2616            .plugin_url("https://example.com/p.zip")
2617            .build_args();
2618        assert_eq!(args.iter().filter(|a| *a == "--plugin-dir").count(), 2);
2619        assert!(
2620            args.windows(2)
2621                .any(|w| w == ["--plugin-url", "https://example.com/p.zip"])
2622        );
2623    }
2624
2625    #[test]
2626    fn build_args_includes_bare_family_bool_flags() {
2627        let args = DuplexOptions::default()
2628            .tmux()
2629            .bare()
2630            .safe_mode()
2631            .disable_slash_commands()
2632            .include_hook_events()
2633            .exclude_dynamic_system_prompt_sections()
2634            .build_args();
2635        for flag in [
2636            "--tmux",
2637            "--bare",
2638            "--safe-mode",
2639            "--disable-slash-commands",
2640            "--include-hook-events",
2641            "--exclude-dynamic-system-prompt-sections",
2642        ] {
2643            assert!(args.iter().any(|a| a == flag), "missing {flag} in {args:?}");
2644        }
2645    }
2646
2647    #[test]
2648    fn build_args_includes_name() {
2649        let args = DuplexOptions::default().name("my session").build_args();
2650        assert!(args.windows(2).any(|w| w == ["--name", "my session"]));
2651    }
2652
2653    #[test]
2654    fn build_args_omits_promoted_parity_flags_by_default() {
2655        let args = DuplexOptions::default().build_args();
2656        for flag in [
2657            "--tools",
2658            "--file",
2659            "--settings",
2660            "--fork-session",
2661            "--debug",
2662            "--debug-file",
2663            "--betas",
2664            "--plugin-dir",
2665            "--plugin-url",
2666            "--tmux",
2667            "--bare",
2668            "--safe-mode",
2669            "--disable-slash-commands",
2670            "--include-hook-events",
2671            "--exclude-dynamic-system-prompt-sections",
2672            "--name",
2673        ] {
2674            assert!(
2675                !args.iter().any(|a| a == flag),
2676                "{flag} should be absent by default; got {args:?}"
2677            );
2678        }
2679    }
2680
2681    #[test]
2682    fn parity_flags_land_before_additional_args() {
2683        // Same `--` ordering bug class as resume/agent.
2684        let args = DuplexOptions::default()
2685            .max_turns(2)
2686            .json_schema("{}")
2687            .arg("--")
2688            .arg("trailing")
2689            .build_args();
2690        let dash_dash_pos = args.iter().position(|a| a == "--").unwrap();
2691        for flag in ["--max-turns", "--json-schema"] {
2692            let pos = args.iter().position(|a| a == flag).unwrap();
2693            assert!(
2694                pos < dash_dash_pos,
2695                "{flag} must precede `--` separator; got {args:?}"
2696            );
2697        }
2698    }
2699
2700    #[test]
2701    fn build_args_omits_parity_flags_by_default() {
2702        let args = DuplexOptions::default().build_args();
2703        for flag in [
2704            "--session-id",
2705            "--json-schema",
2706            "--allowed-tools",
2707            "--disallowed-tools",
2708            "--max-turns",
2709            "--max-budget-usd",
2710            "--fallback-model",
2711            "--effort",
2712            "--add-dir",
2713            "--mcp-config",
2714            "--strict-mcp-config",
2715            "--no-session-persistence",
2716        ] {
2717            assert!(
2718                !args.iter().any(|a| a == flag),
2719                "{flag} should not appear by default; got {args:?}"
2720            );
2721        }
2722    }
2723
2724    #[test]
2725    fn resume_lands_before_additional_args() {
2726        // Catches the same class of bug as QueryCommand::execute_json
2727        // had: a flag appended after the user-supplied raw args (which
2728        // typically include `--`) gets eaten as a positional. Resume
2729        // must precede any caller-injected `arg(...)`.
2730        let args = DuplexOptions::default()
2731            .resume("xyz")
2732            .arg("--")
2733            .arg("trailing")
2734            .build_args();
2735        let resume_pos = args.iter().position(|a| a == "--resume").unwrap();
2736        let dash_dash_pos = args.iter().position(|a| a == "--").unwrap();
2737        assert!(
2738            resume_pos < dash_dash_pos,
2739            "--resume must precede `--` separator; got {args:?}"
2740        );
2741    }
2742
2743    #[test]
2744    fn turn_result_accessors_pull_from_result() {
2745        let r = TurnResult {
2746            result: json!({
2747                "type": "result",
2748                "result": "hello",
2749                "session_id": "sess-123",
2750                "total_cost_usd": 0.0042,
2751                "duration_ms": 1234_u64,
2752            }),
2753            events: vec![],
2754        };
2755        assert_eq!(r.result_text(), Some("hello"));
2756        assert_eq!(r.session_id(), Some("sess-123"));
2757        assert_eq!(r.total_cost_usd(), Some(0.0042));
2758        assert_eq!(r.duration_ms(), Some(1234));
2759    }
2760
2761    #[test]
2762    fn turn_result_total_cost_falls_back_to_legacy_field() {
2763        let r = TurnResult {
2764            result: json!({ "cost_usd": 0.5 }),
2765            events: vec![],
2766        };
2767        assert_eq!(r.total_cost_usd(), Some(0.5));
2768    }
2769
2770    #[test]
2771    fn turn_result_accessors_return_none_when_missing() {
2772        let r = TurnResult {
2773            result: json!({}),
2774            events: vec![],
2775        };
2776        assert_eq!(r.result_text(), None);
2777        assert_eq!(r.session_id(), None);
2778        assert_eq!(r.total_cost_usd(), None);
2779        assert_eq!(r.duration_ms(), None);
2780        assert_eq!(r.subtype(), None);
2781        assert!(!r.is_error());
2782    }
2783
2784    #[test]
2785    fn turn_result_subtype_and_is_error_for_interrupted_turn() {
2786        let r = TurnResult {
2787            result: json!({
2788                "type": "result",
2789                "subtype": "error_during_execution",
2790                "is_error": true,
2791                "total_cost_usd": 0.0004,
2792            }),
2793            events: vec![],
2794        };
2795        assert_eq!(r.subtype(), Some("error_during_execution"));
2796        assert!(r.is_error());
2797        assert_eq!(r.result_text(), None);
2798    }
2799
2800    #[test]
2801    fn turn_cost_delta_tracks_previous_total() {
2802        let mut prev = None;
2803        assert_eq!(turn_cost_delta(&mut prev, Some(0.25)), Some(0.25));
2804        assert_eq!(turn_cost_delta(&mut prev, Some(0.75)), Some(0.5));
2805        // No total: no cost, baseline unchanged.
2806        assert_eq!(turn_cost_delta(&mut prev, None), None);
2807        assert_eq!(prev, Some(0.75));
2808        // Falling total: clamped at zero, becomes the new baseline.
2809        assert_eq!(turn_cost_delta(&mut prev, Some(0.5)), Some(0.0));
2810        assert_eq!(turn_cost_delta(&mut prev, Some(1.0)), Some(0.5));
2811    }
2812
2813    #[test]
2814    fn handle_inbound_appends_non_result_to_pending_events() {
2815        let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
2816        let (events_tx, _events_rx) = broadcast::channel(16);
2817        let mut pending = Some((tx, Vec::new()));
2818        handle_inbound(
2819            json!({ "type": "assistant", "message": {} }),
2820            &mut pending,
2821            &events_tx,
2822        );
2823        let (_, events) = pending.as_ref().unwrap();
2824        assert_eq!(events.len(), 1);
2825        assert_eq!(
2826            events[0].get("type").and_then(Value::as_str),
2827            Some("assistant")
2828        );
2829    }
2830
2831    #[test]
2832    fn handle_inbound_resolves_pending_on_result() {
2833        let (tx, rx) = oneshot::channel::<Result<TurnResult>>();
2834        let (events_tx, _events_rx) = broadcast::channel(16);
2835        let mut pending = Some((tx, vec![json!({ "type": "assistant" })]));
2836        handle_inbound(
2837            json!({ "type": "result", "result": "ok" }),
2838            &mut pending,
2839            &events_tx,
2840        );
2841        assert!(pending.is_none());
2842        let received = rx.blocking_recv().unwrap().unwrap();
2843        assert_eq!(received.result_text(), Some("ok"));
2844        assert_eq!(received.events.len(), 1);
2845    }
2846
2847    #[test]
2848    fn handle_inbound_drops_orphans_without_pending_turn() {
2849        let (events_tx, _events_rx) = broadcast::channel(16);
2850        let mut pending: Option<(oneshot::Sender<Result<TurnResult>>, Vec<Value>)> = None;
2851        handle_inbound(json!({ "type": "assistant" }), &mut pending, &events_tx);
2852        handle_inbound(
2853            json!({ "type": "result", "result": "ok" }),
2854            &mut pending,
2855            &events_tx,
2856        );
2857        assert!(pending.is_none());
2858    }
2859
2860    #[test]
2861    fn handle_inbound_broadcasts_classified_event() {
2862        let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
2863        let (events_tx, mut events_rx) = broadcast::channel(16);
2864        let mut pending = Some((tx, Vec::new()));
2865        handle_inbound(
2866            json!({ "type": "assistant", "message": { "role": "assistant" } }),
2867            &mut pending,
2868            &events_tx,
2869        );
2870        let event = events_rx.try_recv().expect("classified event broadcast");
2871        assert!(matches!(event, InboundEvent::Assistant(_)));
2872    }
2873
2874    #[test]
2875    fn handle_inbound_does_not_broadcast_result() {
2876        let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
2877        let (events_tx, mut events_rx) = broadcast::channel(16);
2878        let mut pending = Some((tx, Vec::new()));
2879        handle_inbound(
2880            json!({ "type": "result", "result": "ok" }),
2881            &mut pending,
2882            &events_tx,
2883        );
2884        // Result is not broadcast -- it lands in TurnResult.result.
2885        assert!(events_rx.try_recv().is_err());
2886    }
2887
2888    #[test]
2889    fn classify_system_init_pulls_session_id() {
2890        let v = json!({
2891            "type": "system",
2892            "subtype": "init",
2893            "session_id": "sess-abc",
2894        });
2895        match classify(&v) {
2896            InboundEvent::SystemInit { session_id } => assert_eq!(session_id, "sess-abc"),
2897            other => panic!("expected SystemInit, got {other:?}"),
2898        }
2899    }
2900
2901    #[test]
2902    fn classify_system_without_init_subtype_is_other() {
2903        let v = json!({ "type": "system", "subtype": "compaction" });
2904        assert!(matches!(classify(&v), InboundEvent::Other(_)));
2905    }
2906
2907    #[test]
2908    fn classify_system_init_without_session_id_is_other() {
2909        let v = json!({ "type": "system", "subtype": "init" });
2910        assert!(matches!(classify(&v), InboundEvent::Other(_)));
2911    }
2912
2913    #[test]
2914    fn classify_assistant_stream_event_user() {
2915        assert!(matches!(
2916            classify(&json!({ "type": "assistant" })),
2917            InboundEvent::Assistant(_)
2918        ));
2919        assert!(matches!(
2920            classify(&json!({ "type": "stream_event" })),
2921            InboundEvent::StreamEvent(_)
2922        ));
2923        assert!(matches!(
2924            classify(&json!({ "type": "user" })),
2925            InboundEvent::User(_)
2926        ));
2927    }
2928
2929    #[test]
2930    fn classify_unknown_type_is_other() {
2931        assert!(matches!(
2932            classify(&json!({ "type": "control_request" })),
2933            InboundEvent::Other(_)
2934        ));
2935        assert!(matches!(
2936            classify(&json!({ "type": "future_thing" })),
2937            InboundEvent::Other(_)
2938        ));
2939        assert!(matches!(classify(&json!({})), InboundEvent::Other(_)));
2940    }
2941
2942    #[test]
2943    fn build_args_does_not_emit_subscriber_capacity_flag() {
2944        // subscriber_capacity is runtime config, not a CLI arg.
2945        let args = DuplexOptions::default()
2946            .subscriber_capacity(64)
2947            .build_args();
2948        assert!(!args.iter().any(|a| a.contains("subscriber")));
2949        assert!(!args.iter().any(|a| a.contains("capacity")));
2950    }
2951
2952    #[test]
2953    fn build_args_includes_permission_prompt_tool_when_handler_set() {
2954        let handler = PermissionHandler::new(|_req| async move {
2955            PermissionDecision::Allow {
2956                updated_input: None,
2957            }
2958        });
2959        let args = DuplexOptions::default().on_permission(handler).build_args();
2960        assert!(
2961            args.windows(2)
2962                .any(|w| w == ["--permission-prompt-tool", "stdio"])
2963        );
2964    }
2965
2966    #[test]
2967    fn build_args_omits_permission_prompt_tool_without_handler() {
2968        let args = DuplexOptions::default().build_args();
2969        assert!(!args.iter().any(|a| a == "--permission-prompt-tool"));
2970    }
2971
2972    #[test]
2973    fn build_args_emits_permission_mode_flag() {
2974        let args = DuplexOptions::default()
2975            .permission_mode(PermissionMode::AcceptEdits)
2976            .build_args();
2977        assert!(
2978            args.windows(2)
2979                .any(|w| w == ["--permission-mode", "acceptEdits"]),
2980            "missing --permission-mode acceptEdits in {args:?}"
2981        );
2982    }
2983
2984    #[test]
2985    fn build_args_emits_plan_mode() {
2986        let args = DuplexOptions::default()
2987            .permission_mode(PermissionMode::Plan)
2988            .build_args();
2989        assert!(args.windows(2).any(|w| w == ["--permission-mode", "plan"]));
2990    }
2991
2992    #[test]
2993    fn build_args_omits_permission_mode_by_default() {
2994        let args = DuplexOptions::default().build_args();
2995        assert!(!args.iter().any(|a| a == "--permission-mode"));
2996    }
2997
2998    #[test]
2999    fn build_args_emits_dangerously_skip_permissions_flag() {
3000        let args = DuplexOptions::default()
3001            .dangerously_skip_permissions()
3002            .build_args();
3003        assert!(args.iter().any(|a| a == "--dangerously-skip-permissions"));
3004    }
3005
3006    #[test]
3007    fn build_args_omits_dangerously_skip_by_default() {
3008        let args = DuplexOptions::default().build_args();
3009        assert!(!args.iter().any(|a| a == "--dangerously-skip-permissions"));
3010    }
3011
3012    #[test]
3013    fn parse_permission_request_extracts_fields() {
3014        let msg = json!({
3015            "type": "control_request",
3016            "request_id": "req-1",
3017            "request": {
3018                "subtype": "can_use_tool",
3019                "tool_name": "Bash",
3020                "input": { "command": "ls" }
3021            }
3022        });
3023        let req = parse_permission_request(&msg).expect("permission request");
3024        assert_eq!(req.request_id, "req-1");
3025        assert_eq!(req.tool_name, "Bash");
3026        assert_eq!(req.input, json!({ "command": "ls" }));
3027        assert_eq!(
3028            req.raw.get("subtype").and_then(Value::as_str),
3029            Some("can_use_tool")
3030        );
3031    }
3032
3033    #[test]
3034    fn parse_permission_request_returns_none_when_missing_request_id() {
3035        let msg = json!({
3036            "type": "control_request",
3037            "request": {
3038                "subtype": "can_use_tool",
3039                "tool_name": "Bash",
3040            }
3041        });
3042        assert!(parse_permission_request(&msg).is_none());
3043    }
3044
3045    #[test]
3046    fn parse_permission_request_returns_none_when_missing_tool_name() {
3047        let msg = json!({
3048            "type": "control_request",
3049            "request_id": "req-1",
3050            "request": { "subtype": "can_use_tool" }
3051        });
3052        assert!(parse_permission_request(&msg).is_none());
3053    }
3054
3055    #[test]
3056    fn parse_permission_request_handles_missing_input() {
3057        let msg = json!({
3058            "type": "control_request",
3059            "request_id": "req-1",
3060            "request": {
3061                "subtype": "can_use_tool",
3062                "tool_name": "Bash",
3063            }
3064        });
3065        let req = parse_permission_request(&msg).expect("request");
3066        assert_eq!(req.input, Value::Null);
3067    }
3068
3069    #[test]
3070    fn handle_inbound_returns_permission_for_can_use_tool() {
3071        let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
3072        let (events_tx, _events_rx) = broadcast::channel(16);
3073        let mut pending = Some((tx, Vec::new()));
3074        let action = handle_inbound(
3075            json!({
3076                "type": "control_request",
3077                "request_id": "req-1",
3078                "request": {
3079                    "subtype": "can_use_tool",
3080                    "tool_name": "Bash",
3081                    "input": { "command": "ls" }
3082                }
3083            }),
3084            &mut pending,
3085            &events_tx,
3086        );
3087        match action {
3088            InboundAction::Permission(req) => {
3089                assert_eq!(req.request_id, "req-1");
3090                assert_eq!(req.tool_name, "Bash");
3091            }
3092            InboundAction::None | InboundAction::ControlResponse { .. } => {
3093                panic!("expected Permission action");
3094            }
3095        }
3096        // Event should also be accumulated in the pending turn.
3097        let (_, events) = pending.as_ref().unwrap();
3098        assert_eq!(events.len(), 1);
3099    }
3100
3101    #[test]
3102    fn handle_inbound_treats_unknown_control_request_as_other() {
3103        let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
3104        let (events_tx, mut events_rx) = broadcast::channel(16);
3105        let mut pending = Some((tx, Vec::new()));
3106        let action = handle_inbound(
3107            json!({
3108                "type": "control_request",
3109                "request_id": "req-2",
3110                "request": { "subtype": "future_subtype" }
3111            }),
3112            &mut pending,
3113            &events_tx,
3114        );
3115        assert!(matches!(action, InboundAction::None));
3116        let event = events_rx.try_recv().expect("broadcast");
3117        assert!(matches!(event, InboundEvent::Other(_)));
3118    }
3119
3120    #[tokio::test]
3121    async fn permission_handler_invokes_closure_async() {
3122        let handler = PermissionHandler::new(|req| async move {
3123            if req.tool_name == "Bash" {
3124                PermissionDecision::Deny {
3125                    message: "no bash".into(),
3126                }
3127            } else {
3128                PermissionDecision::Allow {
3129                    updated_input: None,
3130                }
3131            }
3132        });
3133        let req = PermissionRequest {
3134            request_id: "r1".into(),
3135            tool_name: "Bash".into(),
3136            input: Value::Null,
3137            raw: Value::Null,
3138        };
3139        match handler.invoke(req).await {
3140            PermissionDecision::Deny { message } => assert_eq!(message, "no bash"),
3141            other => panic!("expected Deny, got {other:?}"),
3142        }
3143    }
3144
3145    #[test]
3146    fn parse_control_response_extracts_success() {
3147        let msg = json!({
3148            "type": "control_response",
3149            "response": {
3150                "request_id": "interrupt-1",
3151                "subtype": "success",
3152                "response": {}
3153            }
3154        });
3155        let (id, outcome) = parse_control_response(&msg).expect("parsed");
3156        assert_eq!(id, "interrupt-1");
3157        assert!(outcome.is_ok());
3158    }
3159
3160    #[test]
3161    fn parse_control_response_extracts_error_with_message() {
3162        let msg = json!({
3163            "type": "control_response",
3164            "response": {
3165                "request_id": "interrupt-2",
3166                "subtype": "error",
3167                "error": "no turn in flight"
3168            }
3169        });
3170        let (id, outcome) = parse_control_response(&msg).expect("parsed");
3171        assert_eq!(id, "interrupt-2");
3172        match outcome {
3173            Err(Error::DuplexControlFailed { message }) => {
3174                assert_eq!(message, "no turn in flight");
3175            }
3176            other => panic!("expected DuplexControlFailed, got {other:?}"),
3177        }
3178    }
3179
3180    #[test]
3181    fn parse_control_response_returns_none_on_missing_request_id() {
3182        let msg = json!({
3183            "type": "control_response",
3184            "response": { "subtype": "success" }
3185        });
3186        assert!(parse_control_response(&msg).is_none());
3187    }
3188
3189    #[test]
3190    fn parse_control_response_returns_none_on_unknown_subtype() {
3191        let msg = json!({
3192            "type": "control_response",
3193            "response": { "request_id": "x", "subtype": "future_subtype" }
3194        });
3195        assert!(parse_control_response(&msg).is_none());
3196    }
3197
3198    #[test]
3199    fn handle_inbound_returns_control_response_action() {
3200        let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
3201        let (events_tx, _events_rx) = broadcast::channel(16);
3202        let mut pending = Some((tx, Vec::new()));
3203        let action = handle_inbound(
3204            json!({
3205                "type": "control_response",
3206                "response": {
3207                    "request_id": "interrupt-1",
3208                    "subtype": "success",
3209                    "response": {}
3210                }
3211            }),
3212            &mut pending,
3213            &events_tx,
3214        );
3215        match action {
3216            InboundAction::ControlResponse {
3217                request_id,
3218                outcome,
3219            } => {
3220                assert_eq!(request_id, "interrupt-1");
3221                assert!(outcome.is_ok());
3222            }
3223            InboundAction::None | InboundAction::Permission(_) => {
3224                panic!("expected ControlResponse action");
3225            }
3226        }
3227    }
3228
3229    #[test]
3230    fn handle_inbound_treats_malformed_control_response_as_other() {
3231        let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
3232        let (events_tx, mut events_rx) = broadcast::channel(16);
3233        let mut pending = Some((tx, Vec::new()));
3234        let action = handle_inbound(
3235            json!({
3236                "type": "control_response",
3237                "response": { "subtype": "success" }
3238            }),
3239            &mut pending,
3240            &events_tx,
3241        );
3242        assert!(matches!(action, InboundAction::None));
3243        let event = events_rx.try_recv().expect("broadcast");
3244        assert!(matches!(event, InboundEvent::Other(_)));
3245    }
3246
3247    #[tokio::test]
3248    async fn permission_handler_clones_arc() {
3249        let handler = PermissionHandler::new(|_req| async move {
3250            PermissionDecision::Allow {
3251                updated_input: None,
3252            }
3253        });
3254        let cloned = handler.clone();
3255        let req = PermissionRequest {
3256            request_id: "r1".into(),
3257            tool_name: "Read".into(),
3258            input: Value::Null,
3259            raw: Value::Null,
3260        };
3261        // Both handles invoke the same underlying closure.
3262        let _ = handler.invoke(req.clone()).await;
3263        let _ = cloned.invoke(req).await;
3264    }
3265
3266    /// Build a `DuplexSession` whose channels are wired up but whose
3267    /// background task is a no-op. Tests can drive the watch state
3268    /// machine via the returned `exit_tx` and observe the public
3269    /// accessors. The fake task idles on a oneshot so it stays alive
3270    /// for the life of the test (no JoinHandle::abort handshake
3271    /// needed).
3272    fn fake_session(
3273        initial: SessionExitStatus,
3274    ) -> (
3275        DuplexSession,
3276        watch::Sender<SessionExitStatus>,
3277        oneshot::Sender<()>,
3278    ) {
3279        let (outbound_tx, outbound_rx) = mpsc::unbounded_channel::<OutboundMsg>();
3280        let (events_tx, _events_rx) = broadcast::channel::<InboundEvent>(16);
3281        let (exit_tx, exit_rx) = watch::channel(initial);
3282        let (stop_tx, stop_rx) = oneshot::channel::<()>();
3283
3284        let join = tokio::spawn(async move {
3285            let _outbound_rx = outbound_rx;
3286            let _ = stop_rx.await;
3287            Ok::<(), Error>(())
3288        });
3289
3290        let session = DuplexSession {
3291            outbound_tx,
3292            events_tx,
3293            exit_rx,
3294            join,
3295        };
3296        (session, exit_tx, stop_tx)
3297    }
3298
3299    #[tokio::test]
3300    async fn is_alive_true_while_running() {
3301        let (session, _exit_tx, _stop) = fake_session(SessionExitStatus::Running);
3302        assert!(session.is_alive());
3303    }
3304
3305    #[tokio::test]
3306    async fn is_alive_false_after_completed() {
3307        let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
3308        exit_tx.send(SessionExitStatus::Completed).unwrap();
3309        assert!(!session.is_alive());
3310    }
3311
3312    #[tokio::test]
3313    async fn is_alive_false_after_failed() {
3314        let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
3315        exit_tx
3316            .send(SessionExitStatus::Failed("boom".into()))
3317            .unwrap();
3318        assert!(!session.is_alive());
3319    }
3320
3321    #[tokio::test]
3322    async fn exit_status_reports_running_initially() {
3323        let (session, _exit_tx, _stop) = fake_session(SessionExitStatus::Running);
3324        assert!(matches!(session.exit_status(), SessionExitStatus::Running));
3325    }
3326
3327    #[tokio::test]
3328    async fn exit_status_reflects_completed() {
3329        let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
3330        exit_tx.send(SessionExitStatus::Completed).unwrap();
3331        assert!(matches!(
3332            session.exit_status(),
3333            SessionExitStatus::Completed
3334        ));
3335    }
3336
3337    #[tokio::test]
3338    async fn exit_status_reflects_failed_with_message() {
3339        let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
3340        exit_tx
3341            .send(SessionExitStatus::Failed("oh no".into()))
3342            .unwrap();
3343        match session.exit_status() {
3344            SessionExitStatus::Failed(msg) => assert_eq!(msg, "oh no"),
3345            other => panic!("expected Failed, got {other:?}"),
3346        }
3347    }
3348
3349    #[tokio::test]
3350    async fn wait_for_exit_returns_immediately_when_already_terminal() {
3351        let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
3352        exit_tx.send(SessionExitStatus::Completed).unwrap();
3353        let status = tokio::time::timeout(Duration::from_secs(1), session.wait_for_exit())
3354            .await
3355            .expect("wait_for_exit should not block when already terminal");
3356        assert!(matches!(status, SessionExitStatus::Completed));
3357    }
3358
3359    #[tokio::test]
3360    async fn wait_for_exit_blocks_until_state_transitions() {
3361        let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
3362
3363        let waiter = async { session.wait_for_exit().await };
3364        let driver = async {
3365            tokio::time::sleep(Duration::from_millis(20)).await;
3366            exit_tx.send(SessionExitStatus::Completed).unwrap();
3367        };
3368        let (status, ()) = tokio::join!(waiter, driver);
3369        assert!(matches!(status, SessionExitStatus::Completed));
3370    }
3371
3372    #[tokio::test]
3373    async fn wait_for_exit_supports_multiple_observers() {
3374        let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
3375
3376        let waiter1 = async { session.wait_for_exit().await };
3377        let waiter2 = async { session.wait_for_exit().await };
3378        let driver = async {
3379            tokio::time::sleep(Duration::from_millis(20)).await;
3380            exit_tx
3381                .send(SessionExitStatus::Failed("crash".into()))
3382                .unwrap();
3383        };
3384        let (s1, s2, ()) = tokio::join!(waiter1, waiter2, driver);
3385        match s1 {
3386            SessionExitStatus::Failed(msg) => assert_eq!(msg, "crash"),
3387            other => panic!("waiter1 expected Failed, got {other:?}"),
3388        }
3389        match s2 {
3390            SessionExitStatus::Failed(msg) => assert_eq!(msg, "crash"),
3391            other => panic!("waiter2 expected Failed, got {other:?}"),
3392        }
3393    }
3394
3395    #[tokio::test]
3396    async fn wait_for_exit_returns_last_value_when_sender_dropped() {
3397        // Defensive: if exit_tx is dropped without ever publishing a
3398        // terminal value, wait_for_exit should fall back to the last
3399        // observed state rather than hang.
3400        let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
3401        let waiter = async { session.wait_for_exit().await };
3402        let driver = async {
3403            tokio::time::sleep(Duration::from_millis(20)).await;
3404            drop(exit_tx);
3405        };
3406        let (status, ()) = tokio::time::timeout(Duration::from_secs(1), async {
3407            tokio::join!(waiter, driver)
3408        })
3409        .await
3410        .expect("wait_for_exit must not hang when sender is dropped");
3411        assert!(matches!(status, SessionExitStatus::Running));
3412    }
3413
3414    #[tokio::test]
3415    async fn stderr_reader_retries_interruptions_and_retains_bytes_on_failure() {
3416        struct Reader(u8);
3417        impl tokio::io::AsyncRead for Reader {
3418            fn poll_read(
3419                mut self: std::pin::Pin<&mut Self>,
3420                _: &mut std::task::Context<'_>,
3421                buf: &mut tokio::io::ReadBuf<'_>,
3422            ) -> std::task::Poll<std::io::Result<()>> {
3423                self.0 += 1;
3424                std::task::Poll::Ready(match self.0 {
3425                    1 => Err(std::io::ErrorKind::Interrupted.into()),
3426                    2 => {
3427                        buf.put_slice(b"retained");
3428                        Ok(())
3429                    }
3430                    _ => Err(std::io::Error::other("stderr read fault")),
3431                })
3432            }
3433        }
3434        let mut tail = VecDeque::new();
3435        let error = read_stderr_tail(&mut Reader(0), &mut tail)
3436            .await
3437            .unwrap_err();
3438        assert_eq!(error.to_string(), "stderr read fault");
3439        assert_eq!(tail.into_iter().collect::<Vec<_>>(), b"retained");
3440    }
3441
3442    #[cfg(unix)]
3443    #[test]
3444    fn stderr_read_failure_keeps_native_exit_evidence_and_diagnostics() {
3445        for code in [0, 7] {
3446            let status = std::process::Command::new("/bin/sh")
3447                .args(["-c", &format!("exit {code}")])
3448                .status()
3449                .unwrap();
3450            let result = resident_outcome(
3451                Ok((status, false)),
3452                Some(Err(std::io::Error::other("stderr read fault"))),
3453                "retained diagnostics".into(),
3454            );
3455            match result {
3456                Err(Error::DuplexProcessFailed {
3457                    status,
3458                    stderr,
3459                    stderr_read_error,
3460                    ..
3461                }) => {
3462                    assert_eq!(status.code(), Some(7));
3463                    assert_eq!(stderr, "retained diagnostics");
3464                    assert_eq!(stderr_read_error.unwrap().to_string(), "stderr read fault");
3465                }
3466                Err(Error::Io {
3467                    message, source, ..
3468                }) if code == 0 => {
3469                    assert!(message.contains("retained diagnostics"));
3470                    assert_eq!(source.to_string(), "stderr read fault");
3471                }
3472                other => panic!("expected retained exit/read evidence, got {other:?}"),
3473            }
3474        }
3475    }
3476    #[test]
3477    fn compatible_headless_flags_are_explicit_and_do_not_activate_bypass() {
3478        let defaults = DuplexOptions::default().build_args();
3479        assert!(
3480            !defaults.iter().any(|arg| arg == "--forward-subagent-text"
3481                || arg == "--allow-dangerously-skip-permissions")
3482        );
3483        let args = DuplexOptions::default()
3484            .forward_subagent_text()
3485            .allow_dangerously_skip_permissions()
3486            .build_args();
3487        assert!(args.iter().any(|arg| arg == "--forward-subagent-text"));
3488        assert!(
3489            args.iter()
3490                .any(|arg| arg == "--allow-dangerously-skip-permissions")
3491        );
3492        assert!(
3493            !args
3494                .iter()
3495                .any(|arg| arg == "--dangerously-skip-permissions" || arg == "bypassPermissions")
3496        );
3497    }
3498}