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