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.
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
localStoragesession 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-riskautomode is gated behindALLOW_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 skipsnode_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/, branchfeat/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. Seeios/README.md. (Not yet merged.) - Desktop app (
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 indocs/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; seedocs/PLAN_RELAY_INDEX.mdanddocs/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-ptycompiles a native addon — install Xcode Command Line Tools (xcode-select --install) ifnpm installfails. After a major Node version bump, runnpm rebuild.
Install & run
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:
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)
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
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 → <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
Originnot on the allow-list (HTTP 401). This blocks Cross-Site WebSocket Hijacking — a malicious page in your browser trying to connect tows://<your-lan-ip>:3000. OnlyWS_PATHis 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
gitcalls useexecFile(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 (WireGuard-encrypted), which also gives youwss://— the frontend auto-selectswsson 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 (the why) and docs/ARCHITECTURE.md (the how). Feature PRDs: docs/FEATURE_PROJECT_MANAGER.md (v0.6) and docs/FEATURE_WALKAWAY_WORKBENCH.md (v0.7).