Run and steer many Claude Code sessions at once — from your desk or from your phone.
Web dashboard (desktop + mobile) for up to 16 persistent Claude Code tmux sessions on one machine — sidebar with activity dots, native xterm.js scrollback (WebGL-rendered), direct typing into the focused session, a directory picker with recents for starting sessions per project. Plus: per-slot prompt history (🕘 / ArrowUp recall), print/PDF export (⇩), a conversation view that renders the claude transcript as structured messages (💬), password-gated session sharing for guests (SHARING.md), scheduled prompts (⏱ — one-shot or recurring with idle/claude-alive guard rails), and crash-resilient sessions — a died pane self-heals with claude --resume, keeping the conversation. Fork of claude-deck (single-session phone remote), generalized to a slot registry.
Requirements: Bun, tmux, the claude CLI, macOS or Linux.
Quickstart:
bun install
bun run build
FLEET_HOST=$(tailscale ip -4) bun server.ts # defaults to 127.0.0.1 (loopback only)The server prints a one-click login URL (http://<ip>:8790/?token=…) on boot when stdout is a terminal — with output redirected to a log (the tmux setup under Ops) it deliberately withholds the token; read it from fleet.json instead. Open the URL from any machine on the same private network. The token is stored in a SameSite=Strict cookie; you log in once per browser.
A reachable fleet is remote code execution as your user — every session is a shell. Defenses, in order:
- Bind address — defaults to loopback. Only set
FLEET_HOSTto a private (Tailscale/VPN/LAN) address you trust end-to-end; traffic is plainws://, so on anything but an encrypted overlay network (Tailscale is WireGuard) it's sniffable. - Access token — required on every API/WebSocket request (
?token=login URL → cookie, orAuthorization: Bearer). Generated on first boot and persisted infleet.json(mode 600); override withFLEET_TOKEN. - Cross-site guards —
SameSite=Strictcookie plus Origin and Host checks block cross-site WebSocket hijacking, CSRF, and DNS rebinding (a malicious website in a tab on the same machine can't reach the fleet, even though WebSockets ignore CORS). If you access the fleet via a hostname (e.g. MagicDNS), add it toFLEET_ALLOWED_HOSTS=myhost.tailnet.ts.net:8790. - Session command — defaults to plain
claude(with its permission prompts). Unattended mode is an explicit opt-in:FLEET_CMD='claude --dangerously-skip-permissions'. - Stream files and state are chmod 600/700 (terminal output can contain secrets).
Not provided: TLS, multi-user, rate limiting. For HTTPS + tailnet-identity auth in front, tailscale serve works well.
Same core as claude-deck (see its README for the full rationale), parameterized per slot:
- tmux socket
claudefleet, sessionss1..s16, each created lazily in a directory chosen via the picker (recents are persisted) pipe-paneper session →streams/sN.raw; the Bun server tails all active streams (100ms poll) and broadcasts per-slot overws://…/ws/<N>- Reconnect/slot-switch replays the last 2 MB of that slot's stream
- Per-slot input promise chains and per-slot tmux paste buffers (
fleetbufN) — one hung session can't stall input to the others, and concurrent sends can't race fleet.jsonpersists slot→cwd/label/sessionId + recents + shares + token (writes serialized). Self-heal (2s loop) recreates any activated slot whose pane died — and because each pane's claude session id is pinned at creation (claude --session-id), recreation runsclaude --resume <id>when the transcript still exists: the conversation survives the crash. Kill via the UI ✕ removes the slot from state first, so killed slots stay dead. Server restart re-adopts slots fromfleet.jsonplus any straysNtmux sessions.- The pinned session id also gives the 💬 conversation view a deterministic transcript path (
~/.claude/projects/<cwd-slug>/<uuid>.jsonl, served incrementally via/api/slots/:id/transcript); adopted pre-pinning sessions fall back to newest-by-mtime.
Below 700px viewport width (or a coarse-pointer device in short landscape) the same page switches to a phone layout — one shared codebase, no separate build:
- App bar + drawer — ☰ opens the session list (same slots UI as the desktop sidebar); tapping a slot switches and closes it. Title shows the focused session, dot shows WS state. On touch there's no hover, so each row's ✎ rename and ✕ delete icons are pinned visible and finger-sized — you can rename or kill a session straight from the phone (✕ still guards with a confirm).
- Key row —
esc ⇥ ⇧⇥ ↑ ↓ ← → ⏎ ^Cbuttons send raw bytes over the WS, covering everything Claude Code's TUI needs (interrupt, mode cycle, menu navigation) that virtual keyboards lack. - Live typing (⌨) — the toggle left of the compose box opens a dedicated input that relays every keystroke straight to the focused pane's pty — characters plus Enter/Esc/Backspace/Tab/arrows. Uses a real visible field, not xterm's hidden textarea (unreliable on iOS: keyboard often won't open, autocorrect swallows input); a sweeper keeps the field empty and
beforeinput/compositionendhandling makes IME and dictation work. Tap ⌨ again to exit; leaving mobile width auto-disables it. - Compose — for longer prompts: Enter inserts a newline on mobile (messaging convention); ➤ sends. Inputs are 16px so iOS doesn't zoom on focus.
- Keyboard-safe layout — app height tracks
visualViewport(plusdvh/interactive-widget), so the compose/live bar rides above the on-screen keyboard; safe-area insets handled for notch/home-indicator. The terminal's own hidden textarea getsinputmode=noneso tapping the terminal never pops the keyboard; direct input is opt-in through the ⌨ live bar instead. The viewport handler debounces before refitting (~200ms) — the keyboard's predictive-text bar toggles the visual viewport height in a burst while typing, and refitting on every micro-wobble was triggering spurious/resizecalls mid-keystroke. - Scrollback — touch-drag scrolling is xterm.js's own gesture handler (
Viewport.handleTouchStart/handleTouchMove, not native browser scrolling —.xterm-viewportalready shipsoverflow-y: scroll); a native viewport-scroll listener keeps the ▼ jump-to-bottom pill in sync since xterm'sonScrolldoesn't fire for it. Known caveat: xterm gates that handler on!coreMouseService.areMouseEventsActive, so a subprocess that enables terminal mouse-reporting (a pager,lazygit, etc.) can silently turn touch-drags into mouse clicks instead of scroll until it's disabled again. Cross-width scrollback reflow (connecting at a different width than the pane's current one) is handled by the server's width-reseed path — see "Scrollback fidelity across widths" below for what it fixes and its one remaining gap. - Canvas renderer — xterm without a rendering addon falls back to its DOM renderer, which paints every visible cell as a real DOM node; a busy Claude session streaming output while the terminal is being scrolled means frequent DOM churn competing with the scroll gesture for the main thread, especially on mobile Safari.
@xterm/addon-canvaspaints to a<canvas>instead, which is xterm's own recommended upgrade path for this. Loaded on both desktop and mobile panes. - No disruptive resize jiggle on no-op resizes —
/resizeforces the tmux pane through a shrink-then-grow redraw (repaint()) so stubborn TUIs reflow; without a same-size guard, this fired on every/resizecall including ones the client sends for a size it already reported, blanking the bottom (active-input) row for ~200ms while typing. The server now skips the jiggle when the requested size matches the pane's current size. - Installable — web manifest + icons; "Add to Home Screen" gives a standalone full-screen app. Terminal font drops to 11px (~50 cols portrait). Resizes still follow last-writer-wins across clients, same as two desktop windows.
- xterm.js stdin is enabled: click the terminal and type; keystrokes relay raw over the WS (chunked ≤1000 B). ⌘C/⌘V work natively.
- ⌃1–⌃0 switches slots (⌘+digit is reserved by macOS browsers for tab switching — don't "fix" this back to ⌘).
- Compose box for long prompts: Enter sends (bracketed paste + Enter server-side), ⇧Enter = newline. Command-prefix chips are opt-in via
FLEET_CHIPS=/cmd1,/cmd2(default: hidden).
Two general-purpose Claude Code slash commands ship in commands/ as a working chips demo (canonical home: jp290/sharpen):
/sharpen— prompt compiler: reshapes a rough prompt into the right context plus only the discipline the task needs; executes it only on clear "do this" intent/gosharp— executor: does the work under sharpened discipline (visible restatement of intent, free self-checks, argue-against-your-own-conclusion before finalizing)
Install them user-wide so every fleet session can invoke them, then surface them as chips:
cp commands/*.md ~/.claude/commands/
FLEET_CHIPS='/sharpen,/gosharp' bun server.ts- Slot-row actions (hover): ⤴ share (password-gated guest link, view or interact mode — see SHARING.md), ⇩ export (full scrollback as a printable light page;
?format=txtfor raw), ✎ rename, ✕ kill. - 🕘 prompt history next to the compose box: composed sends are recorded per slot server-side (last 100, dies with the session); the popover copies or re-inserts old prompts, ArrowUp in an empty compose box cycles them.
- global prompt log — independent of the per-slot history, every composed send (owner, share guest, scheduled auto) is appended to
streams/prompts.jsonl({ts, slot, cwd, label, source, text}, mode 600). Never capped, never rotated, survives slot close and server restarts — raw material for prompt analysis/distillation. - directory picker — opens at the last-browsed directory; a filter box narrows the folder list as you type (first match pre-selected), ↑/↓ move the selection, Enter descends into it, ⌘/Ctrl+Enter starts the session there.
- 💬 conversation view per pane: renders the claude transcript as structured messages — your prompts as timestamped anchors (↑/↓ jump between them), everything the agent did in between collapsed to one expandable "⚙ n steps" line. Reflows at any width; the terminal stays the surface for interaction (permission prompts live only there).
- Rename a session via the ✎ icon or double-clicking its name (Enter saves, Esc cancels, blank resets to the folder name). Labels persist in
fleet.jsonand die with the session. The ✎/✕ icons overlay the row's right edge on hover only, so labels get the full sidebar width the rest of the time. - Collapse toggle (‹/›) in the header shrinks the sidebar to a ~50px rail (slot numbers + activity dots) to hand the width to the terminals; click again to restore. State persists in localStorage; it's a desktop-only affordance (on mobile the sidebar is the ☰ drawer).
- Sidebar polls
/api/sessionsevery 2s; green dot = output within 5s (timestamps compared against servernowto dodge clock skew). DOM only re-renders on actual change. - Last-viewed slot and sidebar-collapsed state are restored from localStorage on reload.
The recommended setup is the launchd watchdog — survives crashes and reboots:
# watchdog.sh keeps the `srv` tmux session alive; edit its env line, then:
cp launchd-example.plist ~/Library/LaunchAgents/com.claude-fleet.watchdog.plist # adjust paths
launchctl load ~/Library/LaunchAgents/com.claude-fleet.watchdog.plistManual, without the watchdog:
tmux -L claudefleet new-session -d -s srv 'cd ~/claude-fleet && FLEET_HOST=<ip> exec bun server.ts >> server.log 2>&1' # start
tmux -L claudefleet kill-session -t srv # stop (claude sessions survive)
bun run build # rebuild client bundles after editing src/
./e2e-isolated.sh # full e2e against a throwaway instance (own tmux socket/port — never touches the live fleet)
./e2e-claude-gate.sh # separate: exercises the claude-alive gate against a real compiled stand-in binary (needs a C compiler; own socket/port)Env: FLEET_HOST (default 127.0.0.1), FLEET_PORT (8790), FLEET_SOCK (tmux socket, default claudefleet), FLEET_TOKEN, FLEET_ALLOWED_HOSTS, FLEET_CMD, FLEET_CHIPS, and for sharing FLEET_SHARE_HOSTS + FLEET_SHARE_URL (see SHARING.md).
Inherited from claude-deck (6.x removed the overflow-scroll viewport). Desktop probably tolerates 6.x, but don't upgrade without testing scroll + stdin.
streams/*.rawgrow unbounded (~KB/interaction; killing a slot deletes its stream)- One terminal size per session, last resize wins — fine for a single client; a second browser window fights over size
- Scrollback fidelity across widths — when a connecting client's width differs from the pane's, the server resizes the tmux window and re-seeds from a fresh plain-text
capture-pane(tmux reflows history on resize, so this replays correctly-wrapped text instead of the raw stream's stale wrapping).capture-pane's text output separates rows with a bare LF, never a CR; xterm.js doesn't treat LF alone as "return to column 0", so any captured line shorter than the pane's width used to leave the cursor short and stagger everything after it — this, not any inherent tmux/rendering limitation, was the cause of the severe garbling (including in content drawn with absolute cursor addressing, like Claude Code's own onboarding banner, which reflows fine once every row is properly CRLF-terminated). Bothcapture-panecall sites now normalize to CRLF before anything reaches a terminal./resizealso force-repaints so the live screen redraws immediately instead of waiting on the app's own SIGWINCH handling. One real gap remains: it's still one shared pty width — a second connecting client (or the same client at a new width) resizes the pane for everyone, so simultaneous phone+desktop viewers of the same slot still fight over live width. A true per-client fix would need a per-client VT-emulated render, not attempted here. - Kill ✕ is destructive (session + scrollback gone) behind a
confirm()only - Single shared token, no TLS, no rate limiting — the private network is part of the trust boundary
