SPB Git

Repository Public

terminal-runtime.md 11.2 KB

# 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 containing bypassPermissions);
  • a value after --resume / -r must not start with -;
  • cwd must be an existing directory whose realpath is inside a workspace root;
  • the session id must be new (409 exists), and live cr-* sessions must be below CR_MAX_SESSIONS (default 12; 429 too_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).

  1. Browser opens WS /api/v1/sessions/:id/stream?cols=&rows=. The guard checks cookie, second factor and Origin; preValidation checks ownership and that the session is live (404 unknown, 410 not running).
  2. The server opens WS /sessions/:id/attach on the runtime socket and pipes both directions.
  3. 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.
  4. resize control messages resize the PTY; ping is answered with pong (the client measures latency and declares the connection dead after three unanswered pings, sent every 5 s).
  5. 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_LINES history 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.
  • 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.exit once per session — with the pane's exit status (or 128 + signal), or null when 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's settings.json ($CLAUDE_CONFIG_DIR or ~/.claude): Notification, Stop, UserPromptSubmit, SessionStart, each running apps/runtime/bin/cr-hook with a 5 s timeout. The merge is idempotent, preserves every other key, and refuses to touch an unparsable file.
  • cr-hook is a POSIX script. Without CR_SESSION_ID, CR_HOOK_SOCKET and CR_HOOK_TOKEN it exits 0 immediately, so Claude Code used outside Claude Remote is unaffected. Otherwise it forwards the hook JSON to POST /hooks/claude on 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 while cr-* 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 in claude_conversation_id (used for restart).
  • The Notification message is truncated to 200 characters and shown as the waiting reason only when the user selected notification detail titles; 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_INPUT was tested end to end with the real cr-hook script 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 claude 2.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 mcp output 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.
Files main
Menu

Theme