A Rust WebSocket server that tails your local Claude Code session files and streams the parsed conversation - thinking, tool calls, text, todos, token usage - live to any client, with remote input injection back into the terminal.
- Session discovery - scans
~/.claude/projects/*/*.jsonland lists every Claude Code session with project name, cwd, git branch, Claude Code version, message count, active/stale flag (ACTIVE_THRESHOLD_SECS, default 600s), and the last token-usage snapshot. - Live event stream -
GET /ws/<session_id>replays the last 100 parsed events for catch-up, sends areadyevent, then tails the session's JSONL file every second and pushes new events as they land. - Typed event parsing - raw JSONL lines are turned into
thinking,tool,text,user_msg,todos, andusageevents. Tool calls get a human-readable one-line summary (e.g. the command forBash, the path forRead/Edit/Write, the pattern forGrep/Glob). - REST snapshot API -
/api/health,/api/sessions,/api/sessions/:idfor clients that just want a poll instead of a socket. - Remote input injection -
POST /api/sessions/:id/inputor a{"type":"input","text":"..."}WebSocket message locates the tty of the Claude process running in that session's cwd (viapgrep+lsof+ps) and types the text back into it through AppleScript: directly into iTerm2 with a bracketed-paste-safe two-step write, or by focusing VS Code's integrated terminal and driving System Events keystrokes as a fallback. - Open CORS (
Any/Any/Any) - built for trusted local/LAN use, not a public API.
Claude Code writes one append-only .jsonl file per session under ~/.claude/projects/<project>/<session_id>.jsonl. claude-live doesn't watch the filesystem for change events - it reads the file once on WebSocket connect, then polls its size on a 1-second tokio::interval and reads only the new bytes each tick, so it never re-parses the whole file.
flowchart LR
CC[Claude Code CLI] -->|appends JSONL| F[(~/.claude/projects/*/*.jsonl)]
F -->|catch-up read + 1s poll| WS[ws.rs: WS handler]
WS -->|parse_line| P[parser.rs: SessionEvent]
P -->|JSON| Client[WebSocket client]
Client -->|"{type: input, text}"| WS
WS -->|find tty via pgrep/lsof/ps, then osascript| TTY[iTerm2 / VS Code terminal]
Each JSONL line is one user or assistant turn. parser::parse_line maps it to at most one SessionEvent: todos and plain text come from user lines, thinking/text/tool_use/usage come from assistant lines. Input injection is one-way and best-effort - if no matching tty is found, or neither the iTerm2 nor the VS Code AppleScript path succeeds, the caller gets {"ok": false} back.
| Layer | Choice |
|---|---|
| Language | Rust (edition 2021) |
| Async runtime | Tokio |
| HTTP + WebSocket | Axum 0.7 (ws feature) |
| CORS | tower-http |
| Serialization | serde / serde_json |
| Time formatting | chrono |
| Config | dotenv + env vars |
| Logging | tracing / tracing-subscriber |
| Errors | anyhow |
| Terminal injection | AppleScript via osascript (iTerm2, VS Code) |
git clone https://github.com/bunlongheng/claude-live.git
cd claude-live
cp .env.example .env # optional - all vars have defaults
cargo run --releaseThe server binds 0.0.0.0:$PORT (default 7878) and reads sessions straight from ~/.claude/projects, so nothing to seed - it picks up whatever Claude Code has already written.
claude-live http://0.0.0.0:7878
WS endpoint: ws://0.0.0.0:7878/ws/<session_id>
Sessions API: http://0.0.0.0:7878/api/sessions
| Variable | Default | Purpose |
|---|---|---|
PORT |
7878 |
Port the HTTP/WS server listens on |
ACTIVE_THRESHOLD_SECS |
600 |
Sessions with no writes for longer than this are marked active: false |
RUST_LOG |
claude_progress=info,tower_http=warn |
Standard tracing env filter |
| Method | Path | Purpose |
|---|---|---|
| GET | /api/health |
{"status":"ok","service":"claude-progress"} |
| GET | /api/sessions |
All discovered sessions, sorted active-first then most recent |
| GET | /api/sessions/:id |
One session's metadata, 404 if the id isn't found |
| POST | /api/sessions/:id/input |
Body {"text":"..."} - injects text into that session's terminal |
| GET (WS) | /ws/:session_id |
Live event stream for one session |
| Event | Payload |
|---|---|
ready |
{"session_id": "..."} - sent once catch-up replay finishes |
thinking |
{"text", "timestamp"} |
tool |
{"name", "summary", "timestamp"} |
text |
{"text", "timestamp"} |
user_msg |
{"text", "timestamp"} (truncated to 300 chars) |
todos |
{"todos": [{"id","subject","status","description?","active_form?"}], "timestamp"} |
usage |
{"input_tokens","output_tokens","cache_read","cache_creation","model","timestamp"} |
error |
{"message"} - sent once if the session id doesn't resolve to a file |
{"type": "input", "text": "..."} - triggers the same tty-injection path as the REST endpoint; the server replies with {"event":"input_ack","data":{"ok": true|false}}.
MIT (c) Bunlong Heng
Built by Bunlong Heng · See it in my portfolio →

