Skip to main content

Module duplex

Module duplex 

Source
Expand description

Long-lived duplex stream-json sessions.

DuplexSession holds a claude subprocess open in --input-format stream-json --output-format stream-json mode for the duration of a conversation. A single child is held open across many turns; user messages are written to its stdin, NDJSON events are read from its stdout and dispatched back to send() callers.

§When to use

DuplexSession is the recommended primitive for long-running hosts that drive multi-turn conversations: agent servers, IDE backends, daemons, chat UIs. Holding the child open across turns amortizes init cost and unlocks capabilities that are awkward or impossible from a transient subprocess: mid-turn permission decisions (PermissionHandler), clean interrupts, and a typed event subscriber stream that fans out events to multiple consumers.

For short-lived processes (CLIs, build scripts, batch jobs, lambdas) where each turn can stand on its own, prefer QueryCommand for one-off calls or Session for transient multi-turn with cumulative cost / history tracking.

§Cost and budget bookkeeping

DuplexSession itself keeps no cross-turn accounting. The cost on each TurnResult is the CLI’s running total for the session, not that turn’s cost (see TurnResult::total_cost_usd). For per-turn cost, cumulative cost, turn history, and a BudgetTracker-enforced spend ceiling (send fails fast with Error::BudgetExceeded once the ceiling is hit), wrap the session in a Conversation – it is a thin bookkeeping layer over this module, not a different transport.

§Resident I/O and process exit

Stderr is drained continuously, including while a permission handler is awaiting its decision. Only the latest 64 KiB of diagnostic bytes are kept; they are included in a nonzero process-exit error. Shutdown drains remaining stdout and gives stderr a bounded final read, so an escaped descendant holding a pipe open cannot make diagnostic collection wait forever.

A turn’s TurnResult and the process exit are separate evidence. A result can arrive before a later process failure. Use DuplexSession::wait_for_exit or DuplexSession::close for process settlement. An observed zero exit completes the session; a nonzero exit or I/O/settlement failure is reported through SessionExitStatus::Failed and the error returned by close.

§Example

use claude_wrapper::Claude;
use claude_wrapper::duplex::{DuplexOptions, DuplexSession};

let claude = Claude::builder().build()?;
let session = DuplexSession::spawn(
    &claude,
    DuplexOptions::default().model("haiku"),
).await?;

let turn = session.send("hello").await?;
if let Some(text) = turn.result_text() {
    println!("{text}");
}

session.close().await?;

§Subscribers

For event-driven UIs that want to react to assistant tokens, tool-use blocks, or system events as they arrive, call DuplexSession::subscribe before issuing a DuplexSession::send. Each receiver gets its own buffered view of the event stream; slow consumers see tokio::sync::broadcast::error::RecvError::Lagged rather than blocking the session task.

use claude_wrapper::Claude;
use claude_wrapper::duplex::{DuplexOptions, DuplexSession, InboundEvent};

let claude = Claude::builder().build()?;
let session = DuplexSession::spawn(&claude, DuplexOptions::default()).await?;

let mut rx = session.subscribe();
let _turn = session.send("hello").await?;

while let Ok(event) = rx.try_recv() {
    match event {
        InboundEvent::SystemInit { session_id } => {
            println!("session id: {session_id}");
        }
        InboundEvent::Assistant(_) => {
            // partial or complete assistant message
        }
        _ => {}
    }
}

session.close().await?;

For interleaved (concurrent) event handling while a turn is in flight, drive rx.recv() and the send() future together via tokio::select!. Pin the send future and use a block scope so its borrow of the session ends before DuplexSession::close.

§Mid-turn permission decisions

Configure a PermissionHandler at spawn time to answer the CLI’s permission prompts in-flight. The session writes --permission-prompt-tool stdio automatically when a handler is set, so the CLI emits control_request messages for tool use over the duplex channel rather than blocking on a TUI prompt.

use claude_wrapper::Claude;
use claude_wrapper::duplex::{
    DuplexOptions, DuplexSession, PermissionDecision, PermissionHandler,
};

let handler = PermissionHandler::new(|req| async move {
    if req.tool_name == "Bash" {
        PermissionDecision::Deny { message: "bash is denied".into() }
    } else {
        PermissionDecision::Allow { updated_input: None }
    }
});

let claude = Claude::builder().build()?;
let session = DuplexSession::spawn(
    &claude,
    DuplexOptions::default().on_permission(handler),
).await?;

For human-in-the-loop UIs, return PermissionDecision::Defer from the handler, capture the PermissionRequest::request_id, and answer later via DuplexSession::respond_to_permission.

The callback is the CLI’s approval fallback, so an already-approved tool does not necessarily reach it. For tools that must ask, configure an explicit ask rule in DuplexOptions::settings, for example {"permissions":{"ask":["Bash"]}}. Do not also auto-approve that action. Native denial and deferred-denial regressions exercise the resident transport at the supported 2.1.220 and 2.1.303 endpoints. They do not add SDK hook setup or change the CLI’s permission evaluation rules. See the official permission guide.

§Mid-turn interrupt

DuplexSession::interrupt sends a clean control_request {subtype: "interrupt"} to the CLI. The CLI stops generating, closes the in-flight turn (send().await resolves with the truncated TurnResult), and answers our interrupt with a control_response. Use this instead of dropping the session or killing the child when you want to cancel one turn but keep the conversation going.

use std::time::Duration;
use claude_wrapper::Claude;
use claude_wrapper::duplex::{DuplexOptions, DuplexSession};

let claude = Claude::builder().build()?;
let session = DuplexSession::spawn(&claude, DuplexOptions::default()).await?;

let send_fut = session.send("write a long essay about rust");
let interrupt_fut = async {
    tokio::time::sleep(Duration::from_millis(500)).await;
    session.interrupt().await
};

let (turn, interrupt_result) = tokio::join!(send_fut, interrupt_fut);
let _truncated = turn?;
interrupt_result?;

§Phased rollout

This module rolled out in four PRs tracked in https://github.com/joshrotenberg/claude-wrapper/issues/561: spawn/send/close (PR 1), subscribe (PR 2), mid-turn permission handling (PR 3), and interrupt (PR 4, this one).

Structs§

DuplexOptions
Configuration for DuplexSession::spawn.
DuplexSession
A long-lived claude subprocess in stream-json duplex mode.
PermissionHandler
A user-supplied async callback invoked when the CLI requests permission to use a tool.
PermissionRequest
A mid-turn permission prompt from the CLI for a single tool invocation.
TurnResult
The result of one turn through a DuplexSession.

Enums§

InboundEvent
A classified inbound event broadcast to DuplexSession::subscribe receivers.
PermissionDecision
The decision returned from a PermissionHandler (or passed to DuplexSession::respond_to_permission for deferred decisions).
SessionExitStatus
Liveness state of a DuplexSession’s background task.

Constants§

DEFAULT_SUBSCRIBER_CAPACITY
Default capacity of the per-session broadcast::Sender backing DuplexSession::subscribe.