docs: rewrite README to cover all features (v0.1–v0.7)

This commit is contained in:
Yaojia Wang
2026-06-30 21:26:24 +02:00
parent 7fd2ef3fc8
commit 7b3ba8491a

212
README.md
View File

@@ -1,103 +1,173 @@
# Web Terminal # Web Terminal
A browser-based terminal that exposes the **host machine's local shell** over WebSocket. Open `http://<host-ip>: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://<host-ip>: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 ## 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. ### Core terminal
- **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. - **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.
- **tmux keepalive** (optional) — run the shell inside tmux so sessions survive a server/host restart, not just a disconnect. - **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.
- **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). - **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.
- **Session keepalive + replay** — the PTY keeps running across disconnects; reconnect replays a ~2 MB scrollback ring buffer. - **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).
- **Toolbar** — 🔍 scrollback search · ⚙ themes & font size · ▦ all-sessions dashboard · 📱 QR connect (scan to open on another device). Clickable links. Installable as a PWA. - **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) ### Claude Code cockpit
- macOS/Linux. On macOS, `node-pty` compiles a native addon — install **Xcode Command Line Tools** (`xcode-select --install`) if `npm install` fails. - **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=<id>`), **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 ```bash
npm install # installs deps; postinstall makes node-pty's spawn-helper executable 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
## Run
```bash
npm run build:web # bundle the frontend (public/main.ts → public/build/main.js)
npm start # serve on 0.0.0.0:3000 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 ```bash
# find your LAN IP (macOS): ipconfig getifaddr en0 # macOS
ipconfig getifaddr en0 # open http://<that-ip>:3000
# open http://<that-ip>:3000 on any device on the same network
``` ```
For frontend development, run `npm run dev:web` (esbuild --watch) alongside `npm start`. 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:
### Enable the Claude Code cockpit (optional but recommended)
```bash ```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 # 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. `USE_TMUX=1 npm start` keeps sessions alive across a server restart.
**tmux keepalive** — run with `USE_TMUX=1` (or just have `tmux` on PATH; it auto-detects) to keep sessions alive across a server restart:
### Tests
```bash ```bash
USE_TMUX=1 npm start npm test # vitest, all modules (~470 tests, 80% coverage gate)
```
## 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://<your-lan-ip>: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 run typecheck # tsc (backend + frontend) npm run typecheck # tsc (backend + frontend)
npm run build # compile backend to dist/ 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 → `<repo>-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://<your-lan-ip>: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).