Explains why hooks reliably find nvm/brew-managed node (shells spawn as login shells) and how to recover an old session with a stale PATH.
206 lines
18 KiB
Markdown
206 lines
18 KiB
Markdown
# 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://<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 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=<id>`), **connect-device QR**, a keyboard-shortcut **cheat-sheet**, an installable **PWA**, clickable links, and a line-icon toolbar with hover tooltips.
|
||
|
||
---
|
||
|
||
## Clients
|
||
|
||
The browser is the reference client; two native clients and a remote-access
|
||
service are also in the repo (all consume the same wire protocol — the server
|
||
stays a byte-shuttle).
|
||
|
||
- **iOS / iPad app** ([`ios/`](ios/), branch `feat/ios-client`) — a native
|
||
SwiftUI + SwiftTerm **pure remote client** built for the walk-away loop:
|
||
native terminal with scrollback replay, QR/manual pairing (Keychain), the
|
||
remote **Approve / Reject** gate + three-way plan gate, **APNs push with
|
||
lock-screen Allow / Deny behind Face ID**, deep links, Projects, multi-session
|
||
switcher, timeline, diff, quick-reply, and an **adaptive iPhone/iPad split
|
||
layout**. Looks like the desktop (amber-gold, dark-first). P0 needs zero server
|
||
changes; P1 adds two declared additive touch-points. See
|
||
[`ios/README.md`](ios/README.md). *(Not yet merged.)*
|
||
- **Desktop app** ([`desktop/`](desktop/)) — a Mac/Windows **Electron shell that
|
||
embeds this Node server + node-pty** (all-in-one), for native notifications,
|
||
tray, deep links and launch-at-login. Design in
|
||
[`docs/DESKTOP_PLAN.md`](docs/DESKTOP_PLAN.md). *(Built; packaged & launch-
|
||
verified on Apple Silicon.)*
|
||
- **Rendezvous relay** (`term-relay/` · `agent/` · `relay-e2e/` · `relay-web/` ·
|
||
`relay-auth/` · `relay-contracts/`) — an "ngrok-for-Claude-Code" with
|
||
**end-to-end encryption** to reach a self-hosted host from anywhere, without a
|
||
VPN or port-forwarding. Security-audited but **pre-production / not deployed**;
|
||
see [`docs/PLAN_RELAY_INDEX.md`](docs/PLAN_RELAY_INDEX.md) and
|
||
[`docs/DEPLOY_RELAY.md`](docs/DEPLOY_RELAY.md). For remote access **today**, use
|
||
**Tailscale** (§ Security & deployment below).
|
||
|
||
---
|
||
|
||
## 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://<that-ip>: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.
|
||
|
||
> **Login shells (why hooks can always find `node`)** — sessions spawn the shell as a **login shell** (`zsh -l`, POSIX only), so it loads your full profile (`~/.zprofile`, `~/.zshrc`, …) and rebuilds `PATH`. Without this, a GUI-launched app (desktop build) or a long-lived tmux keepalive can hand the shell a minimal `PATH`, and hooks that call an nvm-/brew-managed `node` fail with `node: command not found`. If you still hit that on an old session, start a fresh one so it picks up the login-shell `PATH`.
|
||
|
||
`USE_TMUX=1 npm start` keeps sessions alive across a server restart.
|
||
|
||
### Tests
|
||
```bash
|
||
npm test # vitest, all modules (~1470 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 → `<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).
|