Root cause: whole tab was draggable=true, so a click with any micro-movement became a (no-op) drag instead of a click → switch failed. Plus every event (activity/title/status) rebuilt the entire tab bar via replaceChildren, which could destroy the element mid-click. Fix: - switch on pointerdown (fires before drag arms; also works on touch) - build tab DOM once; update classes/dot/label IN PLACE (refreshTab); full rebuild only on structural change (add/close/reorder/rename) - close/rename-input stopPropagation on pointerdown so they don't re-activate Verified: single pointerdown/click switches reliably across tabs; rename + drag still work. 198 tests green.
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.
Sessions survive disconnects: the shell (and whatever's running in it) keeps going when you close the tab; reconnect and the output replays.
⚠️ 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.
Requirements
- 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.
Install
npm install # installs deps; postinstall makes node-pty's spawn-helper executable
Run
npm run build:web # bundle the frontend (public/main.ts → public/build/main.js)
npm start # serve on 0.0.0.0:3000
Then:
# find your LAN IP (macOS):
ipconfig getifaddr en0
# 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.
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 |
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
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. - Path-scoped upgrades: only
/termis 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 (WireGuard-encrypted), which also lets you usewss://(the frontend auto-selectswsson HTTPS).
Development
npm test # vitest, all modules
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
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.
Design and rationale: docs/TECH_DOC.md (why) and docs/ARCHITECTURE.md (how).