From 7b3ba8491a228c05fc354f7b893456979a646e82 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Tue, 30 Jun 2026 21:26:24 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20rewrite=20README=20to=20cover=20all=20f?= =?UTF-8?q?eatures=20(v0.1=E2=80=93v0.7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 212 ++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 141 insertions(+), 71 deletions(-) diff --git a/README.md b/README.md index 47e365a..7657904 100644 --- a/README.md +++ b/README.md @@ -1,103 +1,173 @@ # Web Terminal -A browser-based terminal that exposes the **host machine's local shell** over WebSocket. Open `http://:3000` from any device on your LAN (phone, tablet, another computer) and you get a live, interactive terminal — primarily for **vibe coding**: hand Claude Code a task, walk away, reconnect from anywhere to check on it. +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 output replays. +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](#security). +> ⚠️ **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 -- **Multi-tab** — each tab is an independent shell session. 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. -- **Claude Code cockpit** (with hooks, see below) — each tab shows Claude's status (⚙ working / ⏳ needs approval / ✓ idle), sends a browser notification when it needs you, and lets you **tap Approve / Reject** on a tool request with no typing. -- **tmux keepalive** (optional) — run the shell inside tmux so sessions survive a server/host restart, not just a disconnect. -- **Mobile + desktop shortcut bar** — one-tap Esc / Esc·Esc / ⇧Tab / arrows / Enter / ^C / ^O / ^T / ^B / Tab / `/` (the Claude Code keys a phone keyboard can't produce). -- **Session keepalive + replay** — the PTY keeps running across disconnects; reconnect replays a ~2 MB scrollback ring buffer. -- **Toolbar** — 🔍 scrollback search · ⚙ themes & font size · ▦ all-sessions dashboard · 📱 QR connect (scan to open on another device). Clickable links. Installable as a PWA. +### 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. -## Requirements +### 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. -- 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. +### 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. -## Install +### 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; postinstall makes node-pty's spawn-helper executable -``` - -## Run - -```bash -npm run build:web # bundle the frontend (public/main.ts → public/build/main.js) +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: - +Then find your LAN IP and open it from any device on the same network: ```bash -# find your LAN IP (macOS): -ipconfig getifaddr en0 -# open http://:3000 on any device on the same network +ipconfig getifaddr en0 # macOS +# open http://:3000 ``` -For frontend development, run `npm run dev:web` (esbuild --watch) alongside `npm start`. - -## Claude Code cockpit (optional) - -To see Claude's status per tab and approve/reject tool calls from your phone, install the hooks once: +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 http hooks to ~/.claude/settings.json (backs it up) +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. -Then run `claude` inside a tab. The hooks POST to the server (loopback only) when Claude starts/stops/needs permission; the tab badge updates live, and a `PermissionRequest` shows an **Approve / Reject** bar. The hooks are a no-op outside web-terminal (they curl `$WEBTERM_HOOK_URL`, which is only set in spawned shells), so they're safe to leave installed. - -**tmux keepalive** — run with `USE_TMUX=1` (or just have `tmux` on PATH; it auto-detects) to keep sessions alive across a server restart: +`USE_TMUX=1 npm start` keeps sessions alive across a server restart. +### Tests ```bash -USE_TMUX=1 npm start -``` - -## Configuration - -All via environment variables (no hardcoding): - -| Var | Default | Meaning | -|-----|---------|---------| -| `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 | -| `SCROLLBACK_BYTES` | `2097152` | per-session replay ring buffer (bytes) | -| `MAX_PAYLOAD_BYTES` | `1048576` | max single WS frame | -| `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 | - -`allowedOrigins` is **derived from the host's network-interface IPs** (plus `localhost` and anything in `ALLOWED_ORIGINS`) — never from `BIND_HOST`, since `0.0.0.0` is never a real browser Origin. - -## Security - -This is a no-auth, LAN-only tool by design. The defenses that matter: - -- **Origin check (cannot be skipped):** the WS 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`. -- **Path-scoped upgrades:** only `/term` is accepted for WS upgrade. -- **Frame size cap:** oversized frames are rejected (`MAX_PAYLOAD_BYTES`). -- **Never expose to the public internet.** `ws://` is **unencrypted** — on an untrusted network (café/office Wi-Fi) traffic (including keystrokes: passwords, API keys) can be sniffed. The recommended deployment is **[Tailscale](https://tailscale.com/)** (WireGuard-encrypted), which also lets you use `wss://` (the frontend auto-selects `wss` on HTTPS). - -## Development - -```bash -npm test # vitest, all modules +npm test # vitest, all modules (~470 tests, 80% coverage gate) npm run typecheck # tsc (backend + frontend) npm run build # compile backend to dist/ ``` -Real-PTY end-to-end tests (`test/integration/`) auto-skip where `posix_spawn` is unavailable (e.g. sandboxes) and run everywhere else. +--- -## How it works +## Configuration -The server is a **byte-shuttle, not a terminal**: `node-pty` provides a pseudo-terminal so the shell believes it has a real TTY, and `xterm.js` in the browser interprets the ANSI bytes and renders. The server never parses terminal semantics. PTY lifecycle is decoupled from the WebSocket — a disconnect *detaches* (the PTY keeps running) rather than killing it, which is what makes session survival work. +All config is via environment variables (no hardcoding). Invalid values fail fast at startup. -Design and rationale: [`docs/TECH_DOC.md`](docs/TECH_DOC.md) (why) and [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) (how). +### 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).