Closes the six remediation items from the 2026-07-29 iOS completion audit plus the whole P2 wave and Android access-token parity. The audit's headline was that the client was code-complete but stuck at the device door: no DEVELOPMENT_TEAM, no entitlements, so it had never run on real hardware once, and it had fallen two months behind the server (Android had the git panel, iOS had none) while neither native client could connect at all once WEBTERM_TOKEN was set. Package tests 310 -> 452, app bundle 296 -> 550 (iPhone and iPad, zero known issues), integration 10 -> 32, Android 687 -> 691. ClientTLS went 55.76% -> 89.49% and is now actually in the coverage gate, which it never was. Device build now succeeds on the free personal team. src/ and public/ are untouched — the git-panel endpoints already existed server-side; iOS simply never consumed them. # Conflicts: # android/.gitignore # android/README.md # android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt # android/app/src/main/java/wang/yaojia/webterm/screens/PairingScreen.kt # android/app/src/main/java/wang/yaojia/webterm/viewmodels/PairingViewModel.kt # android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt # android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpClientFactory.kt # docs/PROGRESS_LOG.md
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 by design; the optional
WEBTERM_TOKENaccess token raises the bar but is not a substitute for TLS/Tailscale. 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.
Split-grid watch board (v0.8, desktop)
On a large screen (≥ 1024px) the terminal area can split into a grid so several live, interactive sessions show at once — built for watching multiple Claude Code runs in parallel. Desktop-only (a 2×2 of terminals is unusable on a phone); the server and wire protocol are untouched, and single-pane mode is unchanged.
- Layouts — a toolbar toggle cycles single / 1×2 / 1×3 / 2×2 / 2×3. The board shows the first N tabs (drag-reorder the tab bar, or drag a tab straight onto a quadrant, to choose which); a dashed + New session tile fills any empty slot. The choice persists.
- Click-to-focus — the quadrant you click wears a focus ring and owns the keyboard, mobile key-bar, voice, and the approval bar; Ctrl+` cycles focus (⇧ reverses). Only the focused pane takes keyboard focus, so panes don't fight over it.
- Inline approve per quadrant — a background quadrant waiting on a tool permission glows amber and shows its own ✓ / ✗ buttons, so you can clear approvals across several sessions without switching; its OS notification is suppressed while it's on screen.
- Maximize (⛶) / monitor (👁) per quadrant — ⛶ expands one quadrant to fill the grid (the others stay live behind it); 👁 flips a quadrant to a read-only preview (polled screen snapshots — no WebSocket attach, no resize) so watching a session in a small quadrant never shrinks it for another device using it full-screen.
- Resizable splitters + saved presets — drag the gutters between panes to re-balance column/row sizes (persisted per layout), and save a layout + its split as a named preset to re-apply in one click.
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 worktree create / prune / remove. - Git panel — ambient git state on the detail page: a sync band (
↑ahead ↓behind, upstream / detached-HEAD / "never fetched" states, green "in sync" only when nothing is pending and the last fetch is recent), a commit log with the unpushed boundary marked, PR status (via the host'sgh, when installed), and stage / commit / push / fetch actions. All writes are Origin-guardedPOST /projects/git/*routes runninggitthroughexecFile(no shell) with timeouts.
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; three 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/) — 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 (including the web's?join=share link), Projects with a git panel + worktree lifecycle,claude --resumehistory, multi-session switcher, timeline, diff, quick-reply, terminal find bar, voice push-to-talk with a confirm step, theme (system/dark/light) + Dynamic Type, and an adaptive iPhone/iPad split layout. Supports the optionalWEBTERM_TOKENaccess token (per-host, in the Keychain). Looks like the desktop (amber-gold, dark-first). P0 needed zero server changes; P1 added two declared additive touch-points. Merged intodevelop. Seeios/README.md. - Android app (
android/) — a Kotlin/Compose client targeting functional parity with iOS, module-for-module mirroring the iOS package set (:wire-protocol/:session-core/:api-client/:client-tls/:host-registry/:terminal-view/:app). Same wire protocol, same Origin-iff-guarded discipline, sameWEBTERM_TOKENcontract (hand-written cookie, Keystore-backed storage), plus FCM push with lock-screen Allow / Deny, Projects, timeline, diff, quick-reply and an adaptive layout. Terminal rendering wraps the Apache-2.0 Termux terminal view. All modules build and unit-test locally (./gradlew test :app:assembleDebug koverVerify) and the app has been installed and launched on an emulator; real-device QA is still pending. Design indocs/ANDROID_CLIENT_PLAN.md, setup notes inandroid/README.md. - 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.
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 rebuildsPATH. Without this, a GUI-launched app (desktop build) or a long-lived tmux keepalive can hand the shell a minimalPATH, and hooks that call an nvm-/brew-managednodefail withnode: command not found. If you still hit that on an old session, start a fresh one so it picks up the login-shellPATH.
USE_TMUX=1 npm start keeps sessions alive across a server restart.
Tests
npm test # vitest, all modules (~1600 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. |
WEBTERM_TOKEN |
(unset → auth disabled, secret) | Optional shared access token gating every HTTP route + the WS upgrade behind a webterm_auth cookie (alongside, never instead of, the Origin check). 16–512 chars of [A-Za-z0-9._~+/=-] or the server refuses to start. Never logged. See Security & deployment for the honest limits. |
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 LAN-only tool by design — with no token set it hands a full shell to anyone who can reach the port, and even with one set it is not an internet-facing service. 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+/worktree/prune+DELETE /projects/worktree,POST /projects/git/{stage,commit,push,fetch}) 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). - Optional shared access token (
WEBTERM_TOKEN) — a bar-raiser, not authentication. Unset ⇒ the gate is disabled and behaviour is byte-identical to a no-auth server (LAN zero-config preserved). Set (16–512 chars of[A-Za-z0-9._~+/=-], else the server refuses to start) ⇒ every HTTP route and the WS upgrade require anHttpOnly; SameSite=Strictwebterm_authcookie, delivered once byGET /?token=<t>orPOST /auth; the loopback hook ingest (/hook*) stays exempt so the Claude Code side-channel keeps working. It sits in front of the Origin check and never replaces it. Honest limits: on barews://the token travels in cleartext and is replayable by a LAN sniffer; it is one shared secret with no per-user identity and no revocation short of changing the env var and restarting. It meaningfully hardens only the TLS-terminated relay/tunnel path. Seesrc/http/auth.tsanddocs/plans/w5-access-token.md. - Beyond that, there is no authentication. 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 (~1600 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).