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.302 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§
- Duplex
Options - Configuration for
DuplexSession::spawn. - Duplex
Session - A long-lived
claudesubprocess in stream-json duplex mode. - Permission
Handler - A user-supplied async callback invoked when the CLI requests permission to use a tool.
- Permission
Request - A mid-turn permission prompt from the CLI for a single tool invocation.
- Turn
Result - The result of one turn through a
DuplexSession.
Enums§
- Inbound
Event - A classified inbound event broadcast to
DuplexSession::subscribereceivers. - Permission
Decision - The decision returned from a
PermissionHandler(or passed toDuplexSession::respond_to_permissionfor deferred decisions). - Session
Exit Status - Liveness state of a
DuplexSession’s background task.
Constants§
- DEFAULT_
SUBSCRIBER_ CAPACITY - Default capacity of the per-session
broadcast::SenderbackingDuplexSession::subscribe.