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.

§Resource limits and deadlines

A duplex session is unbounded by default: no per-frame or per-turn memory ceiling, no turn or control deadline, and no idle lifetime. The client’s ClaudeBuilder::output_limit and ClaudeBuilder::timeout apply to one-shot runs only. Opt in per session:

Byte limits count wire data, not exact allocator or parsed-JSON heap usage. The reader also uses a fixed-size input buffer.

Per-turn counters and deadlines reset for each turn. When any policy trips, the session closes: new requests are rejected with Error::DuplexClosed, the owned process group is stopped with the client’s configured kill_grace and termination_signal (the graceful shutdown budget is skipped; with process_group(false) only the direct child is killed), the direct child is reaped, and then every accepted turn and control waiter receives Error::DuplexLimitExceeded or Error::DuplexDeadlineExceeded. Requests still queued but not yet accepted receive Error::DuplexClosed. DuplexSession::close returns the same policy error. Its process_settled field reports whether the direct child was reaped; it says nothing about escaped descendants, and a deadline error never implies the CLI acknowledged an interrupt. A turn that trips a policy never resolves as a truncated successful TurnResult.

Dropping a DuplexSession::send or DuplexSession::interrupt future only abandons the wait. The request stays pending in the session until its result or control_response arrives, or until its configured deadline closes the session. An abandoned turn continues to block another send with Error::DuplexTurnInFlight.

§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.