Overview

tmux-web: Your tmux Sessions (and AI Agents) in the Browser

Ashutosh Kumar,•7 min read

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-web

Open 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-session

The 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 override

The 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:

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:

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

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-workflow

tmux-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:

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.

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

© 2026 Ashutosh Kumar.Back to Portfolio

Default Content