tmux-web: Your tmux Sessions (and AI Agents) in the Browser
SSH into a machine, tmux attach, get interrupted, SSH again, scroll to find where you were.
On mobile it’s worse — there’s no good story for attaching to a long-running session from your phone.
The fix is a single command:
npx tmux-web
# or install globally
npm i -g tmux-webOpen localhost:3000 and every tmux session on the machine is a clickable terminal in your browser — from laptop or phone.
No agent to install on the remote, no SSH tunneling required for local use.
How it works
tmux-web is a Hono HTTP server.
On startup it lists your sessions with tmux list-sessions.
When you click one, the browser opens a WebSocket to /ws/<session-name>.
The server uses node-pty to spawn tmux attach-session -t <name>, and from there it’s a bidirectional byte pipe:
browser ⇄ WebSocket ⇄ node-pty ⇄ tmux attach-sessionThe terminal state is served tail-first: when the connection opens, the server snapshots the last N lines of the pane buffer so the screen loads instantly.
Scrolling up triggers on-demand history loading — the browser sends a request, the server runs tmux capture-pane with the right offset, and earlier output streams in.
Alternate-screen mode (vim, less, htop) is tracked separately so the main scrollback isn’t corrupted when you enter or exit a TUI program.
Two renderers: xterm.js by default, Ghostty if you want it
The honest story: ghostty-web (the WASM build of Ghostty) is supported but experimental.
xterm.js is the default because it’s bundled locally with no CDN dependency — the stylesheet ships at /assets/xterm.css and everything is served from localhost.
ghostty-web lazy-loads from esm.sh, pinned to @0.4.0 so a new upstream release can’t silently break the renderer:
const GHOSTTY_WEB_URL = 'https://esm.sh/ghostty-web@0.4.0';If the CDN fetch or WASM initialization fails, the client falls back to xterm.js automatically and surfaces a notice.
Here is the exact adapter-selection logic from src/browser/terminal-client.ts:
async function createTerminal(): Promise<TerminalAdapter> {
if (cfg.renderer === 'ghostty') {
try {
return await GhosttyAdapter.create(container, cfg.scrollback, cfg.theme);
} catch (err) {
console.error('[tmux-web] ghostty-web failed to load; falling back to xterm.js:', err);
showRendererFallbackNotice();
}
}
return XtermAdapter.create(container, cfg.scrollback, cfg.theme);
}To switch renderers:
tmux-web setup # interactive prompt writes terminalRenderer to ~/.config/tmux-web/settings.json
tmux-web --ghostty # per-run override (ghostty for this session only)
TMUX_WEB_TERMINAL_RENDERER=ghostty tmux-web # env var overrideThe principle: ship the cool thing, default to the boring thing that always works.
Watch your AI agents from anywhere
The /agents page is the headline feature for anyone running AI coding agents in tmux.
src/lib/agent-detect.ts identifies which agent is running in a pane by inspecting its foreground process — unwrapping node/bun/python launchers to find the real binary — and then classifies its state from the screen buffer:
- Claude Code, Codex, OpenCode, Cursor Agent, Droid, GitHub Copilot are all detected.
- State is one of
idle,working, orblocked.
The state heuristics look at only the bottom 20 non-blank lines of the visible screen (the “live region” where agents render their status UI), never the whole scrollback. That avoids false positives from conversation history that’s full of words like “yes”, “no”, “do you want”.
Streaming-response phases are where this gets interesting.
When Claude Code (or any agent) is streaming a response, it renders no UI chrome at all — no spinner, no interrupt hint — so the substring heuristics would incorrectly report idle.
The probe layer compensates with screen-delta detection between polls:
a cheap djb2 hash of the screen buffer is computed each cycle, and if the hash changed since the previous probe, idle is upgraded to working:
const h = hashScreen(screen);
const prev = lastScreenHash.get(pane.paneId);
lastScreenHash.set(pane.paneId, h);
if (state === 'idle' && prev !== undefined && prev !== h) state = 'working';The result: “which of my agents is blocked waiting on me?” is answerable from your phone without SSH.
Image paste rounds out the AI-agent workflow.
Cmd/Ctrl+V or drag-and-drop an image onto the terminal → the browser uploads it to the server, which saves it under ~/.tmux-web/uploads/ with a date-stamped folder, magic-byte sniffs the MIME type (PNG/JPEG/WebP/GIF), enforces a 10 MiB default cap (TMUX_WEB_MAX_IMAGE_UPLOAD_BYTES to override), then injects the absolute file path directly into the active pane.
That is exactly the workflow Claude Code’s image input expects — drop an image, path is already in the terminal.
Built for the phone in your pocket
On narrow viewports (≤ 560 px) a bottom toolbar appears with two buttons:
- Mic — browser Web Speech API; transcript lands in a review modal before sending so you can edit before committing.
- Keyboard — opens the same “Send to pane” modal with a
Sendbutton (inserts text as-is) and aSend ⏎button (appends a carriage return).
The sessions sidebar and windows drawer let you tap between sessions and switch windows without needing to type the tmux prefix key.
Tapping a window triggers tmux select-window on the server.
visualViewport resize events are handled so the software keyboard doesn’t bury the terminal when it slides up.
Daily-driver extras
- Per-session Markdown notes — stored via lowdb at
~/.tmux-web/db.json, attached to a session or global; accessible from the notes drawer on any terminal page. - Scheduler — “run this command in that window in 1h30m”; sends keys via
tmux send-keysat the scheduled time, survives server restarts by persisting tasks to db.json, and has a/schedulehistory page that retains fired/missed tasks for 7 days by default (scheduleHistoryDaysin settings). - ⌘K command bar — opt-in via
tmux-web setup; fuzzy session/window switcher and quick actions. - Themes —
tmux-web theme set vscodeortmux-web theme set ghostty; theme JSON lives at~/.config/tmux-web/theme.jsonand applies to both the shell palette and the terminal renderer. /settingsweb page — most config is editable from the browser without touching files.
Extensions over Unix sockets
Each extension is an npm package with a tmux-extension.json manifest and a backend server listening on a Unix domain socket.
The host reverse-proxies /ext/<id>/api/* through http.request({ socketPath }) — no ports to allocate, no CORS headers, no token plumbing between host and extension.
The UI is an iframe served from /ext/<id>/ui/*, communicating with the host via postMessage using @tmux-web/ext-sdk.
Installing an extension:
tmux-web add @tmux-web/ext-git-workflowtmux-web add runs npm install into ~/.tmux-web/, writes the package name to ~/.config/tmux-web/settings.json, and the extension is live on the next restart.
The bundled git-workflow extension is the showcase example:
- Live branch name and diff status for the active pane’s current working directory.
- One-click worktree handoff: creates
~/.worktrees/<org>/<repo>/<id>/, and if the active pane is at a shell prompt (not running a program), automaticallycds into it. - Commit + push from the browser — stages everything, commits with your message, and pushes.
The extension socket path is tmux-web-ext-<id>.sock in the OS temp directory (os.tmpdir()), allocated at runtime by the host before spawning the backend process via the manifest’s start field.
Security notes (important)
tmux-web serves a real shell on your machine over HTTP.
There is no authentication layer built in.
- Bind to localhost only (the default) and never expose port 3000 to the internet.
- Reach it remotely via SSH tunnel:
ssh -L 3000:localhost:3000 yourhostthen openlocalhost:3000locally. - Or use Tailscale — run
tmux-webon a node, reach it from any device on your tailnet. - Running on a shared or cloud machine without one of the above? Don’t.
The upload directory (~/.tmux-web/uploads/) is created mode 0700 and individual files are 0600.
Image data is magic-byte verified against the declared MIME type before saving.
TL;DR
- One command:
npx tmux-web, openlocalhost:3000. - Real PTYs over WebSocket — not a screen-scraper, not VNC.
- xterm.js by default (local, no CDN); ghostty-web opt-in (pinned to @0.4.0, auto-fallback).
/agentspage detects Claude Code, Codex, OpenCode, Cursor, Droid, Copilot — idle/working/blocked — with screen-delta hashing for streaming phases.- Image paste: Cmd/Ctrl+V or drag-and-drop → absolute path injected into the pane, ready for Claude Code.
- Voice input on mobile via Web Speech API; sessions and windows browsable without a tmux prefix key.
- Per-session Markdown notes, scheduler with history, opt-in ⌘K command bar, themes.
- Unix-socket extension system — no ports, no CORS; bundled git-workflow extension for branch/diff/worktree/commit+push.
- MIT licensed. Source: github.com/ashutoshpw/tmux-web . Package: npmjs.com/package/tmux-web .