# Web Terminal A self-hosted, browser-based terminal **and** Claude-Code session/project workbench. It exposes the **host machine's local shell** over WebSocket: open `http://:3000` from any device on your LAN (phone, tablet, another laptop) and you get a live, interactive shell. The core use case is **vibe coding** — hand Claude Code a task, walk away, then reconnect from anywhere to check on it, approve a tool call from your phone's lock screen, or kick off the next one. Sessions survive disconnects: the shell (and whatever's running in it) keeps going when you close the tab; reconnect and the scrollback replays. The server is a **byte-shuttle** — it ferries raw bytes between the shell and the browser and never parses terminal/ANSI semantics; xterm.js renders, node-pty provides the TTY. > ⚠️ **This hands a full shell to anyone who can reach the port.** LAN-only, no authentication. **Never** port-forward or tunnel it to the public internet. See [Security & deployment](#security--deployment). --- ## Features ### Core terminal - **WebSocket terminal** — xterm.js in the browser, node-pty on the host. Keystrokes shuttle to the shell; output streams back. No terminal parsing on the server. - **Sessions survive disconnects** — the PTY lifecycle is decoupled from the WebSocket. Closing a tab *detaches* a client; the shell keeps running. Reconnect replays a ~2 MB scrollback ring buffer. - **Auto-reconnect** — exponential backoff (1s/2s/4s… capped at 30s), carrying the `localStorage` session id, so a flaky network or a phone waking from sleep just resumes. - **Mobile touch key-bar** — one-tap Esc / Esc·Esc / ⇧Tab / arrows / Enter / ^C / ^O / ^T / ^B / ^R / ^L / ^D / Tab / `/` — the high-frequency Claude Code keys a soft keyboard can't produce, sent as raw bytes (no soft-keyboard pop). - **Multi-device session mirroring** — many devices can attach to the *same* session at once. Output, exit and status broadcast to all; any device can type (shared control). PTY sizing is **latest-writer-wins**: whichever device you're actively using drives the size and stays full-screen. ### Tabs & home - **Multi-tab** — each tab is an independent shell. Tabs auto-name to the current folder (double-click to rename), show a connection dot and an unread-output dot, and can be drag-reordered. `+` opens a new tab in the active tab's directory. - **Home Sessions chooser** — opening the app lands on a chooser, not a blank shell. It shows the host's running sessions as **live preview thumbnails** (read-only renders of each screen) so you can see what each one is doing, plus a dashed **+ New session** tile. Pick one to open (full scrollback replay) or start fresh. - **Per-session Open / Kill** — manage sessions right from the chooser; no separate manage page. - **Sessions ↔ Projects toggle** — a segmented control on the home screen flips between the running-sessions view and the projects view. - **⌂ Home overlay** — a Home button in the tab bar overlays the chooser on top of the current terminal so you can start another session/project without closing your tabs. ### Claude Code cockpit - **Live per-tab status** — Claude Code hooks POST to the server (loopback side-channel); each tab badge shows **working / waiting-for-approval / idle / stuck** in real time. Install once with `npm run setup-hooks`. - **Remote approve / reject** — when Claude asks for tool permission, the request is *held* server-side and an **Approve / Reject** bar appears on every attached device — resolve it with a tap, no typing. Works across multiple devices (closing one mirror doesn't cancel the prompt for the others). - **Plan-mode / permission-mode relay** — start a session in a chosen `--permission-mode` (default / acceptEdits / plan / auto). When Claude exits plan mode, the approval bar becomes a **three-way** gate (approve+auto / approve+review / keep planning). The high-risk `auto` mode is gated behind `ALLOW_AUTO_MODE`. - **tmux keepalive** — run the shell inside tmux so sessions survive a **server/host restart**, not just a disconnect. Auto-detected, or forced with `USE_TMUX`. - **Session history / resume** — browse past Claude Code sessions (from `~/.claude/projects`) and resume one. ### Projects (v0.6) - **Auto-discovered git repos** — scans configurable roots for `.git`, showing each repo's branch and dirty state. Read-only, cached, with a depth-bounded BFS that skips `node_modules`/dotdirs/symlinks. - **Per-project launchers** — each card has brand-logo buttons: **Claude** and **Codex** open a new tab running that CLI in the repo; **VS Code** asks the *host* to open the editor on that path. Projects with an active Claude session highlight the Claude button (with a count). - **Project detail page** — branch + a **git worktrees** list, the project's **active sessions** (open/kill), a **CLAUDE.md viewer** with a **Generate / Update (`/init`)** button, a **read-only git diff viewer**, and a **create-worktree** action. ### Walk-away workbench (v0.7) - **Mobile Web Push + lock-screen triage** — the host actively notifies your phone on **needs-input** (high priority, with **Allow / Deny** action buttons) and **done** (low priority). Approve or deny a held tool request straight from the lock screen without opening the app, secured by a per-decision capability token. Optional **ntfy / Pushover** bridge for setups without HTTPS Web Push. - **Voice dictation** — push-to-talk mic on the input bar (Web Speech API); release to send the transcript as input (optional auto-Enter). Hidden where unsupported; audio never touches the server. - **Quick-reply chips + saved-prompt palette** — tap-to-send chips (`yes`, `continue`, `1/2/3`, `Esc`) plus a persistent palette of your own named snippets. - **Activity timeline** — a human-readable, timestamped stream of what happened while you were gone ("ran Bash · edited 3 files · waiting for approval · done"), built from discrete hook events in a bounded per-session ring. - **Stuck / idle alert** — if a session goes silent past a threshold (no output, not idle, not exited) it fires a one-shot "possibly stuck" alert through the same notification channel. Runs on the existing reaper tick (no extra timer). - **statusLine telemetry → per-tab gauges** — a statusLine script feeds back context-usage, cumulative cost, model, lines +/−, PR state, and rate-limit (5h/7d) telemetry, rendered as **per-tab gauges** on tabs, thumbnails and project cards. Stale telemetry greys out. ### UX - Themed UI (Amber dark theme + light / solarized, adjustable font size), scrollback **search**, an all-sessions **dashboard**, **share-session QR/link** (`?join=`), **connect-device QR**, a keyboard-shortcut **cheat-sheet**, an installable **PWA**, clickable links, and a line-icon toolbar with hover tooltips. --- ## Quick start ### Prerequisites - **Node.js ≥ 18** (developed on v24). - **macOS / Linux.** On macOS, `node-pty` compiles a native addon — install **Xcode Command Line Tools** (`xcode-select --install`) if `npm install` fails. After a major Node version bump, run `npm rebuild`. ### Install & run ```bash npm install # installs deps (incl. node-pty native build); postinstall fixes the spawn-helper bit npm run build:web # bundle the frontend (public/main.ts → public/build/) with esbuild npm start # serve on 0.0.0.0:3000 ``` Then find your LAN IP and open it from any device on the same network: ```bash ipconfig getifaddr en0 # macOS # open http://:3000 ``` For frontend development, run `npm run dev:web` (esbuild `--watch`) alongside `npm start`. ### Enable the Claude Code cockpit (optional but recommended) ```bash npm run setup-hooks # adds the hooks + statusLine to ~/.claude/settings.json (backs it up) # npm run setup-hooks -- --remove # to uninstall ``` This wires Claude Code's hooks → **live per-tab status**, the **statusLine gauges**, and **push** notifications. The hooks are a no-op outside web-terminal (they only fire when `$WEBTERM_*` env vars are set in spawned shells), so they're safe to leave installed. Then run `claude` inside a tab. `USE_TMUX=1 npm start` keeps sessions alive across a server restart. ### Tests ```bash npm test # vitest, all modules (~470 tests, 80% coverage gate) npm run typecheck # tsc (backend + frontend) npm run build # compile backend to dist/ ``` --- ## Configuration All config is via environment variables (no hardcoding). Invalid values fail fast at startup. ### Core | Var | Default | Purpose | |-----|---------|---------| | `PORT` | `3000` | Listen port. | | `BIND_HOST` | `0.0.0.0` | Listen address. | | `SHELL_PATH` | `$SHELL` or `/bin/zsh` | Shell to spawn. | | `IDLE_TTL` | `86400` (s) | Reclaim a detached session after this idle time (no new output since detach). | | `SCROLLBACK_BYTES` | `2097152` (2 MB) | Per-session replay ring buffer size. | | `MAX_PAYLOAD_BYTES` | `1048576` (1 MB) | Max single WS frame; oversized frames are rejected. | | `WS_PATH` | `/term` | The only path accepted for WS upgrade. | | `MAX_SESSIONS` | `50` | Cap on concurrent sessions (DoS guard). | | `MAX_MSGS_PER_SEC` | `2000` | Per-connection WS frame-rate cap; over-limit frames are dropped (not disconnected). | | `USE_TMUX` | `auto` | `1`/`0`/`auto` — run the shell inside tmux (keepalive across restart); `auto` = on if `tmux` is on PATH. | | `ALLOWED_ORIGINS` | (derived) | Extra allowed WS origins, comma-separated. The base list is derived from the host's NIC IPs + localhost — never from `BIND_HOST`. | | `PERM_TIMEOUT_MS` | `300000` (5 min) | How long a held tool-permission request waits for a remote decision before falling back to Claude's own prompt. Must be > 0. | | `REAP_INTERVAL_MS` | `60000` | Idle-reaper / stuck-sweep tick interval. | | `PREVIEW_BYTES` | `24576` (24 KB) | Tail of scrollback served for live preview thumbnails. | ### Projects (v0.6) | Var | Default | Purpose | |-----|---------|---------| | `PROJECT_ROOTS` | `$HOME` | Comma-separated absolute roots to scan for git repos (`~`/`~/...` expanded; relative paths rejected). | | `PROJECT_SCAN_DEPTH` | `4` | How deep to descend looking for `.git`. | | `PROJECT_SCAN_TTL` | `10000` (ms) | Cache TTL for repo discovery. | | `PROJECT_DIRTY_CHECK` | `1` (on) | Run `git status --porcelain` to flag dirty repos. | | `EDITOR_CMD` | `code` | Command the host runs for the project "VS Code" button. | ### Walk-away workbench (v0.7) | Var | Default | Purpose | |-----|---------|---------| | `VAPID_PUBLIC_KEY` | (unset → push off) | Web Push public key, exposed to the service worker. | | `VAPID_PRIVATE_KEY` | (unset, **secret**) | Web Push private key; required to enable push. Never logged. | | `VAPID_SUBJECT` | `mailto:admin@localhost` | VAPID `sub`. | | `PUSH_STORE_PATH` | `~/.web-terminal-push-subs.json` | Persisted push-subscription store. | | `PUSH_MAX_SUBS` | `50` | Max stored push subscriptions (DoS guard). | | `NOTIFY_DONE` | `1` (on) | Send the low-priority DONE push on Stop/SessionEnd. | | `NOTIFY_DND` | `0` (off) | Global do-not-disturb default. | | `DECISION_TOKEN_TTL_MS` | = `PERM_TIMEOUT_MS` | Lifetime of a lock-screen decision capability token. | | `TIMELINE_MAX` | `200` | Per-session activity-timeline event ring cap. | | `TIMELINE_ENABLED` | `1` (on) | Capture/serve the activity timeline. | | `STUCK_TTL` | `600` (s) | Silence window before a "possibly stuck" alert; `0` disables. | | `STUCK_ALERT` | `1` (on) | Master switch for stuck alerts. | | `DIFF_TIMEOUT_MS` | `2000` | Timeout for a single `git diff`. | | `DIFF_MAX_BYTES` | `2097152` (2 MB) | Patch truncation cap. | | `DIFF_MAX_FILES` | `300` | Max files returned before the diff is marked `truncated`. | | `STATUSLINE_TTL_MS` | `30000` | After this with no update, per-tab telemetry greys out as stale. | | `WORKTREE_ENABLED` | `1` (on) | Master switch for the create-worktree feature (the only write-to-disk action). | | `WORKTREE_ROOT` | (unset → `-worktrees`) | Root that new worktrees must land inside. | | `WORKTREE_TIMEOUT_MS` | `10000` | Timeout for `git worktree add`. | | `DEFAULT_PERMISSION_MODE` | `default` | `--permission-mode` used when a session is started without an explicit choice (`default`/`acceptEdits`/`plan`/`auto`). | | `ALLOW_AUTO_MODE` | `0` (off) | Whether the high-risk `auto` (bypass-permissions) mode is offered/honored. | ### Spawn-injected env (set by the server / read by hook scripts — not user config) `WEBTERM_SESSION`, `WEBTERM_HOOK_URL`, `WEBTERM_STATUSLINE_URL` are injected into each spawned shell so the hooks/statusLine know which session they belong to and where to POST. The optional ntfy/Pushover bridge reads `WEBTERM_NTFY_URL` / `WEBTERM_NTFY_TOPIC` / `WEBTERM_NTFY_TOKEN` and `WEBTERM_PUSHOVER_TOKEN` / `WEBTERM_PUSHOVER_USER` — secrets stay in env, never written into `settings.json` or command argv. --- ## Security & deployment This is a **no-auth, LAN-only** tool by design — it hands a full shell to anyone who can reach the port. The defenses that matter: - **WS Origin validation (cannot be skipped):** the WebSocket handshake rejects any `Origin` not on the allow-list (HTTP 401). This blocks Cross-Site WebSocket Hijacking — a malicious page in your browser trying to connect to `ws://:3000`. Only `WS_PATH` is accepted for upgrade. - **CSRF / Origin guards on state-changing routes:** every route with a side effect (`DELETE /live-sessions`, `POST /open-in-editor`, `POST /push/subscribe`, `POST /hook/decision`, `POST /projects/worktree`) requires an allowed Origin (403 otherwise). Read-only discovery routes don't. - **Loopback-only hook ingest:** the side-channel ingest endpoints (`/hook`, `/hook/permission`, `/hook/status`) only accept loopback peers — the Claude process always runs on the host. - **Per-IP rate limits** on push subscribe and lock-screen decision routes; a per-connection WS frame-rate cap. - **Safe git exec + per-decision capability tokens:** all `git` calls use `execFile` (no shell) with timeouts and path validation; the lock-screen Allow/Deny is authorized by a token bound to that session's current pending request (it expires on resolve/timeout). - **No authentication yet.** Treat the whole app as "shell access for anyone on the network." **Never expose it to the public internet.** `ws://` is unencrypted, so on an untrusted network keystrokes (passwords, API keys) can be sniffed. The recommended deployment is **[Tailscale](https://tailscale.com/)** (WireGuard-encrypted), which also gives you `wss://` — the frontend auto-selects `wss` on HTTPS. **Web Push requires HTTPS** (a secure context), so the push features need the Tailscale/TLS deploy; bare LAN-over-HTTP falls back to the ntfy/Pushover bridge. --- ## Architecture The server is a **byte-shuttle, not a terminal**: `node-pty` gives the shell a real pseudo-terminal, the server shuttles raw bytes over the WebSocket, and `xterm.js` in the browser interprets ANSI and renders. The server never parses terminal semantics — every "smart" feature (status, timeline, telemetry, diff, push) rides an **out-of-band side-channel** (loopback HTTP/JSON from Claude Code hooks, or `git` subprocesses), keeping the terminal stream a pure pipe. The other central design point: **PTY lifecycle ≠ WebSocket lifecycle.** A WS close *detaches* a client (the PTY keeps running for other devices and for reconnect); only an idle timeout (or explicit kill / server shutdown) ends a session. Tested with **vitest** (~470 tests, 80% coverage gate across backend + the logic-bearing frontend modules), plus real-PTY integration tests that auto-skip where `posix_spawn` is unavailable (sandboxes) and run everywhere else. Design and rationale: [`docs/TECH_DOC.md`](docs/TECH_DOC.md) (the *why*) and [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) (the *how*). Feature PRDs: [`docs/FEATURE_PROJECT_MANAGER.md`](docs/FEATURE_PROJECT_MANAGER.md) (v0.6) and [`docs/FEATURE_WALKAWAY_WORKBENCH.md`](docs/FEATURE_WALKAWAY_WORKBENCH.md) (v0.7).