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