A mobile-first, self-hosted web terminal powered by Ghostty's VT engine compiled to WebAssembly — with sessions that survive network drops, a built-in file explorer, and herdr integration.
Most web terminals die with the WebSocket. This one is built around the part of mosh that actually matters over TCP: the server owns the terminal, not the connection.
- Full-spec rendering — the client renders through
ghostty-web, Ghostty's real VT parser compiled to a ~400 KB WASM module: truecolor, complex grapheme clusters, CJK widths, emoji, alt-screen TUIs. - Disconnect-surviving sessions — PTYs live in the server. Lose the network, close the tab, switch from Wi-Fi to LTE: reconnect and the session is exactly where you left it, repainted from a bounded replay buffer with cumulative byte offsets.
- Low-bandwidth friendly — binary WebSocket frames, 8 ms output batching, permessage-deflate, exponential-backoff reconnect with jitter, 15 s heartbeat with live latency readout.
- Mobile-first UX — a touch key toolbar (Esc / Tab / sticky Ctrl / arrows / ^C / paste), safe-area insets, 44 px touch targets, drawer-mode sidebar, clean at 375 px, Korean IME composition support.
- Files sidebar — browse, upload, download, edit, and delete files under a jailed root directory.
- herdr integration — the server keeps a local herdr server alive (auto-start on boot and on demand) and the sidebar shows its live workspaces and agent statuses over herdr's NDJSON socket API.
Requires Bun ≥ 1.4 (canary).
bun install
WT_PASSWORD='choose-a-long-password' bun run start
# open http://127.0.0.1:7777| Env | Default | Meaning |
|---|---|---|
WT_PORT |
7777 |
Listen port |
WT_HOST |
127.0.0.1 |
Bind address (0.0.0.0 to expose on a LAN/tailnet) |
WT_PASSWORD |
— | Login password (≥ 8 chars; hashed with argon2id at boot) |
WT_PASSWORD_HASH |
— | Pre-computed argon2id hash (overrides WT_PASSWORD) |
WT_FILES_ROOT |
$HOME |
File explorer jail root |
WT_HERDR_SOCKET |
~/.config/herdr/herdr.sock |
herdr socket path |
WT_ALLOWED_ORIGINS |
(same-origin) | Extra allowed origins for WebSocket upgrades |
WT_TRUSTED_BIND |
(unset) | Extra listener address that skips the password (e.g. a Tailscale IP). Unset = single password-protected listener |
WT_TRUSTED_PORT |
WT_PORT |
Port for the trusted listener |
WT_SHELL |
(auto) | Shell for new sessions (defaults to your $SHELL, or zsh/bash/sh; never fish, which stalls on VT capability queries) |
WT_DEV |
0 |
1 enables Bun's dev-mode host guard (local development only) |
Deployment is intentionally not part of CI — it runs wherever the machine you are exposing lives:
WT_PASSWORD='choose-a-long-password' ./scripts/deploy.sh
# prints a https://<random>.trycloudflare.com URL
./scripts/stop.sh # tear down server + tunnelFor a tailnet instead, bind to your tailscale interface: WT_HOST=0.0.0.0 bun run start and open http://<machine>.<tailnet>.ts.net:7777.
- Password auth — argon2id (64 MiB, t=3) via
Bun.password; constant-time verification. - Sessions — 256-bit random tokens in
HttpOnly+SameSite=Laxcookies (+Securebehind HTTPS), 7-day TTL. - Rate limiting — 5 failed logins per 5 minutes per IP (honors
CF-Connecting-IPbehind Cloudflare), then429+Retry-After. - WebSocket — upgrades require a valid session cookie and a same-origin
Originheader. - File jail — every path is resolved inside
WT_FILES_ROOT;..traversal, absolute paths, and symlink escapes are rejected with real-path checks.
WT_TRUSTED_BIND starts a second listener on an address only your private
network can reach (a Tailscale interface IP). Requests accepted on that socket are
pre-authenticated; requests on the public listener always need the password:
WT_PASSWORD='...' WT_PORT=7820 WT_HOST=127.0.0.1 \
WT_TRUSTED_BIND=100.x.y.z WT_TRUSTED_PORT=7820 bun run startThe bypass is a property of which socket accepted the connection, never of a
request header. CF-Connecting-IP, X-Forwarded-For and friends are settable by
anyone once traffic arrives through a public tunnel, so they are ignored for trust
decisions — reaching the tailnet socket is itself the proof of membership, and that
socket is unroutable from the internet. The bypass is off unless WT_TRUSTED_BIND
is set, so a misconfigured deploy fails closed to password auth.
Point cloudflared at the loopback listener only, never at the trusted one.
One password guards a real shell on your machine: run it behind cloudflared/Tailscale, use a long password, and never expose it as plain HTTP on an untrusted network.
┌────────────── browser ──────────────┐ ┌──────────────── bun server ───────────────┐
│ ghostty-web (WASM VT) sidebar UI │ │ Bun.serve — static + REST + WebSocket │
│ ▲ write() ▲ fetch /api/* │ │ ├─ auth (argon2id, sessions, rate limit) │
│ │ │ │ │ ├─ files (jailed root) │
│ TerminalConnection ◄────────────────┼────┼──►├─ ws-handler ── SessionStore │
│ offsets · backoff · heartbeat │ WS │ │ ├─ Bun.Terminal (PTY) │
│ │ │ │ └─ ReplayBuffer (4 MiB) │
│ │ │ └─ herdr bridge ── ~/.config/herdr/*.sock│
└─────────────────────────────────────┘ └────────────────────────────────────────────┘
Reconnect protocol — every output byte has a cumulative offset. The client says hello {sessionId, lastOffset}; if the offset is still inside the server's replay buffer it receives exactly the missed bytes, otherwise it gets a reset plus the buffer tail. Input, resize, ping/pong and repaints all flow over one WebSocket with 1-byte-opcode binary frames.
bun run dev # hot-reloading server
bun test # unit + integration tests
bun run typecheck # tsgo --noEmit (TypeScript 7 native preview)
bun run lint # biome
node script/qa/e2e-scenarios.mjs # real-browser QA (playwright)
./script/qa/api-scenarios.sh # curl-level auth/files/herdr QAThe design system lives in DESIGN.md — every color, spacing, and motion value in the UI traces back to a token there.