Terminal runtime
How a browser terminal is connected to the real Claude Code CLI. Source: apps/runtime/src/
(tmux.ts, sessions.ts, core-attach.ts, core-scrollback.ts, core-backpressure.ts, hooks.ts,
bin/cr-hook), apps/server/src/modules/{sessions,terminal-ws.ts}, apps/server/src/ws-pipe.ts,
apps/web/src/components/terminal/. Persistence guarantees are in
session-persistence.md.
This document describes the Claude profile (the default). For an instance started with
CR_AGENT=codex, see codex-profile.md: codex instead of claude, resume
instead of --resume, a notify program instead of hooks, and terminal input ending
WAITING_FOR_INPUT.
tmux server
The runtime drives a dedicated tmux server: tmux -L <CR_TMUX_SOCKET> -f <data dir>/run/tmux.conf
(socket name claude-remote in production). The configuration file is generated at every runtime start.
Relevant options:
| Option | Purpose |
|---|---|
exit-empty off, destroy-unattached off |
the server and sessions stay alive with no client |
remain-on-exit on |
a dead pane is kept so the exit code can be read |
history-limit 20000 |
bounded pane history |
status off, prefix None, all key bindings unbound, mouse off |
the browser terminal is transparent: no tmux UI, no prefix key |
window-size latest, aggressive-resize off |
the window follows the client that was used last |
default-terminal xterm-256color, RGB feature |
colours |
terminal-overrides xterm-256color:smcup@:rmcup@:csr@:indn@ |
attach clients render in the normal buffer and redraw instead of scrolling (see "Scrollback") |
allow-passthrough off, set-clipboard off, set-titles off |
no escape passthrough to the browser terminal |
set-environment -gu for each runtime secret |
secrets never live in the tmux global environment |
The tmux server daemonizes itself, so it outlives the runtime process. A server that survived a runtime
restart gets the terminal-overrides option re-applied, because the file is only read at server start.
Session creation
POST /api/v1/sessions (CreateSession: project, kind claude | shell, optional cwd, optional
resumeConversationId or continueLast, initial size) → the server inserts the row (CREATED →
STARTING), builds the argument list itself, and calls the runtime POST /sessions.
| Kind | Command executed by tmux (no intermediate shell) |
|---|---|
claude |
$SHELL -lc 'exec <CR_CLAUDE_BIN> <args>' — a login shell, so PATH matches an interactive terminal; each argument is single-quoted |
shell |
$SHELL -l |
Arguments are exactly one of: none, --continue, --resume <conversationId>.
Runtime checks before spawning (sessions.ts):
- at most 64 arguments, no NUL; permission-bypass flags are refused
(
--dangerously-skip-permissions,--allow-dangerously-skip-permissions,--permission-mode bypassPermissions, any option containingbypassPermissions); - a value after
--resume/-rmust not start with-; cwdmust be an existing directory whose realpath is inside a workspace root;- the session id must be new (409
exists), and livecr-*sessions must be belowCR_MAX_SESSIONS(default 12; 429too_many_sessions). Creations are serialized so these checks cannot race.
The tmux session is named cr-<uuid>, created detached at the requested size (clamped to 20–500 × 5–200)
with this environment: TERM=xterm-256color, COLORTERM=truecolor, CR_SESSION_ID, CR_HOOK_SOCKET,
CR_HOOK_TOKEN. The runtime's own CR_* variables and database credentials are removed.
Attach
One browser connection = one WebSocket = one node-pty running tmux attach-session -t =cr-<uuid>,
sized to the client (core-attach.ts).
- Browser opens
WS /api/v1/sessions/:id/stream?cols=&rows=. The guard checks cookie, second factor and Origin;preValidationchecks ownership and that the session is live (404 unknown, 410 not running). - The server opens
WS /sessions/:id/attachon the runtime socket and pipes both directions. - The runtime sends
{"t":"ready",…}, replays the pane history, then spawns the attach PTY; tmux paints the current screen. Input typed before the PTY is up is held (≤ 1 MiB) and replayed in order. resizecontrol messages resize the PTY;pingis answered withpong(the client measures latency and declares the connection dead after three unanswered pings, sent every 5 s).- Closing the WebSocket kills that attach client only. The tmux session and its process keep running.
Close codes (WS_CLOSE in packages/shared/src/wire.ts): 4401 unauthorized, 4403 forbidden, 4404 not
found, 4410 session ended (preceded by an exit message with the code), 4429 backpressure, 4503 runtime
unavailable; 1001 on server or runtime shutdown.
Several browsers can attach to the same session at once. Sessions are isolated from each other (one tmux session each).
Scrollback
tmux attach renders the visible screen and skips intermediate output when it arrives fast (measured
with tmux 3.8: 300 lines printed at once left 29 in xterm.js). The attach stream therefore cannot feed a
browser scrollback. Design (core-scrollback.ts):
- Attach clients are told their terminal has no alternate screen and no scroll region, so tmux paints in the browser's normal buffer and always redraws; it never pushes lines into the browser scrollback.
- The runtime is the only writer of that scrollback, as a mirror of the pane history:
- on attach, before the PTY starts, it sends the last
CR_REPLAY_LINEShistory lines (default 5000, maximum 20 000, and at most 2 MiB), captured with colours and with wrapped rows joined; - while attached, it appends the lines tmux added to the history (checked ≤ 250 ms after output, backing off to 1 s while output does not scroll), pushed through a temporary two-row scroll region, then makes tmux repaint (SIGWINCH to the attach client);
- when continuity cannot be proven — pane resized, history cleared or trimmed at
history-limit, more new lines than the replay window — the browser scrollback is cleared and replayed.
- on attach, before the PTY starts, it sends the last
- A client whose size differs from the window (a second browser that is not the one being typed in) is not touched until the sizes agree, then caught up with a full replay.
- Full-screen programs inside the pane (their own alternate screen) render through tmux and add nothing to the scrollback.
The browser keeps at most scrollback lines (user setting, 500–50 000, default 5000).
Backpressure and limits
| Limit | Value | Where |
|---|---|---|
| Inbound WebSocket frame | 1 MiB (larger frames are rejected) | server and runtime |
| Outbound buffer high-water mark | CR_TERM_BUFFER_BYTES (default 4 MiB) |
server pipe and runtime |
| Behaviour above the mark | the source is paused (pty.pause() in the runtime; the upstream socket in the server) and resumed below half the mark |
core-backpressure.ts, ws-pipe.ts |
| Saturated for 30 s | connection closed with 4429; the session keeps running | both |
| Client input chunking | 64 KiB per frame | controller.ts |
No scrollback synchronisation happens while a connection is saturated.
Session state from the runtime's point of view
The runtime polls tmux list-sessions every 2 s (and 150 ms / 700 ms after an attach client starts or
ends) and emits:
session.exitonce per session — with the pane's exit status (or 128 + signal), ornullwhen the tmux session vanished;session.activity— at once when the attached-client count changes, otherwise at most once every 5 s per session.
The server maps these to states (modules/sessions/service.ts): attached > 0 ⇒ RUNNING, 0 ⇒
DETACHED; a proxied browser stream counts immediately, and after the last one closes the server asks
the runtime again after 250 ms and 1.5 s. Exit 0 or unknown ⇒ STOPPED, non-zero ⇒ FAILED. An exit
without a code is checked against the runtime: session unknown ⇒ RECOVERABLE.
Terminate (POST /sessions/:id/terminate): STOPPING, then the runtime sends SIGHUP to the pane process,
waits up to 5 s, and kills the tmux session.
WAITING_FOR_INPUT (Claude Code hooks)
WAITING_FOR_INPUT is derived only from Claude Code hooks. Terminal output is never parsed.
- With
CR_INSTALL_CLAUDE_HOOKS=1(production only) the runtime merges four entries into the account'ssettings.json($CLAUDE_CONFIG_DIRor~/.claude):Notification,Stop,UserPromptSubmit,SessionStart, each runningapps/runtime/bin/cr-hookwith a 5 s timeout. The merge is idempotent, preserves every other key, and refuses to touch an unparsable file. cr-hookis a POSIX script. WithoutCR_SESSION_ID,CR_HOOK_SOCKETandCR_HOOK_TOKENit exits 0 immediately, so Claude Code used outside Claude Remote is unaffected. Otherwise it forwards the hook JSON toPOST /hooks/claudeon the runtime socket (2 s maximum) and always exits 0.- The hook token is accepted on that route only. It is stored in
run/hook-token(0600) and kept across a runtime restart whilecr-*sessions are alive (they carry it in their environment). - The runtime emits
claude.hook; the server applies:Notification/Stop⇒WAITING_FOR_INPUT(plus an in-app notification),UserPromptSubmit⇒RUNNING. Any hook carrying a conversation id that matches^[A-Za-z0-9][A-Za-z0-9-]{7,63}$stores it inclaude_conversation_id(used for restart). - The
Notificationmessage is truncated to 200 characters and shown as the waiting reason only when the user selected notification detailtitles; otherwise the reason is generic.
Other runtime endpoints used by the terminal UI
GET /sessions/:id/snapshot: last 200 lines of the pane as plain text, for an explicit user-requested preview.GET /host/metrics: includes CPU % and RSS per session, summed over each session's process tree.POST /claude/conversations: conversation list for a directory (id, first user prompt truncated to 160 characters, update time, message count), read from Claude Code's transcript files; used by the "resume" picker. At most 50 conversations, 4 MiB read per transcript.
Browser side
components/terminal/controller.ts: one xterm.js instance and one WebSocket per session; controllers
outlive React views, so a terminal keeps its socket and buffer while its tab is hidden. WebGL renderer
when available, DOM renderer on iOS, on context loss or on failure. Browser shortcuts (Cmd+R/T/W/L…) are
never swallowed. Reconnection behaviour is described in session-persistence.md.
Limitations / not verified
WAITING_FOR_INPUTwas tested end to end with the realcr-hookscript and synthetic hook input; it has not been observed from a real Claude conversation (none was run: Claude Code is not authenticated on the node yet).- The real
claude2.1.322 binary was launched under tmux and its welcome screen rendered, on the laptop and on the node; no prompt was sent anywhere. - The 4429 close is unit-tested (
main.test.ts), not observed under real saturation. - Hook payload formats and
claude auth status/claude mcpoutput formats are those observed with Claude Code 2.1.x; a CLI update can change them. - tmux runs with
mouse off. The behaviour of applications that request mouse reporting inside the pane was not tested.