A native, cross-platform daemon that manages interactive AI CLI sessions under a real PTY and streams structured events to your application. The Rust successor to lit-bridge — same JSON-lines protocol, no tmux.
One static binary. ConPTY on Windows, forkpty/openpty on Unix. Runs the CLI in a
real terminal — not a pipe.
lit-bridge-rs sits between your application and an AI CLI (like Claude Code). It:
- Spawns CLI sessions under a real PTY (no tmux, no shell wrapper)
- Sends messages by typing into the terminal
- Renders the TUI in an in-process terminal emulator and watches it for state changes (thinking, responding, idle)
- Tails the CLI's JSONL transcript for authoritative turn completion and tool events
- Streams structured JSON events back to your application over a Unix socket
Your application speaks a simple JSON-lines protocol. It never touches the PTY, never parses terminal output, never manages processes.
The Python lit-bridge leans on tmux for three things: hosting the PTY, providing a
queryable rendered screen (capture-pane), and keeping sessions alive across daemon
restarts. tmux is also the only reason claude-interactive is Unix-only.
lit-bridge-rs replaces tmux with:
portable-pty(WezTerm) — ConPTY + Unix PTY behind one API. This is what unblocks Windows.vt100— an in-process terminal emulator that gives a rendered screen grid, thecapture-panereplacement.
Session-survival-across-restart is intentionally dropped — the binary is the long-lived
per-user daemon, and recovery after a restart is the CLI's own --resume (which the
bridge already drives). One fewer moving part.
Why not pipe mode? AI CLIs render rich TUIs and manage their own context, tool permissions, and MCP servers. Using
-p/--output-format jsonstrips that away and can reclassify subscription usage as programmatic. lit-bridge-rs deliberately drives the real TUI, just like its predecessor.
# Build
cargo build --release
# Start the daemon (defaults to /tmp/lit-bridge-rs.sock)
./target/release/lit-bridge-rs --socket /tmp/lit-bridge-rs.sock
# In another terminal, connect and interact:
# (using socat for the demo — your app would use a proper socket client)
# Create a session
echo '{"cmd":"create","session":"demo","cli":"claude","args":["--model","sonnet"]}' \
| socat - UNIX-CONNECT:/tmp/lit-bridge-rs.sock
# Send a message
echo '{"cmd":"send","session":"demo","content":"What is the capital of France?"}' \
| socat - UNIX-CONNECT:/tmp/lit-bridge-rs.sock
# Events stream back as JSON lines:
# {"session":"demo","event":"state","from":"idle","to":"thinking"}
# {"session":"demo","event":"replace","text":"● The capital of France is Paris."}
# {"session":"demo","event":"complete","content":"● The capital of France is Paris."}lit-bridge-rs speaks JSON-lines: one JSON object per line, newline-delimited, over a Unix
domain socket. On connect, the daemon sends a monitor_ready handshake, then flushes any
events buffered while no client was attached.
| Command | Purpose |
|---|---|
create |
Start (or reuse) a CLI session |
send |
Send a message and observe the response |
input |
Type text + Enter without observing (slash commands like /model, /clear) |
keystroke |
Send named keys (Up, Down, Enter, …) for dialogs/menus |
list |
List sessions and their states |
dump |
Diagnostic snapshot of a session (rendered screen + parser counts) |
kill |
Terminate a session (optionally stash its id for --resume) |
ping |
Health check → {"event":"pong"} |
create
{
"cmd": "create",
"session": "my-session",
"cli": "claude",
"args": ["--model", "opus"],
"working_dir": "/path/to/project",
"env": {"ANTHROPIC_API_KEY": "sk-..."},
"channel_id": "optional-channel-name"
}| Field | Required | Description |
|---|---|---|
session |
yes | Unique session name. |
cli |
no | CLI binary name. Default "claude". (exe is also accepted.) |
args |
no | Additional CLI arguments. |
working_dir |
no | Working directory for the CLI process. |
env |
no | Environment variables to set. |
channel_id |
no | Sub-session key — session:channel_id are tracked independently. |
Response: {"session":"my-session","event":"ready","state":"starting","model":"opus","resumed":false},
or {"session":"my-session","event":"ready","reused":true,"model":"opus"} if a live session is reused.
send — types the message and begins observing. Events stream back as the CLI responds.
{"cmd": "send", "session": "my-session", "content": "Explain quicksort"}kill — pass "store_resume": true to stash the CLI session id so the next create
appends --resume <id> (used for model switches: kill + recreate, conversation intact).
{"cmd": "kill", "session": "my-session", "store_resume": true}| Event | Meaning |
|---|---|
monitor_ready |
Handshake on client connect (sessions, buffered counts) |
ready |
A create finished startup (dialogs dismissed, prompt reached) |
state |
State changed (from/to) |
replace |
Full current response text (replace, don't append) |
complete |
Turn finished — content is the authoritative text from the JSONL transcript |
tool_use |
CLI invoked a tool |
tool_result |
A tool returned |
dialog_dismissed |
A startup dialog was auto-dismissed during create |
killed |
Session terminated |
error |
Something went wrong (message) |
States: starting, idle, thinking, responding, dialog, dead.
The replace event always carries the complete current response, so your UI replaces
rather than appends — this handles tool calls, retries, and edits cleanly. complete
arrives from the JSONL transcript (authoritative); a TUI-quiescence fallback closes the
turn only if the transcript doesn't, and is tuned to fail slower, never shorter.
Two corroborating paths (see docs/.../02-completion-detection.md):
- JSONL transcript (authoritative). Tailing the CLI's
.jsonltranscript yields clean turn text and tool events as soon as the turn ends — fast and exact. - TUI quiescence (fallback). If the transcript isn't holding the turn open (no pending tool), and the rendered screen has been still past a quiescence window with the prompt visible, close the turn. Hard-capped so a turn never hangs forever. This never overrides the JSONL path — truncation is unacceptable, latency is fine.
The daemon also listens on <socket>.attach. A terminal client connects, sends a one-line
session selector ({"session":"…","channel_id":"…"} or a bare key), and then gets a
bidirectional raw byte pipe to the PTY: live output out, keystrokes in. On attach the
current rendered screen is painted so the terminal isn't blank. This is how the platform's
xterm.js "open terminal" button works — for slash commands, dismissing odd dialogs, and
diagnostics when scraping falls short.
The PTY is a fixed size (200×50) because the parser's unwrap logic depends on a known width. An attaching browser terminal is a view of that fixed grid, not a resize of it.
See scripts/attach.py for a reference client.
┌─→ vt100 emulator → parser/observer ──→ JSON event protocol (socket)
PTY output ─┤
└─→ raw bytes ─────────────────────────→ attached terminal client(s) (.attach socket)
src/main.rs— the daemon: socket server, command dispatch, 300ms observer loop, attach handler.src/session.rs— one PTY session: spawn, capture (rendered screen), send text/keys, JSONL tailing, broadcast tee for attach.src/jsonl.rs— JSONL transcript watcher: tool events + authoritative turn completion.src/parser/— version-aware TUI parser, parity-locked to the Pythonclaude/v2_1parser.
- Rust (stable) to build, or a prebuilt binary
- A supported CLI installed and on PATH (e.g.
claudefrom@anthropic-ai/claude-code) — plus its runtime (Node) - No tmux. Works on Linux, macOS, and Windows (native ConPTY — no WSL).
Dogfooding. macOS/Linux parity with lit-bridge is the current bar; Windows-native and
packaging are in progress. Versioned 0.0.x until the parity and packaging gates are met.
Business Source License 1.1. Free for non-production use; production use requires a commercial license (licensing@positronic.ai). Converts to Apache 2.0 four years after each version's release. Contributions are covered by the CLA.