SPB Git

Repository Public

terminal-runtime.md 11.2 KB 182 lines markdown

1# Terminal runtime23How a browser terminal is connected to the real Claude Code CLI. Source: `apps/runtime/src/`4(`tmux.ts`, `sessions.ts`, `core-attach.ts`, `core-scrollback.ts`, `core-backpressure.ts`, `hooks.ts`,5`bin/cr-hook`), `apps/server/src/modules/{sessions,terminal-ws.ts}`, `apps/server/src/ws-pipe.ts`,6`apps/web/src/components/terminal/`. Persistence guarantees are in7[session-persistence.md](session-persistence.md).89This document describes the Claude profile (the default). For an instance started with10`CR_AGENT=codex`, see [codex-profile.md](codex-profile.md): `codex` instead of `claude`, `resume`11instead of `--resume`, a `notify` program instead of hooks, and terminal input ending12`WAITING_FOR_INPUT`.1314## tmux server1516The runtime drives a dedicated tmux server: `tmux -L <CR_TMUX_SOCKET> -f <data dir>/run/tmux.conf`17(socket name `claude-remote` in production). The configuration file is generated at every runtime start.18Relevant options:1920| Option | Purpose |21|---|---|22| `exit-empty off`, `destroy-unattached off` | the server and sessions stay alive with no client |23| `remain-on-exit on` | a dead pane is kept so the exit code can be read |24| `history-limit 20000` | bounded pane history |25| `status off`, `prefix None`, all key bindings unbound, `mouse off` | the browser terminal is transparent: no tmux UI, no prefix key |26| `window-size latest`, `aggressive-resize off` | the window follows the client that was used last |27| `default-terminal xterm-256color`, RGB feature | colours |28| `terminal-overrides xterm-256color:smcup@:rmcup@:csr@:indn@` | attach clients render in the normal buffer and redraw instead of scrolling (see "Scrollback") |29| `allow-passthrough off`, `set-clipboard off`, `set-titles off` | no escape passthrough to the browser terminal |30| `set-environment -gu` for each runtime secret | secrets never live in the tmux global environment |3132The tmux server daemonizes itself, so it outlives the runtime process. A server that survived a runtime33restart gets the `terminal-overrides` option re-applied, because the file is only read at server start.3435## Session creation3637`POST /api/v1/sessions` (`CreateSession`: project, kind `claude` | `shell`, optional `cwd`, optional38`resumeConversationId` or `continueLast`, initial size) → the server inserts the row (`CREATED` →39`STARTING`), builds the argument list itself, and calls the runtime `POST /sessions`.4041| Kind | Command executed by tmux (no intermediate shell) |42|---|---|43| `claude` | `$SHELL -lc 'exec <CR_CLAUDE_BIN> <args>'` — a login shell, so `PATH` matches an interactive terminal; each argument is single-quoted |44| `shell` | `$SHELL -l` |4546Arguments are exactly one of: none, `--continue`, `--resume <conversationId>`.4748Runtime checks before spawning (`sessions.ts`):4950- at most 64 arguments, no NUL; **permission-bypass flags are refused**51  (`--dangerously-skip-permissions`, `--allow-dangerously-skip-permissions`,52  `--permission-mode bypassPermissions`, any option containing `bypassPermissions`);53- a value after `--resume` / `-r` must not start with `-`;54- `cwd` must be an existing directory whose realpath is inside a workspace root;55- the session id must be new (409 `exists`), and live `cr-*` sessions must be below `CR_MAX_SESSIONS`56  (default 12; 429 `too_many_sessions`). Creations are serialized so these checks cannot race.5758The tmux session is named `cr-<uuid>`, created detached at the requested size (clamped to 20–500 × 5–200)59with this environment: `TERM=xterm-256color`, `COLORTERM=truecolor`, `CR_SESSION_ID`, `CR_HOOK_SOCKET`,60`CR_HOOK_TOKEN`. The runtime's own `CR_*` variables and database credentials are removed.6162## Attach6364One browser connection = one WebSocket = one `node-pty` running `tmux attach-session -t =cr-<uuid>`,65sized to the client (`core-attach.ts`).66671. Browser opens `WS /api/v1/sessions/:id/stream?cols=&rows=`. The guard checks cookie, second factor and68   Origin; `preValidation` checks ownership and that the session is live (404 unknown, 410 not running).692. The server opens `WS /sessions/:id/attach` on the runtime socket and pipes both directions.703. The runtime sends `{"t":"ready",…}`, replays the pane history, then spawns the attach PTY; tmux paints71   the current screen. Input typed before the PTY is up is held (≤ 1 MiB) and replayed in order.724. `resize` control messages resize the PTY; `ping` is answered with `pong` (the client measures latency73   and declares the connection dead after three unanswered pings, sent every 5 s).745. Closing the WebSocket kills that attach client only. The tmux session and its process keep running.7576Close codes (`WS_CLOSE` in `packages/shared/src/wire.ts`): 4401 unauthorized, 4403 forbidden, 4404 not77found, 4410 session ended (preceded by an `exit` message with the code), 4429 backpressure, 4503 runtime78unavailable; 1001 on server or runtime shutdown.7980Several browsers can attach to the same session at once. Sessions are isolated from each other (one tmux81session each).8283## Scrollback8485`tmux attach` renders the visible screen and skips intermediate output when it arrives fast (measured86with tmux 3.8: 300 lines printed at once left 29 in xterm.js). The attach stream therefore cannot feed a87browser scrollback. Design (`core-scrollback.ts`):8889- Attach clients are told their terminal has no alternate screen and no scroll region, so tmux paints in90  the browser's normal buffer and always redraws; it never pushes lines into the browser scrollback.91- The runtime is the only writer of that scrollback, as a mirror of the pane history:92  - on attach, before the PTY starts, it sends the last `CR_REPLAY_LINES` history lines (default 5000,93    maximum 20 000, and at most 2 MiB), captured with colours and with wrapped rows joined;94  - while attached, it appends the lines tmux added to the history (checked ≤ 250 ms after output, backing95    off to 1 s while output does not scroll), pushed through a temporary two-row scroll region, then makes96    tmux repaint (SIGWINCH to the attach client);97  - when continuity cannot be proven — pane resized, history cleared or trimmed at `history-limit`, more98    new lines than the replay window — the browser scrollback is cleared and replayed.99- A client whose size differs from the window (a second browser that is not the one being typed in) is100  not touched until the sizes agree, then caught up with a full replay.101- Full-screen programs inside the pane (their own alternate screen) render through tmux and add nothing102  to the scrollback.103104The browser keeps at most `scrollback` lines (user setting, 500–50 000, default 5000).105106## Backpressure and limits107108| Limit | Value | Where |109|---|---|---|110| Inbound WebSocket frame | 1 MiB (larger frames are rejected) | server and runtime |111| Outbound buffer high-water mark | `CR_TERM_BUFFER_BYTES` (default 4 MiB) | server pipe and runtime |112| 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` |113| Saturated for 30 s | connection closed with 4429; the session keeps running | both |114| Client input chunking | 64 KiB per frame | `controller.ts` |115116No scrollback synchronisation happens while a connection is saturated.117118## Session state from the runtime's point of view119120The runtime polls `tmux list-sessions` every 2 s (and 150 ms / 700 ms after an attach client starts or121ends) and emits:122123- `session.exit` once per session — with the pane's exit status (or 128 + signal), or `null` when the124  tmux session vanished;125- `session.activity` — at once when the attached-client count changes, otherwise at most once every 5 s126  per session.127128The server maps these to states (`modules/sessions/service.ts`): attached > 0 ⇒ `RUNNING`, 0 ⇒129`DETACHED`; a proxied browser stream counts immediately, and after the last one closes the server asks130the runtime again after 250 ms and 1.5 s. Exit 0 or unknown ⇒ `STOPPED`, non-zero ⇒ `FAILED`. An exit131without a code is checked against the runtime: session unknown ⇒ `RECOVERABLE`.132133Terminate (`POST /sessions/:id/terminate`): `STOPPING`, then the runtime sends SIGHUP to the pane process,134waits up to 5 s, and kills the tmux session.135136## WAITING_FOR_INPUT (Claude Code hooks)137138`WAITING_FOR_INPUT` is derived only from Claude Code hooks. Terminal output is never parsed.139140- With `CR_INSTALL_CLAUDE_HOOKS=1` (production only) the runtime merges four entries into the account's141  `settings.json` (`$CLAUDE_CONFIG_DIR` or `~/.claude`): `Notification`, `Stop`, `UserPromptSubmit`,142  `SessionStart`, each running `apps/runtime/bin/cr-hook` with a 5 s timeout. The merge is idempotent,143  preserves every other key, and refuses to touch an unparsable file.144- `cr-hook` is a POSIX script. Without `CR_SESSION_ID`, `CR_HOOK_SOCKET` and `CR_HOOK_TOKEN` it exits 0145  immediately, so Claude Code used outside Claude Remote is unaffected. Otherwise it forwards the hook146  JSON to `POST /hooks/claude` on the runtime socket (2 s maximum) and always exits 0.147- The hook token is accepted on that route only. It is stored in `run/hook-token` (0600) and kept across148  a runtime restart while `cr-*` sessions are alive (they carry it in their environment).149- The runtime emits `claude.hook`; the server applies: `Notification` / `Stop` ⇒ `WAITING_FOR_INPUT`150  (plus an in-app notification), `UserPromptSubmit` ⇒ `RUNNING`. Any hook carrying a conversation id that151  matches `^[A-Za-z0-9][A-Za-z0-9-]{7,63}$` stores it in `claude_conversation_id` (used for restart).152- The `Notification` message is truncated to 200 characters and shown as the waiting reason only when the153  user selected notification detail `titles`; otherwise the reason is generic.154155## Other runtime endpoints used by the terminal UI156157- `GET /sessions/:id/snapshot`: last 200 lines of the pane as plain text, for an explicit user-requested preview.158- `GET /host/metrics`: includes CPU % and RSS per session, summed over each session's process tree.159- `POST /claude/conversations`: conversation list for a directory (id, first user prompt truncated to160  160 characters, update time, message count), read from Claude Code's transcript files; used by the161  "resume" picker. At most 50 conversations, 4 MiB read per transcript.162163## Browser side164165`components/terminal/controller.ts`: one xterm.js instance and one WebSocket per session; controllers166outlive React views, so a terminal keeps its socket and buffer while its tab is hidden. WebGL renderer167when available, DOM renderer on iOS, on context loss or on failure. Browser shortcuts (Cmd+R/T/W/L…) are168never swallowed. Reconnection behaviour is described in [session-persistence.md](session-persistence.md).169170## Limitations / not verified171172- `WAITING_FOR_INPUT` was tested end to end with the real `cr-hook` script and synthetic hook input; it173  has not been observed from a real Claude conversation (none was run: Claude Code is not authenticated174  on the node yet).175- The real `claude` 2.1.322 binary was launched under tmux and its welcome screen rendered, on the laptop176  and on the node; no prompt was sent anywhere.177- The 4429 close is unit-tested (`main.test.ts`), not observed under real saturation.178- Hook payload formats and `claude auth status` / `claude mcp` output formats are those observed with179  Claude Code 2.1.x; a CLI update can change them.180- tmux runs with `mouse off`. The behaviour of applications that request mouse reporting inside the pane181  was not tested.182
Files main
Menu

Theme