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.

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

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.