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::{Instrument, 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            .stdin(Stdio::piped())
1161            .stdout(Stdio::piped())
1162            .stderr(Stdio::piped())
1163            .kill_on_drop(true);
1164        crate::exec::apply_child_environment(cmd.as_std_mut(), claude.clear_env, &claude.env);
1165        // Own process group (Unix) so shutdown can signal the whole
1166        // tree, not just the direct child (see exec::GroupKillGuard).
1167        // Opt out via ClaudeBuilder::process_group.
1168        crate::exec::apply_process_group(&mut cmd, claude.process_group);
1169
1170        if let Some(ref dir) = claude.working_dir {
1171            cmd.current_dir(dir);
1172        }
1173
1174        let mut child = cmd.spawn().map_err(|e| Error::Io {
1175            message: format!("failed to spawn claude: {e}"),
1176            source: e,
1177            working_dir: claude.working_dir.clone(),
1178        })?;
1179        let group =
1180            crate::exec::arm_and_notify(claude.process_group, child.id(), claude.on_spawn.as_ref());
1181
1182        let stdin = child.stdin.take().expect("stdin was piped");
1183        let stdout = child.stdout.take().expect("stdout was piped");
1184
1185        let (outbound_tx, outbound_rx) = mpsc::unbounded_channel();
1186        let (events_tx, _initial_rx) = broadcast::channel(capacity);
1187        let (exit_tx, exit_rx) = watch::channel(SessionExitStatus::Running);
1188
1189        // A session-lifetime span, entered inside the spawned task. The
1190        // run loop interleaves events from every turn on one stdout
1191        // stream, so without this its lines ("dropping orphan result
1192        // event", parse failures, shutdown-budget kills) cannot be
1193        // attributed to a session at all when several are open.
1194        //
1195        // `session_id` starts empty and is recorded when the CLI's init
1196        // event arrives, since it is not known at spawn unless resuming.
1197        let session_span = tracing::debug_span!(
1198            "claude.session",
1199            session_id = tracing::field::Empty,
1200            model = opts.shared.model.as_deref().unwrap_or("default"),
1201            permission_mode = opts
1202                .shared
1203                .permission_mode
1204                .as_ref()
1205                .map(|m| m.as_arg())
1206                .unwrap_or("default"),
1207            resumed = opts.shared.resume.is_some(),
1208            turns = tracing::field::Empty,
1209            exit = tracing::field::Empty,
1210        );
1211        let join = tokio::spawn(
1212            run_session(
1213                child,
1214                group,
1215                claude.kill_grace,
1216                stdin,
1217                stdout,
1218                outbound_rx,
1219                events_tx.clone(),
1220                permission_handler,
1221                exit_tx,
1222            )
1223            .instrument(session_span),
1224        );
1225
1226        Ok(Self {
1227            outbound_tx,
1228            events_tx,
1229            exit_rx,
1230            join,
1231        })
1232    }
1233
1234    /// Send one user message and await the closing result event.
1235    ///
1236    /// Returns [`Error::DuplexTurnInFlight`] if another turn is
1237    /// already pending, and [`Error::DuplexClosed`] if the session
1238    /// task has already exited.
1239    ///
1240    /// The returned [`TurnResult`] is per-turn; nothing accumulates
1241    /// across calls. For cumulative cost/history and a budget
1242    /// ceiling, send through a
1243    /// [`Conversation`](crate::conversation::Conversation) instead.
1244    pub async fn send(&self, prompt: impl Into<String>) -> Result<TurnResult> {
1245        let (reply_tx, reply_rx) = oneshot::channel();
1246        self.outbound_tx
1247            .send(OutboundMsg::Send {
1248                prompt: prompt.into(),
1249                reply: reply_tx,
1250            })
1251            .map_err(|_| Error::DuplexClosed)?;
1252        reply_rx.await.map_err(|_| Error::DuplexClosed)?
1253    }
1254
1255    /// Subscribe to the session's classified inbound event stream.
1256    ///
1257    /// Returns a [`broadcast::Receiver<InboundEvent>`] that receives
1258    /// every non-`result` event as it arrives. Each subscriber gets
1259    /// its own buffered view; subscribers added later miss earlier
1260    /// events. Slow subscribers see
1261    /// [`RecvError::Lagged`](tokio::sync::broadcast::error::RecvError::Lagged)
1262    /// rather than blocking the session task.
1263    ///
1264    /// Subscribers see the same events that accumulate in
1265    /// [`TurnResult::events`], in the same order.
1266    ///
1267    /// # Example
1268    ///
1269    /// ```no_run
1270    /// use claude_wrapper::Claude;
1271    /// use claude_wrapper::duplex::{DuplexOptions, DuplexSession, InboundEvent};
1272    ///
1273    /// # async fn example() -> claude_wrapper::Result<()> {
1274    /// let claude = Claude::builder().build()?;
1275    /// let session = DuplexSession::spawn(&claude, DuplexOptions::default()).await?;
1276    /// let mut rx = session.subscribe();
1277    ///
1278    /// // Subscribe before send so we receive every event.
1279    /// let _turn = session.send("hello").await?;
1280    ///
1281    /// while let Ok(event) = rx.try_recv() {
1282    ///     if let InboundEvent::SystemInit { session_id } = event {
1283    ///         println!("session id: {session_id}");
1284    ///     }
1285    /// }
1286    /// # Ok(())
1287    /// # }
1288    /// ```
1289    #[must_use]
1290    pub fn subscribe(&self) -> broadcast::Receiver<InboundEvent> {
1291        self.events_tx.subscribe()
1292    }
1293
1294    /// Cheap, non-blocking liveness check.
1295    ///
1296    /// Returns `true` while the session task is running, `false` once
1297    /// it has exited (whether normally or with an error). Multiple
1298    /// concurrent callers are allowed, and the call does not consume
1299    /// the session: [`Self::close`] still works after polling.
1300    ///
1301    /// Reads the latest value from a `tokio::sync::watch` channel
1302    /// updated from inside the session task, so it never blocks and
1303    /// reflects state set just before the task returns.
1304    #[must_use]
1305    pub fn is_alive(&self) -> bool {
1306        matches!(*self.exit_rx.borrow(), SessionExitStatus::Running)
1307    }
1308
1309    /// Snapshot the session task's [`SessionExitStatus`].
1310    ///
1311    /// Returns [`SessionExitStatus::Running`] while the task is still
1312    /// alive, [`SessionExitStatus::Completed`] after a clean exit, or
1313    /// [`SessionExitStatus::Failed`] with the underlying error
1314    /// rendered to a string.
1315    ///
1316    /// Like [`Self::is_alive`], this is a cheap non-blocking read.
1317    #[must_use]
1318    pub fn exit_status(&self) -> SessionExitStatus {
1319        self.exit_rx.borrow().clone()
1320    }
1321
1322    /// Block until the session task transitions out of
1323    /// [`SessionExitStatus::Running`] and return the terminal status.
1324    ///
1325    /// Returns immediately if the task has already exited. Multiple
1326    /// concurrent callers are supported (each gets its own receiver
1327    /// clone), and the call does not consume the session.
1328    ///
1329    /// If the underlying watch sender is dropped without ever
1330    /// publishing a terminal state -- which should not happen in
1331    /// practice, but is treated defensively -- this returns the last
1332    /// observed value.
1333    pub async fn wait_for_exit(&self) -> SessionExitStatus {
1334        let mut rx = self.exit_rx.clone();
1335        loop {
1336            {
1337                let value = rx.borrow_and_update();
1338                if !matches!(*value, SessionExitStatus::Running) {
1339                    return value.clone();
1340                }
1341            }
1342            if rx.changed().await.is_err() {
1343                return rx.borrow().clone();
1344            }
1345        }
1346    }
1347
1348    /// Answer a deferred permission request from a different task.
1349    ///
1350    /// Use this after the [`PermissionHandler`] returned
1351    /// [`PermissionDecision::Defer`] for the matching `request_id`.
1352    /// Passing `decision = PermissionDecision::Defer` here is a
1353    /// no-op (logged at `warn`); pass `Allow` or `Deny`.
1354    ///
1355    /// Returns [`Error::DuplexClosed`] if the session task has
1356    /// already exited.
1357    ///
1358    /// # Example
1359    ///
1360    /// ```no_run
1361    /// use claude_wrapper::Claude;
1362    /// use claude_wrapper::duplex::{
1363    ///     DuplexOptions, DuplexSession, PermissionDecision, PermissionHandler,
1364    /// };
1365    /// use tokio::sync::mpsc;
1366    ///
1367    /// # async fn example() -> claude_wrapper::Result<()> {
1368    /// // Forward request_ids out to a UI thread; answer asynchronously.
1369    /// let (tx, _rx) = mpsc::unbounded_channel::<String>();
1370    /// let handler = PermissionHandler::new(move |req| {
1371    ///     let tx = tx.clone();
1372    ///     async move {
1373    ///         let _ = tx.send(req.request_id);
1374    ///         PermissionDecision::Defer
1375    ///     }
1376    /// });
1377    ///
1378    /// let claude = Claude::builder().build()?;
1379    /// let session = DuplexSession::spawn(
1380    ///     &claude,
1381    ///     DuplexOptions::default().on_permission(handler),
1382    /// ).await?;
1383    ///
1384    /// // ...later, from the UI thread:
1385    /// session.respond_to_permission(
1386    ///     "req-abc",
1387    ///     PermissionDecision::Allow { updated_input: None },
1388    /// )?;
1389    /// # Ok(())
1390    /// # }
1391    /// ```
1392    pub fn respond_to_permission(
1393        &self,
1394        request_id: impl Into<String>,
1395        decision: PermissionDecision,
1396    ) -> Result<()> {
1397        if matches!(decision, PermissionDecision::Defer) {
1398            warn!("respond_to_permission called with Defer; ignoring");
1399            return Ok(());
1400        }
1401        self.outbound_tx
1402            .send(OutboundMsg::PermissionResponse {
1403                request_id: request_id.into(),
1404                decision,
1405            })
1406            .map_err(|_| Error::DuplexClosed)?;
1407        Ok(())
1408    }
1409
1410    /// Send a clean interrupt to the CLI and wait for its
1411    /// acknowledgment.
1412    ///
1413    /// Writes a `control_request {subtype: "interrupt"}` and resolves
1414    /// when the matching `control_response` comes back. The
1415    /// in-flight turn (if any) closes shortly after with a truncated
1416    /// [`TurnResult`] -- the [`DuplexSession::send`] future for it
1417    /// resolves independently. Either ordering is possible; await
1418    /// both via `tokio::join!` if you care about both outcomes.
1419    ///
1420    /// Returns:
1421    /// - `Ok(())` when the CLI acknowledges with `subtype: "success"`.
1422    /// - [`Error::DuplexControlFailed`] when the CLI answers with an
1423    ///   error payload.
1424    /// - [`Error::DuplexClosed`] if the session task exited before
1425    ///   the response arrived.
1426    ///
1427    /// # Example
1428    ///
1429    /// ```no_run
1430    /// use std::time::Duration;
1431    /// use claude_wrapper::Claude;
1432    /// use claude_wrapper::duplex::{DuplexOptions, DuplexSession};
1433    ///
1434    /// # async fn example() -> claude_wrapper::Result<()> {
1435    /// let claude = Claude::builder().build()?;
1436    /// let session = DuplexSession::spawn(&claude, DuplexOptions::default()).await?;
1437    ///
1438    /// let send_fut = session.send("a question that triggers tool use");
1439    /// let interrupt_fut = async {
1440    ///     tokio::time::sleep(Duration::from_millis(250)).await;
1441    ///     session.interrupt().await
1442    /// };
1443    ///
1444    /// let (turn, interrupt) = tokio::join!(send_fut, interrupt_fut);
1445    /// let _truncated = turn?;
1446    /// interrupt?;
1447    /// # Ok(())
1448    /// # }
1449    /// ```
1450    pub async fn interrupt(&self) -> Result<()> {
1451        let (reply_tx, reply_rx) = oneshot::channel();
1452        self.outbound_tx
1453            .send(OutboundMsg::Interrupt { reply: reply_tx })
1454            .map_err(|_| Error::DuplexClosed)?;
1455        reply_rx.await.map_err(|_| Error::DuplexClosed)?
1456    }
1457
1458    /// Close the session and wait for the underlying task to exit.
1459    ///
1460    /// Drops the outbound channel sender, which the session task
1461    /// observes as `recv() -> None`, then closes stdin and reaps the
1462    /// child.
1463    pub async fn close(self) -> Result<()> {
1464        drop(self.outbound_tx);
1465        drop(self.events_tx);
1466        match self.join.await {
1467            Ok(result) => result,
1468            Err(e) if e.is_cancelled() => Ok(()),
1469            Err(e) => Err(Error::Io {
1470                message: format!("duplex session task panicked: {e}"),
1471                source: std::io::Error::other(e.to_string()),
1472                working_dir: None,
1473            }),
1474        }
1475    }
1476}
1477
1478/// Time budget for the graceful child shutdown after the run loop
1479/// exits. If the child is still alive after this deadline we SIGKILL
1480/// it so close() does not hang on a misbehaving subprocess.
1481const SHUTDOWN_BUDGET: Duration = Duration::from_secs(5);
1482
1483#[allow(clippy::too_many_arguments)]
1484async fn run_session(
1485    mut child: Child,
1486    mut group: crate::exec::GroupKillGuard,
1487    kill_grace: Option<Duration>,
1488    mut stdin: ChildStdin,
1489    stdout: ChildStdout,
1490    mut outbound_rx: mpsc::UnboundedReceiver<OutboundMsg>,
1491    events_tx: broadcast::Sender<InboundEvent>,
1492    permission_handler: Option<PermissionHandler>,
1493    exit_tx: watch::Sender<SessionExitStatus>,
1494) -> Result<()> {
1495    let mut lines = BufReader::new(stdout).lines();
1496    let mut pending: Option<(oneshot::Sender<Result<TurnResult>>, Vec<Value>)> = None;
1497    let mut pending_control: HashMap<String, oneshot::Sender<Result<()>>> = HashMap::new();
1498    let mut next_control_id: u64 = 0;
1499    let mut stream_err: Option<Error> = None;
1500    // The session span is this task's own span (see DuplexSession::spawn).
1501    let session_span = tracing::Span::current();
1502    let mut turns: u64 = 0;
1503    let mut turn_span: Option<tracing::Span> = None;
1504    let mut turn_started: Option<std::time::Instant> = None;
1505
1506    loop {
1507        tokio::select! {
1508            biased;
1509
1510            line = lines.next_line() => match line {
1511                Ok(Some(l)) => {
1512                    if l.trim().is_empty() {
1513                        continue;
1514                    }
1515                    let parsed = match serde_json::from_str::<Value>(&l) {
1516                        Ok(v) => v,
1517                        Err(e) => {
1518                            debug!(line = %l, error = %e, "failed to parse duplex event, skipping");
1519                            continue;
1520                        }
1521                    };
1522                    // Peek before handle_inbound consumes it: the init
1523                    // event is where the session id first appears, and the
1524                    // result event carries the turn's outcome.
1525                    match parsed.get("type").and_then(Value::as_str) {
1526                        Some("system")
1527                            if parsed.get("subtype").and_then(Value::as_str) == Some("init") =>
1528                        {
1529                            if let Some(id) = parsed.get("session_id").and_then(Value::as_str) {
1530                                session_span.record("session_id", id);
1531                            }
1532                        }
1533                        Some("result") => {
1534                            if let Some(span) = turn_span.take() {
1535                                span.record(
1536                                    "is_error",
1537                                    parsed.get("is_error").and_then(Value::as_bool).unwrap_or(false),
1538                                );
1539                                if let Some(sub) = parsed.get("subtype").and_then(Value::as_str) {
1540                                    span.record("subtype", sub);
1541                                }
1542                                if let Some(c) =
1543                                    parsed.get("total_cost_usd").and_then(Value::as_f64)
1544                                {
1545                                    span.record("cost_usd", c);
1546                                }
1547                                if let Some(started) = turn_started.take() {
1548                                    span.record(
1549                                        "duration_ms",
1550                                        started.elapsed().as_millis() as u64,
1551                                    );
1552                                }
1553                            }
1554                        }
1555                        _ => {}
1556                    }
1557                    match handle_inbound(parsed, &mut pending, &events_tx) {
1558                        InboundAction::None => {}
1559                        InboundAction::Permission(req) => {
1560                            let request_id = req.request_id.clone();
1561                            let decision = match permission_handler.as_ref() {
1562                                Some(h) => h.invoke(req).await,
1563                                None => {
1564                                    warn!(
1565                                        request_id = %request_id,
1566                                        "received can_use_tool with no permission handler; auto-denying"
1567                                    );
1568                                    PermissionDecision::Deny {
1569                                        message:
1570                                            "no permission handler configured on duplex session"
1571                                                .into(),
1572                                    }
1573                                }
1574                            };
1575                            if matches!(decision, PermissionDecision::Defer) {
1576                                debug!(
1577                                    request_id = %request_id,
1578                                    "permission handler deferred; waiting for respond_to_permission"
1579                                );
1580                            } else if let Err(e) =
1581                                write_permission_response(&mut stdin, &request_id, &decision).await
1582                            {
1583                                warn!(error = %e, "failed to write permission response");
1584                            }
1585                        }
1586                        InboundAction::ControlResponse { request_id, outcome } => {
1587                            if let Some(reply) = pending_control.remove(&request_id) {
1588                                let _ = reply.send(outcome);
1589                            } else {
1590                                debug!(
1591                                    request_id = %request_id,
1592                                    "received control_response with no pending request"
1593                                );
1594                            }
1595                        }
1596                    }
1597                }
1598                Ok(None) => break,
1599                Err(e) => {
1600                    stream_err = Some(Error::Io {
1601                        message: "failed to read duplex stdout".to_string(),
1602                        source: e,
1603                        working_dir: None,
1604                    });
1605                    break;
1606                }
1607            },
1608
1609            msg = outbound_rx.recv() => match msg {
1610                Some(OutboundMsg::Send { prompt, reply }) => {
1611                    if pending.is_some() {
1612                        let _ = reply.send(Err(Error::DuplexTurnInFlight));
1613                        continue;
1614                    }
1615                    if let Err(e) = write_user(&mut stdin, &prompt).await {
1616                        let _ = reply.send(Err(e));
1617                        continue;
1618                    }
1619                    turns += 1;
1620                    // One span per turn, so the events a turn produces are
1621                    // attributable to it rather than to the session as a
1622                    // whole. Outcome fields are recorded when the result
1623                    // event arrives above.
1624                    turn_span = Some(tracing::debug_span!(
1625                        parent: &session_span,
1626                        "claude.turn",
1627                        turn = turns,
1628                        is_error = tracing::field::Empty,
1629                        subtype = tracing::field::Empty,
1630                        cost_usd = tracing::field::Empty,
1631                        duration_ms = tracing::field::Empty,
1632                    ));
1633                    turn_started = Some(std::time::Instant::now());
1634                    pending = Some((reply, Vec::new()));
1635                }
1636                Some(OutboundMsg::PermissionResponse { request_id, decision }) => {
1637                    if let Err(e) =
1638                        write_permission_response(&mut stdin, &request_id, &decision).await
1639                    {
1640                        warn!(error = %e, "failed to write deferred permission response");
1641                    }
1642                }
1643                Some(OutboundMsg::Interrupt { reply }) => {
1644                    next_control_id += 1;
1645                    let request_id = format!("interrupt-{next_control_id}");
1646                    if let Err(e) =
1647                        write_control_request(&mut stdin, &request_id, "interrupt").await
1648                    {
1649                        let _ = reply.send(Err(e));
1650                        continue;
1651                    }
1652                    pending_control.insert(request_id, reply);
1653                }
1654                None => break,
1655            },
1656        }
1657    }
1658
1659    drop(stdin);
1660    match tokio::time::timeout(SHUTDOWN_BUDGET, child.wait()).await {
1661        Ok(Ok(_status)) => {
1662            group.disarm();
1663        }
1664        Ok(Err(e)) => {
1665            warn!(error = %e, "failed to wait for duplex child");
1666        }
1667        Err(_) => {
1668            warn!("duplex child did not exit within shutdown budget; killing");
1669            // Take down the whole group (Unix), honoring the optional
1670            // SIGTERM grace, so subprocesses spawned for tool use die
1671            // too, then kill+reap the direct child.
1672            crate::exec::kill_group_with_grace(&mut group, kill_grace).await;
1673            let _ = child.kill().await;
1674        }
1675    }
1676
1677    if let Some((reply, _)) = pending.take() {
1678        let _ = reply.send(Err(Error::DuplexClosed));
1679    }
1680    for (_, reply) in pending_control.drain() {
1681        let _ = reply.send(Err(Error::DuplexClosed));
1682    }
1683
1684    let result = match stream_err {
1685        Some(e) => Err(e),
1686        None => Ok(()),
1687    };
1688    let final_state = match &result {
1689        Ok(()) => SessionExitStatus::Completed,
1690        Err(e) => SessionExitStatus::Failed(e.to_string()),
1691    };
1692    session_span.record("turns", turns);
1693    session_span.record(
1694        "exit",
1695        match &final_state {
1696            SessionExitStatus::Completed => "completed",
1697            SessionExitStatus::Failed(_) => "failed",
1698            SessionExitStatus::Running => "running",
1699        },
1700    );
1701    let _ = exit_tx.send(final_state);
1702    result
1703}
1704
1705/// Action returned from [`handle_inbound`] for the run loop to act
1706/// on after the side-effects (broadcast, accumulate, resolve) are
1707/// done.
1708enum InboundAction {
1709    /// No further action -- side-effects were all handled inline.
1710    None,
1711    /// A `control_request {subtype: "can_use_tool"}` was received and
1712    /// needs the [`PermissionHandler`] invoked. The run loop awaits
1713    /// the handler and writes the response.
1714    Permission(PermissionRequest),
1715    /// A `control_response` matching one of our outbound
1716    /// `control_request`s arrived. The run loop matches `request_id`
1717    /// against its `pending_control` table and resolves the
1718    /// corresponding oneshot.
1719    ControlResponse {
1720        request_id: String,
1721        outcome: Result<()>,
1722    },
1723}
1724
1725fn handle_inbound(
1726    msg: Value,
1727    pending: &mut Option<(oneshot::Sender<Result<TurnResult>>, Vec<Value>)>,
1728    events_tx: &broadcast::Sender<InboundEvent>,
1729) -> InboundAction {
1730    match msg.get("type").and_then(Value::as_str) {
1731        Some("result") => {
1732            if let Some((reply, events)) = pending.take() {
1733                let _ = reply.send(Ok(TurnResult {
1734                    result: msg,
1735                    events,
1736                }));
1737            } else {
1738                debug!("dropping orphan result event with no pending turn");
1739            }
1740            InboundAction::None
1741        }
1742        Some("control_request") => {
1743            // can_use_tool flows through the permission handler;
1744            // anything else is logged + accumulated as Other for now.
1745            if msg
1746                .get("request")
1747                .and_then(|r| r.get("subtype"))
1748                .and_then(Value::as_str)
1749                == Some("can_use_tool")
1750                && let Some(req) = parse_permission_request(&msg)
1751            {
1752                if let Some((_, events)) = pending.as_mut() {
1753                    events.push(msg);
1754                }
1755                return InboundAction::Permission(req);
1756            }
1757            debug!(
1758                ?msg,
1759                "received unhandled control_request; treating as Other"
1760            );
1761            let _ = events_tx.send(InboundEvent::Other(msg.clone()));
1762            if let Some((_, events)) = pending.as_mut() {
1763                events.push(msg);
1764            }
1765            InboundAction::None
1766        }
1767        Some("control_response") => {
1768            if let Some((request_id, outcome)) = parse_control_response(&msg) {
1769                return InboundAction::ControlResponse {
1770                    request_id,
1771                    outcome,
1772                };
1773            }
1774            debug!(
1775                ?msg,
1776                "received malformed control_response; treating as Other"
1777            );
1778            let _ = events_tx.send(InboundEvent::Other(msg.clone()));
1779            if let Some((_, events)) = pending.as_mut() {
1780                events.push(msg);
1781            }
1782            InboundAction::None
1783        }
1784        _ => {
1785            // Broadcast a classified copy. Send error means no
1786            // subscribers, which is fine -- subscribers are optional.
1787            let _ = events_tx.send(classify(&msg));
1788
1789            if let Some((_, events)) = pending.as_mut() {
1790                events.push(msg);
1791            } else {
1792                debug!("dropping inbound event with no pending turn");
1793            }
1794            InboundAction::None
1795        }
1796    }
1797}
1798
1799fn parse_permission_request(msg: &Value) -> Option<PermissionRequest> {
1800    let request_id = msg.get("request_id").and_then(Value::as_str)?;
1801    let request = msg.get("request")?;
1802    let tool_name = request.get("tool_name").and_then(Value::as_str)?;
1803    let input = request.get("input").cloned().unwrap_or(Value::Null);
1804    Some(PermissionRequest {
1805        request_id: request_id.to_string(),
1806        tool_name: tool_name.to_string(),
1807        input,
1808        raw: request.clone(),
1809    })
1810}
1811
1812/// Pull `(request_id, outcome)` out of a `control_response` envelope.
1813///
1814/// Returns `None` if `request_id` is missing or the subtype is
1815/// unrecognised. `Some((id, Ok(())))` for `subtype: "success"`,
1816/// `Some((id, Err(DuplexControlFailed)))` for `subtype: "error"`.
1817fn parse_control_response(msg: &Value) -> Option<(String, Result<()>)> {
1818    let response = msg.get("response")?;
1819    let request_id = response.get("request_id").and_then(Value::as_str)?;
1820    let outcome = match response.get("subtype").and_then(Value::as_str) {
1821        Some("success") => Ok(()),
1822        Some("error") => {
1823            let message = response
1824                .get("error")
1825                .and_then(Value::as_str)
1826                .unwrap_or("unknown control_response error")
1827                .to_string();
1828            Err(Error::DuplexControlFailed { message })
1829        }
1830        _ => return None,
1831    };
1832    Some((request_id.to_string(), outcome))
1833}
1834
1835async fn write_user(stdin: &mut ChildStdin, prompt: &str) -> Result<()> {
1836    let user_msg = serde_json::json!({
1837        "type": "user",
1838        "message": {
1839            "role": "user",
1840            "content": prompt,
1841        },
1842        "parent_tool_use_id": null,
1843    });
1844    write_line(stdin, &user_msg, "user message").await
1845}
1846
1847async fn write_control_request(
1848    stdin: &mut ChildStdin,
1849    request_id: &str,
1850    subtype: &str,
1851) -> Result<()> {
1852    let envelope = serde_json::json!({
1853        "type": "control_request",
1854        "request_id": request_id,
1855        "request": { "subtype": subtype },
1856    });
1857    write_line(stdin, &envelope, "control_request").await
1858}
1859
1860async fn write_permission_response(
1861    stdin: &mut ChildStdin,
1862    request_id: &str,
1863    decision: &PermissionDecision,
1864) -> Result<()> {
1865    let inner = match decision {
1866        PermissionDecision::Allow { updated_input } => {
1867            let mut obj = serde_json::Map::new();
1868            obj.insert("behavior".to_string(), Value::String("allow".to_string()));
1869            if let Some(input) = updated_input {
1870                obj.insert("updatedInput".to_string(), input.clone());
1871            }
1872            Value::Object(obj)
1873        }
1874        PermissionDecision::Deny { message } => serde_json::json!({
1875            "behavior": "deny",
1876            "message": message,
1877        }),
1878        PermissionDecision::Defer => {
1879            // Caller path is supposed to filter this; defensive guard.
1880            return Ok(());
1881        }
1882    };
1883    let envelope = serde_json::json!({
1884        "type": "control_response",
1885        "response": {
1886            "request_id": request_id,
1887            "subtype": "success",
1888            "response": inner,
1889        },
1890    });
1891    write_line(stdin, &envelope, "control_response").await
1892}
1893
1894async fn write_line(stdin: &mut ChildStdin, value: &Value, what: &'static str) -> Result<()> {
1895    let mut line = serde_json::to_string(value).map_err(|e| Error::Json {
1896        message: format!("failed to serialize duplex {what}"),
1897        source: e,
1898    })?;
1899    line.push('\n');
1900    stdin
1901        .write_all(line.as_bytes())
1902        .await
1903        .map_err(|e| Error::Io {
1904            message: format!("failed to write {what} to duplex stdin"),
1905            source: e,
1906            working_dir: None,
1907        })?;
1908    stdin.flush().await.map_err(|e| Error::Io {
1909        message: "failed to flush duplex stdin".to_string(),
1910        source: e,
1911        working_dir: None,
1912    })?;
1913    Ok(())
1914}
1915
1916#[cfg(test)]
1917mod tests {
1918    use super::*;
1919    use serde_json::json;
1920
1921    #[test]
1922    fn build_args_default_includes_required_flags() {
1923        let args = DuplexOptions::default().build_args();
1924        assert!(args.contains(&"--print".to_string()));
1925        assert!(args.contains(&"--verbose".to_string()));
1926        assert!(
1927            args.windows(2)
1928                .any(|w| w == ["--output-format", "stream-json"])
1929        );
1930        assert!(
1931            args.windows(2)
1932                .any(|w| w == ["--input-format", "stream-json"])
1933        );
1934    }
1935
1936    #[test]
1937    fn build_args_includes_model() {
1938        let args = DuplexOptions::default().model("haiku").build_args();
1939        assert!(args.windows(2).any(|w| w == ["--model", "haiku"]));
1940    }
1941
1942    #[test]
1943    fn build_args_includes_system_prompts() {
1944        let args = DuplexOptions::default()
1945            .system_prompt("be concise")
1946            .append_system_prompt("also polite")
1947            .build_args();
1948        assert!(
1949            args.windows(2)
1950                .any(|w| w == ["--system-prompt", "be concise"])
1951        );
1952        assert!(
1953            args.windows(2)
1954                .any(|w| w == ["--append-system-prompt", "also polite"])
1955        );
1956    }
1957
1958    #[test]
1959    fn build_args_appends_raw_args_last() {
1960        let args = DuplexOptions::default()
1961            .arg("--add-dir")
1962            .arg("/tmp/foo")
1963            .build_args();
1964        // Last two entries should be the additional args, in order.
1965        assert_eq!(&args[args.len() - 2..], &["--add-dir", "/tmp/foo"]);
1966    }
1967
1968    // ─── to_command_string / spawn parity (#703) ───
1969
1970    fn preview_claude() -> Claude {
1971        Claude::builder()
1972            .binary("/usr/local/bin/claude")
1973            .build()
1974            .unwrap()
1975    }
1976
1977    #[test]
1978    fn spawn_command_args_prepends_global_args() {
1979        let claude = Claude::builder()
1980            .binary("/usr/local/bin/claude")
1981            .arg("--debug")
1982            .build()
1983            .unwrap();
1984        let args = DuplexOptions::default()
1985            .model("haiku")
1986            .spawn_command_args(&claude);
1987        assert_eq!(args[0], "--debug");
1988        assert_eq!(args[1], "--print");
1989        assert!(args.windows(2).any(|w| w == ["--model", "haiku"]));
1990    }
1991
1992    #[test]
1993    fn to_command_string_is_binary_plus_spawn_args() {
1994        // The preview must be exactly the binary plus the args the
1995        // spawn path assembles (no arg here needs quoting).
1996        let claude = Claude::builder()
1997            .binary("/usr/local/bin/claude")
1998            .arg("--debug")
1999            .build()
2000            .unwrap();
2001        let opts = DuplexOptions::default().agent("reviewer");
2002        let expected = format!(
2003            "/usr/local/bin/claude {}",
2004            opts.spawn_command_args(&claude).join(" ")
2005        );
2006        assert_eq!(opts.to_command_string(&claude), expected);
2007    }
2008
2009    #[test]
2010    fn to_command_string_includes_persona_flags() {
2011        // Repro from #703: a spawn configured with an agent, allowed
2012        // tools, and setting sources must show all three in the echo.
2013        let command_str = DuplexOptions::default()
2014            .agent("reviewer")
2015            .allowed_tool("Read")
2016            .allowed_tool("Bash(git:*)")
2017            .setting_sources("project")
2018            .to_command_string(&preview_claude());
2019        assert!(command_str.starts_with("/usr/local/bin/claude"));
2020        assert!(command_str.contains("--agent reviewer"));
2021        assert!(command_str.contains("--allowed-tools 'Read,Bash(git:*)'"));
2022        assert!(command_str.contains("--setting-sources project"));
2023    }
2024
2025    #[test]
2026    fn to_command_string_quotes_args_with_spaces() {
2027        let command_str = DuplexOptions::default()
2028            .system_prompt("be concise")
2029            .to_command_string(&preview_claude());
2030        assert!(command_str.contains("--system-prompt 'be concise'"));
2031    }
2032
2033    #[test]
2034    fn to_command_string_does_not_consume_options() {
2035        // Borrowing preview: the same options remain usable after the
2036        // echo (previewed first, then handed to spawn).
2037        let claude = preview_claude();
2038        let opts = DuplexOptions::default().model("haiku");
2039        let first = opts.to_command_string(&claude);
2040        let second = opts.to_command_string(&claude);
2041        assert_eq!(first, second);
2042    }
2043
2044    #[test]
2045    fn build_args_includes_resume_when_set() {
2046        let args = DuplexOptions::default().resume("abc-123").build_args();
2047        assert!(args.windows(2).any(|w| w == ["--resume", "abc-123"]));
2048    }
2049
2050    #[test]
2051    fn build_args_omits_resume_by_default() {
2052        let args = DuplexOptions::default().build_args();
2053        assert!(
2054            !args.iter().any(|a| a == "--resume"),
2055            "--resume should not appear without an explicit resume(...) call; got {args:?}"
2056        );
2057    }
2058
2059    #[test]
2060    fn build_args_includes_continue_when_set() {
2061        let args = DuplexOptions::default().continue_session().build_args();
2062        assert!(args.iter().any(|a| a == "--continue"));
2063    }
2064
2065    #[test]
2066    fn build_args_omits_continue_by_default() {
2067        let args = DuplexOptions::default().build_args();
2068        assert!(!args.iter().any(|a| a == "--continue"));
2069    }
2070
2071    #[test]
2072    fn build_args_includes_worktree_flag_without_name() {
2073        let args = DuplexOptions::default().worktree(None::<&str>).build_args();
2074        assert!(args.iter().any(|a| a == "--worktree"));
2075        // No name means no positional follows --worktree.
2076        let pos = args.iter().position(|a| a == "--worktree").unwrap();
2077        assert!(
2078            args.get(pos + 1).is_none_or(|a| a.starts_with("--")),
2079            "--worktree without a name should not be followed by a positional; got {args:?}"
2080        );
2081    }
2082
2083    #[test]
2084    fn build_args_includes_worktree_flag_with_name() {
2085        let args = DuplexOptions::default()
2086            .worktree(Some("agent-xyz"))
2087            .build_args();
2088        let pos = args.iter().position(|a| a == "--worktree").unwrap();
2089        assert_eq!(args.get(pos + 1).map(String::as_str), Some("agent-xyz"));
2090    }
2091
2092    #[test]
2093    fn build_args_omits_worktree_by_default() {
2094        let args = DuplexOptions::default().build_args();
2095        assert!(
2096            !args.iter().any(|a| a == "--worktree"),
2097            "--worktree should not appear without an explicit worktree(...) call; got {args:?}"
2098        );
2099    }
2100
2101    #[test]
2102    fn worktree_lands_before_additional_args() {
2103        // Same `--` ordering bug class as resume.
2104        let args = DuplexOptions::default()
2105            .worktree(Some("foo"))
2106            .arg("--")
2107            .arg("trailing")
2108            .build_args();
2109        let wt_pos = args.iter().position(|a| a == "--worktree").unwrap();
2110        let dash_dash_pos = args.iter().position(|a| a == "--").unwrap();
2111        assert!(
2112            wt_pos < dash_dash_pos,
2113            "--worktree must precede `--` separator; got {args:?}"
2114        );
2115    }
2116
2117    #[test]
2118    fn build_args_includes_agent_when_set() {
2119        let args = DuplexOptions::default().agent("rust-qa").build_args();
2120        assert!(
2121            args.windows(2).any(|w| w == ["--agent", "rust-qa"]),
2122            "missing --agent rust-qa in {args:?}"
2123        );
2124    }
2125
2126    #[test]
2127    fn build_args_omits_agent_by_default() {
2128        let args = DuplexOptions::default().build_args();
2129        assert!(
2130            !args.iter().any(|a| a == "--agent"),
2131            "--agent should not appear without an explicit agent(...) call; got {args:?}"
2132        );
2133    }
2134
2135    #[test]
2136    fn build_args_includes_agents_json_when_set() {
2137        let json = r#"{"reviewer":{"description":"r","prompt":"p"}}"#;
2138        let args = DuplexOptions::default().agents_json(json).build_args();
2139        let pos = args.iter().position(|a| a == "--agents").unwrap();
2140        assert_eq!(args.get(pos + 1).map(String::as_str), Some(json));
2141    }
2142
2143    #[test]
2144    fn build_args_omits_agents_json_by_default() {
2145        let args = DuplexOptions::default().build_args();
2146        assert!(!args.iter().any(|a| a == "--agents"));
2147    }
2148
2149    #[test]
2150    fn agent_and_agents_json_compose() {
2151        let json = r#"{"reviewer":{"description":"r","prompt":"p"}}"#;
2152        let args = DuplexOptions::default()
2153            .agents_json(json)
2154            .agent("reviewer")
2155            .build_args();
2156        // Both flags present.
2157        assert!(args.iter().any(|a| a == "--agents"));
2158        assert!(args.iter().any(|a| a == "--agent"));
2159    }
2160
2161    #[test]
2162    fn agent_lands_before_additional_args() {
2163        let args = DuplexOptions::default()
2164            .agent("rust-qa")
2165            .arg("--")
2166            .arg("trailing")
2167            .build_args();
2168        let agent_pos = args.iter().position(|a| a == "--agent").unwrap();
2169        let dash_dash_pos = args.iter().position(|a| a == "--").unwrap();
2170        assert!(
2171            agent_pos < dash_dash_pos,
2172            "--agent must precede `--` separator; got {args:?}"
2173        );
2174    }
2175
2176    #[test]
2177    fn agents_json_lands_before_additional_args() {
2178        let args = DuplexOptions::default()
2179            .agents_json("{}")
2180            .arg("--")
2181            .arg("trailing")
2182            .build_args();
2183        let agents_pos = args.iter().position(|a| a == "--agents").unwrap();
2184        let dash_dash_pos = args.iter().position(|a| a == "--").unwrap();
2185        assert!(
2186            agents_pos < dash_dash_pos,
2187            "--agents must precede `--` separator; got {args:?}"
2188        );
2189    }
2190
2191    // -- QueryCommand knob-set parity (#672) -------------------------
2192
2193    #[test]
2194    fn build_args_includes_session_id() {
2195        let args = DuplexOptions::default().session_id("sid-9").build_args();
2196        assert!(args.windows(2).any(|w| w == ["--session-id", "sid-9"]));
2197    }
2198
2199    #[test]
2200    fn build_args_includes_setting_sources() {
2201        let args = DuplexOptions::default()
2202            .setting_sources("user,project")
2203            .build_args();
2204        assert!(
2205            args.windows(2)
2206                .any(|w| w == ["--setting-sources", "user,project"]),
2207            "got {args:?}"
2208        );
2209    }
2210
2211    #[test]
2212    fn build_args_omits_setting_sources_by_default() {
2213        let args = DuplexOptions::default().build_args();
2214        assert!(!args.iter().any(|a| a == "--setting-sources"));
2215    }
2216
2217    #[test]
2218    fn build_args_hermetic_emits_full_seal() {
2219        let args = DuplexOptions::default().hermetic().build_args();
2220        assert!(
2221            args.windows(2)
2222                .any(|w| w[0] == "--setting-sources" && w[1].is_empty()),
2223            "got {args:?}"
2224        );
2225        assert!(args.iter().any(|a| a == "--strict-mcp-config"));
2226        assert!(
2227            args.iter()
2228                .any(|a| a == "--exclude-dynamic-system-prompt-sections")
2229        );
2230        // A hermetic seal must never imply --bare.
2231        assert!(!args.iter().any(|a| a == "--bare"));
2232    }
2233
2234    #[test]
2235    fn build_args_hermetic_scoped_project_keeps_user() {
2236        let args = DuplexOptions::default()
2237            .hermetic_scoped(HermeticScope::Project)
2238            .build_args();
2239        assert!(args.windows(2).any(|w| w == ["--setting-sources", "user"]));
2240        assert!(args.iter().any(|a| a == "--strict-mcp-config"));
2241    }
2242
2243    #[test]
2244    fn build_args_includes_json_schema() {
2245        let schema = r#"{"type":"object"}"#;
2246        let args = DuplexOptions::default().json_schema(schema).build_args();
2247        assert!(args.windows(2).any(|w| w == ["--json-schema", schema]));
2248    }
2249
2250    #[test]
2251    fn build_args_joins_allowed_tools_comma_separated() {
2252        let args = DuplexOptions::default()
2253            .allowed_tools(["Read", "Bash(git log:*)"])
2254            .allowed_tool("Write")
2255            .build_args();
2256        assert!(
2257            args.windows(2)
2258                .any(|w| w == ["--allowed-tools", "Read,Bash(git log:*),Write"]),
2259            "missing joined --allowed-tools in {args:?}"
2260        );
2261    }
2262
2263    #[test]
2264    fn build_args_joins_disallowed_tools_comma_separated() {
2265        let args = DuplexOptions::default()
2266            .disallowed_tools(["WebSearch"])
2267            .disallowed_tool("WebFetch")
2268            .build_args();
2269        assert!(
2270            args.windows(2)
2271                .any(|w| w == ["--disallowed-tools", "WebSearch,WebFetch"]),
2272            "missing joined --disallowed-tools in {args:?}"
2273        );
2274    }
2275
2276    #[test]
2277    fn build_args_includes_caps() {
2278        let args = DuplexOptions::default()
2279            .max_turns(4)
2280            .max_budget_usd(0.25)
2281            .build_args();
2282        assert!(args.windows(2).any(|w| w == ["--max-turns", "4"]));
2283        assert!(args.windows(2).any(|w| w == ["--max-budget-usd", "0.25"]));
2284    }
2285
2286    #[test]
2287    fn build_args_includes_fallback_model_and_effort() {
2288        let args = DuplexOptions::default()
2289            .fallback_model("haiku")
2290            .effort(Effort::Low)
2291            .build_args();
2292        assert!(args.windows(2).any(|w| w == ["--fallback-model", "haiku"]));
2293        assert!(args.windows(2).any(|w| w == ["--effort", "low"]));
2294    }
2295
2296    #[test]
2297    fn build_args_repeats_add_dir_and_mcp_config() {
2298        let args = DuplexOptions::default()
2299            .add_dir("/a")
2300            .add_dir("/b")
2301            .mcp_config("x.json")
2302            .strict_mcp_config()
2303            .build_args();
2304        assert!(args.windows(2).any(|w| w == ["--add-dir", "/a"]));
2305        assert!(args.windows(2).any(|w| w == ["--add-dir", "/b"]));
2306        assert!(args.windows(2).any(|w| w == ["--mcp-config", "x.json"]));
2307        assert!(args.iter().any(|a| a == "--strict-mcp-config"));
2308    }
2309
2310    #[test]
2311    fn build_args_includes_no_session_persistence() {
2312        let args = DuplexOptions::default()
2313            .no_session_persistence()
2314            .build_args();
2315        assert!(args.iter().any(|a| a == "--no-session-persistence"));
2316    }
2317
2318    // ─── #690: parity builders promoted from QueryCommand ───
2319
2320    #[test]
2321    fn build_args_joins_tools_comma_separated() {
2322        let args = DuplexOptions::default()
2323            .tools(["Bash", "Read", "Edit"])
2324            .build_args();
2325        assert!(
2326            args.windows(2).any(|w| w == ["--tools", "Bash,Read,Edit"]),
2327            "missing joined --tools in {args:?}"
2328        );
2329    }
2330
2331    #[test]
2332    fn build_args_repeats_file_per_spec() {
2333        let args = DuplexOptions::default()
2334            .file("file_a:doc.txt")
2335            .file("file_b:notes.md")
2336            .build_args();
2337        assert_eq!(args.iter().filter(|a| *a == "--file").count(), 2);
2338        assert!(args.iter().any(|a| a == "file_a:doc.txt"));
2339        assert!(args.iter().any(|a| a == "file_b:notes.md"));
2340    }
2341
2342    #[test]
2343    fn build_args_includes_settings() {
2344        let args = DuplexOptions::default()
2345            .settings("/tmp/settings.json")
2346            .build_args();
2347        assert!(
2348            args.windows(2)
2349                .any(|w| w == ["--settings", "/tmp/settings.json"])
2350        );
2351    }
2352
2353    #[test]
2354    fn build_args_includes_fork_session() {
2355        let args = DuplexOptions::default().fork_session().build_args();
2356        assert!(args.iter().any(|a| a == "--fork-session"));
2357    }
2358
2359    #[test]
2360    fn build_args_includes_debug_filter_and_file() {
2361        let args = DuplexOptions::default()
2362            .debug_filter("api,hooks")
2363            .debug_file("/tmp/debug.log")
2364            .build_args();
2365        assert!(args.windows(2).any(|w| w == ["--debug", "api,hooks"]));
2366        assert!(
2367            args.windows(2)
2368                .any(|w| w == ["--debug-file", "/tmp/debug.log"])
2369        );
2370    }
2371
2372    #[test]
2373    fn build_args_includes_betas() {
2374        let args = DuplexOptions::default().betas("feature-x").build_args();
2375        assert!(args.windows(2).any(|w| w == ["--betas", "feature-x"]));
2376    }
2377
2378    #[test]
2379    fn build_args_repeats_plugin_dir_and_url() {
2380        let args = DuplexOptions::default()
2381            .plugin_dir("/plugins/a")
2382            .plugin_dir("/plugins/b")
2383            .plugin_url("https://example.com/p.zip")
2384            .build_args();
2385        assert_eq!(args.iter().filter(|a| *a == "--plugin-dir").count(), 2);
2386        assert!(
2387            args.windows(2)
2388                .any(|w| w == ["--plugin-url", "https://example.com/p.zip"])
2389        );
2390    }
2391
2392    #[test]
2393    fn build_args_includes_bare_family_bool_flags() {
2394        let args = DuplexOptions::default()
2395            .tmux()
2396            .bare()
2397            .safe_mode()
2398            .disable_slash_commands()
2399            .include_hook_events()
2400            .exclude_dynamic_system_prompt_sections()
2401            .build_args();
2402        for flag in [
2403            "--tmux",
2404            "--bare",
2405            "--safe-mode",
2406            "--disable-slash-commands",
2407            "--include-hook-events",
2408            "--exclude-dynamic-system-prompt-sections",
2409        ] {
2410            assert!(args.iter().any(|a| a == flag), "missing {flag} in {args:?}");
2411        }
2412    }
2413
2414    #[test]
2415    fn build_args_includes_name() {
2416        let args = DuplexOptions::default().name("my session").build_args();
2417        assert!(args.windows(2).any(|w| w == ["--name", "my session"]));
2418    }
2419
2420    #[test]
2421    fn build_args_omits_promoted_parity_flags_by_default() {
2422        let args = DuplexOptions::default().build_args();
2423        for flag in [
2424            "--tools",
2425            "--file",
2426            "--settings",
2427            "--fork-session",
2428            "--debug",
2429            "--debug-file",
2430            "--betas",
2431            "--plugin-dir",
2432            "--plugin-url",
2433            "--tmux",
2434            "--bare",
2435            "--safe-mode",
2436            "--disable-slash-commands",
2437            "--include-hook-events",
2438            "--exclude-dynamic-system-prompt-sections",
2439            "--name",
2440        ] {
2441            assert!(
2442                !args.iter().any(|a| a == flag),
2443                "{flag} should be absent by default; got {args:?}"
2444            );
2445        }
2446    }
2447
2448    #[test]
2449    fn parity_flags_land_before_additional_args() {
2450        // Same `--` ordering bug class as resume/agent.
2451        let args = DuplexOptions::default()
2452            .max_turns(2)
2453            .json_schema("{}")
2454            .arg("--")
2455            .arg("trailing")
2456            .build_args();
2457        let dash_dash_pos = args.iter().position(|a| a == "--").unwrap();
2458        for flag in ["--max-turns", "--json-schema"] {
2459            let pos = args.iter().position(|a| a == flag).unwrap();
2460            assert!(
2461                pos < dash_dash_pos,
2462                "{flag} must precede `--` separator; got {args:?}"
2463            );
2464        }
2465    }
2466
2467    #[test]
2468    fn build_args_omits_parity_flags_by_default() {
2469        let args = DuplexOptions::default().build_args();
2470        for flag in [
2471            "--session-id",
2472            "--json-schema",
2473            "--allowed-tools",
2474            "--disallowed-tools",
2475            "--max-turns",
2476            "--max-budget-usd",
2477            "--fallback-model",
2478            "--effort",
2479            "--add-dir",
2480            "--mcp-config",
2481            "--strict-mcp-config",
2482            "--no-session-persistence",
2483        ] {
2484            assert!(
2485                !args.iter().any(|a| a == flag),
2486                "{flag} should not appear by default; got {args:?}"
2487            );
2488        }
2489    }
2490
2491    #[test]
2492    fn resume_lands_before_additional_args() {
2493        // Catches the same class of bug as QueryCommand::execute_json
2494        // had: a flag appended after the user-supplied raw args (which
2495        // typically include `--`) gets eaten as a positional. Resume
2496        // must precede any caller-injected `arg(...)`.
2497        let args = DuplexOptions::default()
2498            .resume("xyz")
2499            .arg("--")
2500            .arg("trailing")
2501            .build_args();
2502        let resume_pos = args.iter().position(|a| a == "--resume").unwrap();
2503        let dash_dash_pos = args.iter().position(|a| a == "--").unwrap();
2504        assert!(
2505            resume_pos < dash_dash_pos,
2506            "--resume must precede `--` separator; got {args:?}"
2507        );
2508    }
2509
2510    #[test]
2511    fn turn_result_accessors_pull_from_result() {
2512        let r = TurnResult {
2513            result: json!({
2514                "type": "result",
2515                "result": "hello",
2516                "session_id": "sess-123",
2517                "total_cost_usd": 0.0042,
2518                "duration_ms": 1234_u64,
2519            }),
2520            events: vec![],
2521        };
2522        assert_eq!(r.result_text(), Some("hello"));
2523        assert_eq!(r.session_id(), Some("sess-123"));
2524        assert_eq!(r.total_cost_usd(), Some(0.0042));
2525        assert_eq!(r.duration_ms(), Some(1234));
2526    }
2527
2528    #[test]
2529    fn turn_result_total_cost_falls_back_to_legacy_field() {
2530        let r = TurnResult {
2531            result: json!({ "cost_usd": 0.5 }),
2532            events: vec![],
2533        };
2534        assert_eq!(r.total_cost_usd(), Some(0.5));
2535    }
2536
2537    #[test]
2538    fn turn_result_accessors_return_none_when_missing() {
2539        let r = TurnResult {
2540            result: json!({}),
2541            events: vec![],
2542        };
2543        assert_eq!(r.result_text(), None);
2544        assert_eq!(r.session_id(), None);
2545        assert_eq!(r.total_cost_usd(), None);
2546        assert_eq!(r.duration_ms(), None);
2547    }
2548
2549    #[test]
2550    fn handle_inbound_appends_non_result_to_pending_events() {
2551        let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
2552        let (events_tx, _events_rx) = broadcast::channel(16);
2553        let mut pending = Some((tx, Vec::new()));
2554        handle_inbound(
2555            json!({ "type": "assistant", "message": {} }),
2556            &mut pending,
2557            &events_tx,
2558        );
2559        let (_, events) = pending.as_ref().unwrap();
2560        assert_eq!(events.len(), 1);
2561        assert_eq!(
2562            events[0].get("type").and_then(Value::as_str),
2563            Some("assistant")
2564        );
2565    }
2566
2567    #[test]
2568    fn handle_inbound_resolves_pending_on_result() {
2569        let (tx, rx) = oneshot::channel::<Result<TurnResult>>();
2570        let (events_tx, _events_rx) = broadcast::channel(16);
2571        let mut pending = Some((tx, vec![json!({ "type": "assistant" })]));
2572        handle_inbound(
2573            json!({ "type": "result", "result": "ok" }),
2574            &mut pending,
2575            &events_tx,
2576        );
2577        assert!(pending.is_none());
2578        let received = rx.blocking_recv().unwrap().unwrap();
2579        assert_eq!(received.result_text(), Some("ok"));
2580        assert_eq!(received.events.len(), 1);
2581    }
2582
2583    #[test]
2584    fn handle_inbound_drops_orphans_without_pending_turn() {
2585        let (events_tx, _events_rx) = broadcast::channel(16);
2586        let mut pending: Option<(oneshot::Sender<Result<TurnResult>>, Vec<Value>)> = None;
2587        handle_inbound(json!({ "type": "assistant" }), &mut pending, &events_tx);
2588        handle_inbound(
2589            json!({ "type": "result", "result": "ok" }),
2590            &mut pending,
2591            &events_tx,
2592        );
2593        assert!(pending.is_none());
2594    }
2595
2596    #[test]
2597    fn handle_inbound_broadcasts_classified_event() {
2598        let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
2599        let (events_tx, mut events_rx) = broadcast::channel(16);
2600        let mut pending = Some((tx, Vec::new()));
2601        handle_inbound(
2602            json!({ "type": "assistant", "message": { "role": "assistant" } }),
2603            &mut pending,
2604            &events_tx,
2605        );
2606        let event = events_rx.try_recv().expect("classified event broadcast");
2607        assert!(matches!(event, InboundEvent::Assistant(_)));
2608    }
2609
2610    #[test]
2611    fn handle_inbound_does_not_broadcast_result() {
2612        let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
2613        let (events_tx, mut events_rx) = broadcast::channel(16);
2614        let mut pending = Some((tx, Vec::new()));
2615        handle_inbound(
2616            json!({ "type": "result", "result": "ok" }),
2617            &mut pending,
2618            &events_tx,
2619        );
2620        // Result is not broadcast -- it lands in TurnResult.result.
2621        assert!(events_rx.try_recv().is_err());
2622    }
2623
2624    #[test]
2625    fn classify_system_init_pulls_session_id() {
2626        let v = json!({
2627            "type": "system",
2628            "subtype": "init",
2629            "session_id": "sess-abc",
2630        });
2631        match classify(&v) {
2632            InboundEvent::SystemInit { session_id } => assert_eq!(session_id, "sess-abc"),
2633            other => panic!("expected SystemInit, got {other:?}"),
2634        }
2635    }
2636
2637    #[test]
2638    fn classify_system_without_init_subtype_is_other() {
2639        let v = json!({ "type": "system", "subtype": "compaction" });
2640        assert!(matches!(classify(&v), InboundEvent::Other(_)));
2641    }
2642
2643    #[test]
2644    fn classify_system_init_without_session_id_is_other() {
2645        let v = json!({ "type": "system", "subtype": "init" });
2646        assert!(matches!(classify(&v), InboundEvent::Other(_)));
2647    }
2648
2649    #[test]
2650    fn classify_assistant_stream_event_user() {
2651        assert!(matches!(
2652            classify(&json!({ "type": "assistant" })),
2653            InboundEvent::Assistant(_)
2654        ));
2655        assert!(matches!(
2656            classify(&json!({ "type": "stream_event" })),
2657            InboundEvent::StreamEvent(_)
2658        ));
2659        assert!(matches!(
2660            classify(&json!({ "type": "user" })),
2661            InboundEvent::User(_)
2662        ));
2663    }
2664
2665    #[test]
2666    fn classify_unknown_type_is_other() {
2667        assert!(matches!(
2668            classify(&json!({ "type": "control_request" })),
2669            InboundEvent::Other(_)
2670        ));
2671        assert!(matches!(
2672            classify(&json!({ "type": "future_thing" })),
2673            InboundEvent::Other(_)
2674        ));
2675        assert!(matches!(classify(&json!({})), InboundEvent::Other(_)));
2676    }
2677
2678    #[test]
2679    fn build_args_does_not_emit_subscriber_capacity_flag() {
2680        // subscriber_capacity is runtime config, not a CLI arg.
2681        let args = DuplexOptions::default()
2682            .subscriber_capacity(64)
2683            .build_args();
2684        assert!(!args.iter().any(|a| a.contains("subscriber")));
2685        assert!(!args.iter().any(|a| a.contains("capacity")));
2686    }
2687
2688    #[test]
2689    fn build_args_includes_permission_prompt_tool_when_handler_set() {
2690        let handler = PermissionHandler::new(|_req| async move {
2691            PermissionDecision::Allow {
2692                updated_input: None,
2693            }
2694        });
2695        let args = DuplexOptions::default().on_permission(handler).build_args();
2696        assert!(
2697            args.windows(2)
2698                .any(|w| w == ["--permission-prompt-tool", "stdio"])
2699        );
2700    }
2701
2702    #[test]
2703    fn build_args_omits_permission_prompt_tool_without_handler() {
2704        let args = DuplexOptions::default().build_args();
2705        assert!(!args.iter().any(|a| a == "--permission-prompt-tool"));
2706    }
2707
2708    #[test]
2709    fn build_args_emits_permission_mode_flag() {
2710        let args = DuplexOptions::default()
2711            .permission_mode(PermissionMode::AcceptEdits)
2712            .build_args();
2713        assert!(
2714            args.windows(2)
2715                .any(|w| w == ["--permission-mode", "acceptEdits"]),
2716            "missing --permission-mode acceptEdits in {args:?}"
2717        );
2718    }
2719
2720    #[test]
2721    fn build_args_emits_plan_mode() {
2722        let args = DuplexOptions::default()
2723            .permission_mode(PermissionMode::Plan)
2724            .build_args();
2725        assert!(args.windows(2).any(|w| w == ["--permission-mode", "plan"]));
2726    }
2727
2728    #[test]
2729    fn build_args_omits_permission_mode_by_default() {
2730        let args = DuplexOptions::default().build_args();
2731        assert!(!args.iter().any(|a| a == "--permission-mode"));
2732    }
2733
2734    #[test]
2735    fn build_args_emits_dangerously_skip_permissions_flag() {
2736        let args = DuplexOptions::default()
2737            .dangerously_skip_permissions()
2738            .build_args();
2739        assert!(args.iter().any(|a| a == "--dangerously-skip-permissions"));
2740    }
2741
2742    #[test]
2743    fn build_args_omits_dangerously_skip_by_default() {
2744        let args = DuplexOptions::default().build_args();
2745        assert!(!args.iter().any(|a| a == "--dangerously-skip-permissions"));
2746    }
2747
2748    #[test]
2749    fn parse_permission_request_extracts_fields() {
2750        let msg = json!({
2751            "type": "control_request",
2752            "request_id": "req-1",
2753            "request": {
2754                "subtype": "can_use_tool",
2755                "tool_name": "Bash",
2756                "input": { "command": "ls" }
2757            }
2758        });
2759        let req = parse_permission_request(&msg).expect("permission request");
2760        assert_eq!(req.request_id, "req-1");
2761        assert_eq!(req.tool_name, "Bash");
2762        assert_eq!(req.input, json!({ "command": "ls" }));
2763        assert_eq!(
2764            req.raw.get("subtype").and_then(Value::as_str),
2765            Some("can_use_tool")
2766        );
2767    }
2768
2769    #[test]
2770    fn parse_permission_request_returns_none_when_missing_request_id() {
2771        let msg = json!({
2772            "type": "control_request",
2773            "request": {
2774                "subtype": "can_use_tool",
2775                "tool_name": "Bash",
2776            }
2777        });
2778        assert!(parse_permission_request(&msg).is_none());
2779    }
2780
2781    #[test]
2782    fn parse_permission_request_returns_none_when_missing_tool_name() {
2783        let msg = json!({
2784            "type": "control_request",
2785            "request_id": "req-1",
2786            "request": { "subtype": "can_use_tool" }
2787        });
2788        assert!(parse_permission_request(&msg).is_none());
2789    }
2790
2791    #[test]
2792    fn parse_permission_request_handles_missing_input() {
2793        let msg = json!({
2794            "type": "control_request",
2795            "request_id": "req-1",
2796            "request": {
2797                "subtype": "can_use_tool",
2798                "tool_name": "Bash",
2799            }
2800        });
2801        let req = parse_permission_request(&msg).expect("request");
2802        assert_eq!(req.input, Value::Null);
2803    }
2804
2805    #[test]
2806    fn handle_inbound_returns_permission_for_can_use_tool() {
2807        let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
2808        let (events_tx, _events_rx) = broadcast::channel(16);
2809        let mut pending = Some((tx, Vec::new()));
2810        let action = handle_inbound(
2811            json!({
2812                "type": "control_request",
2813                "request_id": "req-1",
2814                "request": {
2815                    "subtype": "can_use_tool",
2816                    "tool_name": "Bash",
2817                    "input": { "command": "ls" }
2818                }
2819            }),
2820            &mut pending,
2821            &events_tx,
2822        );
2823        match action {
2824            InboundAction::Permission(req) => {
2825                assert_eq!(req.request_id, "req-1");
2826                assert_eq!(req.tool_name, "Bash");
2827            }
2828            InboundAction::None | InboundAction::ControlResponse { .. } => {
2829                panic!("expected Permission action");
2830            }
2831        }
2832        // Event should also be accumulated in the pending turn.
2833        let (_, events) = pending.as_ref().unwrap();
2834        assert_eq!(events.len(), 1);
2835    }
2836
2837    #[test]
2838    fn handle_inbound_treats_unknown_control_request_as_other() {
2839        let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
2840        let (events_tx, mut events_rx) = broadcast::channel(16);
2841        let mut pending = Some((tx, Vec::new()));
2842        let action = handle_inbound(
2843            json!({
2844                "type": "control_request",
2845                "request_id": "req-2",
2846                "request": { "subtype": "future_subtype" }
2847            }),
2848            &mut pending,
2849            &events_tx,
2850        );
2851        assert!(matches!(action, InboundAction::None));
2852        let event = events_rx.try_recv().expect("broadcast");
2853        assert!(matches!(event, InboundEvent::Other(_)));
2854    }
2855
2856    #[tokio::test]
2857    async fn permission_handler_invokes_closure_async() {
2858        let handler = PermissionHandler::new(|req| async move {
2859            if req.tool_name == "Bash" {
2860                PermissionDecision::Deny {
2861                    message: "no bash".into(),
2862                }
2863            } else {
2864                PermissionDecision::Allow {
2865                    updated_input: None,
2866                }
2867            }
2868        });
2869        let req = PermissionRequest {
2870            request_id: "r1".into(),
2871            tool_name: "Bash".into(),
2872            input: Value::Null,
2873            raw: Value::Null,
2874        };
2875        match handler.invoke(req).await {
2876            PermissionDecision::Deny { message } => assert_eq!(message, "no bash"),
2877            other => panic!("expected Deny, got {other:?}"),
2878        }
2879    }
2880
2881    #[test]
2882    fn parse_control_response_extracts_success() {
2883        let msg = json!({
2884            "type": "control_response",
2885            "response": {
2886                "request_id": "interrupt-1",
2887                "subtype": "success",
2888                "response": {}
2889            }
2890        });
2891        let (id, outcome) = parse_control_response(&msg).expect("parsed");
2892        assert_eq!(id, "interrupt-1");
2893        assert!(outcome.is_ok());
2894    }
2895
2896    #[test]
2897    fn parse_control_response_extracts_error_with_message() {
2898        let msg = json!({
2899            "type": "control_response",
2900            "response": {
2901                "request_id": "interrupt-2",
2902                "subtype": "error",
2903                "error": "no turn in flight"
2904            }
2905        });
2906        let (id, outcome) = parse_control_response(&msg).expect("parsed");
2907        assert_eq!(id, "interrupt-2");
2908        match outcome {
2909            Err(Error::DuplexControlFailed { message }) => {
2910                assert_eq!(message, "no turn in flight");
2911            }
2912            other => panic!("expected DuplexControlFailed, got {other:?}"),
2913        }
2914    }
2915
2916    #[test]
2917    fn parse_control_response_returns_none_on_missing_request_id() {
2918        let msg = json!({
2919            "type": "control_response",
2920            "response": { "subtype": "success" }
2921        });
2922        assert!(parse_control_response(&msg).is_none());
2923    }
2924
2925    #[test]
2926    fn parse_control_response_returns_none_on_unknown_subtype() {
2927        let msg = json!({
2928            "type": "control_response",
2929            "response": { "request_id": "x", "subtype": "future_subtype" }
2930        });
2931        assert!(parse_control_response(&msg).is_none());
2932    }
2933
2934    #[test]
2935    fn handle_inbound_returns_control_response_action() {
2936        let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
2937        let (events_tx, _events_rx) = broadcast::channel(16);
2938        let mut pending = Some((tx, Vec::new()));
2939        let action = handle_inbound(
2940            json!({
2941                "type": "control_response",
2942                "response": {
2943                    "request_id": "interrupt-1",
2944                    "subtype": "success",
2945                    "response": {}
2946                }
2947            }),
2948            &mut pending,
2949            &events_tx,
2950        );
2951        match action {
2952            InboundAction::ControlResponse {
2953                request_id,
2954                outcome,
2955            } => {
2956                assert_eq!(request_id, "interrupt-1");
2957                assert!(outcome.is_ok());
2958            }
2959            InboundAction::None | InboundAction::Permission(_) => {
2960                panic!("expected ControlResponse action");
2961            }
2962        }
2963    }
2964
2965    #[test]
2966    fn handle_inbound_treats_malformed_control_response_as_other() {
2967        let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
2968        let (events_tx, mut events_rx) = broadcast::channel(16);
2969        let mut pending = Some((tx, Vec::new()));
2970        let action = handle_inbound(
2971            json!({
2972                "type": "control_response",
2973                "response": { "subtype": "success" }
2974            }),
2975            &mut pending,
2976            &events_tx,
2977        );
2978        assert!(matches!(action, InboundAction::None));
2979        let event = events_rx.try_recv().expect("broadcast");
2980        assert!(matches!(event, InboundEvent::Other(_)));
2981    }
2982
2983    #[tokio::test]
2984    async fn permission_handler_clones_arc() {
2985        let handler = PermissionHandler::new(|_req| async move {
2986            PermissionDecision::Allow {
2987                updated_input: None,
2988            }
2989        });
2990        let cloned = handler.clone();
2991        let req = PermissionRequest {
2992            request_id: "r1".into(),
2993            tool_name: "Read".into(),
2994            input: Value::Null,
2995            raw: Value::Null,
2996        };
2997        // Both handles invoke the same underlying closure.
2998        let _ = handler.invoke(req.clone()).await;
2999        let _ = cloned.invoke(req).await;
3000    }
3001
3002    /// Build a `DuplexSession` whose channels are wired up but whose
3003    /// background task is a no-op. Tests can drive the watch state
3004    /// machine via the returned `exit_tx` and observe the public
3005    /// accessors. The fake task idles on a oneshot so it stays alive
3006    /// for the life of the test (no JoinHandle::abort handshake
3007    /// needed).
3008    fn fake_session(
3009        initial: SessionExitStatus,
3010    ) -> (
3011        DuplexSession,
3012        watch::Sender<SessionExitStatus>,
3013        oneshot::Sender<()>,
3014    ) {
3015        let (outbound_tx, outbound_rx) = mpsc::unbounded_channel::<OutboundMsg>();
3016        let (events_tx, _events_rx) = broadcast::channel::<InboundEvent>(16);
3017        let (exit_tx, exit_rx) = watch::channel(initial);
3018        let (stop_tx, stop_rx) = oneshot::channel::<()>();
3019
3020        let join = tokio::spawn(async move {
3021            let _outbound_rx = outbound_rx;
3022            let _ = stop_rx.await;
3023            Ok::<(), Error>(())
3024        });
3025
3026        let session = DuplexSession {
3027            outbound_tx,
3028            events_tx,
3029            exit_rx,
3030            join,
3031        };
3032        (session, exit_tx, stop_tx)
3033    }
3034
3035    #[tokio::test]
3036    async fn is_alive_true_while_running() {
3037        let (session, _exit_tx, _stop) = fake_session(SessionExitStatus::Running);
3038        assert!(session.is_alive());
3039    }
3040
3041    #[tokio::test]
3042    async fn is_alive_false_after_completed() {
3043        let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
3044        exit_tx.send(SessionExitStatus::Completed).unwrap();
3045        assert!(!session.is_alive());
3046    }
3047
3048    #[tokio::test]
3049    async fn is_alive_false_after_failed() {
3050        let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
3051        exit_tx
3052            .send(SessionExitStatus::Failed("boom".into()))
3053            .unwrap();
3054        assert!(!session.is_alive());
3055    }
3056
3057    #[tokio::test]
3058    async fn exit_status_reports_running_initially() {
3059        let (session, _exit_tx, _stop) = fake_session(SessionExitStatus::Running);
3060        assert!(matches!(session.exit_status(), SessionExitStatus::Running));
3061    }
3062
3063    #[tokio::test]
3064    async fn exit_status_reflects_completed() {
3065        let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
3066        exit_tx.send(SessionExitStatus::Completed).unwrap();
3067        assert!(matches!(
3068            session.exit_status(),
3069            SessionExitStatus::Completed
3070        ));
3071    }
3072
3073    #[tokio::test]
3074    async fn exit_status_reflects_failed_with_message() {
3075        let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
3076        exit_tx
3077            .send(SessionExitStatus::Failed("oh no".into()))
3078            .unwrap();
3079        match session.exit_status() {
3080            SessionExitStatus::Failed(msg) => assert_eq!(msg, "oh no"),
3081            other => panic!("expected Failed, got {other:?}"),
3082        }
3083    }
3084
3085    #[tokio::test]
3086    async fn wait_for_exit_returns_immediately_when_already_terminal() {
3087        let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
3088        exit_tx.send(SessionExitStatus::Completed).unwrap();
3089        let status = tokio::time::timeout(Duration::from_secs(1), session.wait_for_exit())
3090            .await
3091            .expect("wait_for_exit should not block when already terminal");
3092        assert!(matches!(status, SessionExitStatus::Completed));
3093    }
3094
3095    #[tokio::test]
3096    async fn wait_for_exit_blocks_until_state_transitions() {
3097        let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
3098
3099        let waiter = async { session.wait_for_exit().await };
3100        let driver = async {
3101            tokio::time::sleep(Duration::from_millis(20)).await;
3102            exit_tx.send(SessionExitStatus::Completed).unwrap();
3103        };
3104        let (status, ()) = tokio::join!(waiter, driver);
3105        assert!(matches!(status, SessionExitStatus::Completed));
3106    }
3107
3108    #[tokio::test]
3109    async fn wait_for_exit_supports_multiple_observers() {
3110        let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
3111
3112        let waiter1 = async { session.wait_for_exit().await };
3113        let waiter2 = async { session.wait_for_exit().await };
3114        let driver = async {
3115            tokio::time::sleep(Duration::from_millis(20)).await;
3116            exit_tx
3117                .send(SessionExitStatus::Failed("crash".into()))
3118                .unwrap();
3119        };
3120        let (s1, s2, ()) = tokio::join!(waiter1, waiter2, driver);
3121        match s1 {
3122            SessionExitStatus::Failed(msg) => assert_eq!(msg, "crash"),
3123            other => panic!("waiter1 expected Failed, got {other:?}"),
3124        }
3125        match s2 {
3126            SessionExitStatus::Failed(msg) => assert_eq!(msg, "crash"),
3127            other => panic!("waiter2 expected Failed, got {other:?}"),
3128        }
3129    }
3130
3131    #[tokio::test]
3132    async fn wait_for_exit_returns_last_value_when_sender_dropped() {
3133        // Defensive: if exit_tx is dropped without ever publishing a
3134        // terminal value, wait_for_exit should fall back to the last
3135        // observed state rather than hang.
3136        let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
3137        let waiter = async { session.wait_for_exit().await };
3138        let driver = async {
3139            tokio::time::sleep(Duration::from_millis(20)).await;
3140            drop(exit_tx);
3141        };
3142        let (status, ()) = tokio::time::timeout(Duration::from_secs(1), async {
3143            tokio::join!(waiter, driver)
3144        })
3145        .await
3146        .expect("wait_for_exit must not hang when sender is dropped");
3147        assert!(matches!(status, SessionExitStatus::Running));
3148    }
3149}