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