Architecture
How the browser, the Node.js server, tmux, and the Claude CLI fit together.
purplemux is three layers stitched together: a browser front-end, a Node.js server on :8022, and tmux + the Claude CLI on the host. Everything between them is either a binary WebSocket or a small HTTP POST.
The three layers
Browser Node.js server (:8022) Host
───────── ──────────────────────── ──────────────
xterm.js ◀──ws /api/terminal──▶ terminal-server.ts ──node-pty──▶ tmux (purple socket)
Timeline ◀──ws /api/timeline──▶ timeline-server.ts │
Status ◀──ws /api/status────▶ status-server.ts └─▶ shell ─▶ claude
Sync ◀──ws /api/sync──────▶ sync-server.ts
status-manager.ts ◀──POST /api/status/hook── status-hook.sh
rate-limits-watcher.ts ◀──POST /api/status/statusline── statusline.sh
JSONL watcher ──reads── ~/.claude/projects/**/*.jsonl
Each WebSocket has a single purpose; they don't multiplex. Authentication is a NextAuth JWT cookie verified during the WS upgrade.
Browser
The front-end is a Next.js (Pages Router) app. The pieces that talk to the server:
| Component | Library | Purpose |
|---|---|---|
| Terminal pane | xterm.js |
Renders bytes from /api/terminal. Emits keystrokes, resize events, title changes (onTitleChange). |
| Session timeline | React + useTimeline |
Renders Claude turns from /api/timeline. No cliState derivation — that's all server-side. |
| Status indicators | Zustand useTabStore |
Tab badges, sidebar dots, notification counts driven by /api/status messages. |
| Multi-device sync | useSyncClient |
Watches workspace / layout edits made on another device via /api/sync. |
Tab titles and the foreground process come from xterm.js's onTitleChange event — tmux is configured (src/config/tmux.conf) to emit #{pane_current_command}|#{pane_current_path} every two seconds, and lib/tab-title.ts parses it.
Node.js server
server.ts is a custom HTTP server that hosts Next.js plus four ws WebSocketServer instances on the same port.
WebSocket endpoints
| Path | Handler | Direction | Use |
|---|---|---|---|
/api/terminal |
terminal-server.ts |
bidirectional, binary | Terminal I/O via node-pty attached to a tmux session |
/api/timeline |
timeline-server.ts |
server → client | Streams Claude session entries parsed from JSONL |
/api/status |
status-server.ts |
bidirectional, JSON | status:sync / status:update / status:hook-event from server, status:tab-dismissed / status:ack-notification / status:request-sync from client |
/api/sync |
sync-server.ts |
bidirectional, JSON | Cross-device workspace state |
Plus /api/install for the first-run installer (no auth required).
Terminal binary protocol
/api/terminal uses a tiny binary protocol defined in src/lib/terminal-protocol.ts:
| Code | Name | Direction | Payload |
|---|---|---|---|
0x00 |
MSG_STDIN |
client → server | Key bytes |
0x01 |
MSG_STDOUT |
server → client | Terminal output |
0x02 |
MSG_RESIZE |
client → server | cols: u16, rows: u16 |
0x03 |
MSG_HEARTBEAT |
both | 30 s interval, 90 s timeout |
0x04 |
MSG_KILL_SESSION |
client → server | End the underlying tmux session |
0x05 |
MSG_WEB_STDIN |
client → server | Web input bar text (delivered after copy-mode exit) |
Backpressure: pty.pause when WS bufferedAmount > 1 MB, resume below 256 KB. At most 32 concurrent connections per server, with the oldest dropped beyond that.
Status manager
src/lib/status-manager.ts is the single source of truth for cliState. Hook events flow through /api/status/hook (token-authenticated POST), get sequenced (eventSeq per tab), and are reduced into idle / busy / needs-input / ready-for-review / unknown by deriveStateFromEvent. The JSONL watcher updates only metadata except for one synthetic interrupt event.
For the full state machine see Session status (STATUS.md).
tmux layer
purplemux runs an isolated tmux on a dedicated socket — -L purple — using its own config at src/config/tmux.conf. Your ~/.tmux.conf is never read.
Sessions are named pt-{workspaceId}-{paneId}-{tabId}. One terminal pane in the browser maps to one tmux session, attached via node-pty.
tmux socket: purple
├── pt-ws-MMKl07-pa-1-tb-1 ← browser tab 1
├── pt-ws-MMKl07-pa-1-tb-2 ← browser tab 2
└── pt-ws-MMKl07-pa-2-tb-1 ← split pane, tab 1
prefix is disabled, the status bar is off (xterm.js draws the chrome), set-titles is on, and mouse on puts the wheel into copy-mode. tmux is the reason sessions survive a closed browser, a Wi-Fi drop, or a server restart.
For the full tmux setup, command wrapper, and process detection details see tmux & process detection (TMUX.md).
Claude CLI integration
purplemux doesn't fork or wrap Claude — the claude binary is just whatever you have installed. Two things get added:
- Hook settings — At startup,
ensureHookSettings()writes~/.purplemux/hooks.json,status-hook.sh, andstatusline.sh. Every Claude tab launches with--settings ~/.purplemux/hooks.json, soSessionStart,UserPromptSubmit,Notification,Stop,PreCompact,PostCompactall POST back to the server. - JSONL reads —
~/.claude/projects/**/*.jsonlis parsed bytimeline-server.tsfor the live conversation view, and watched bysession-detection.tsto detect a running Claude process via the PID files at~/.claude/sessions/.
Hook scripts read ~/.purplemux/port and ~/.purplemux/cli-token and POST with x-pmux-token. They fail silently if the server is down, so closing purplemux while Claude is running doesn't crash anything.
Startup sequence
server.ts:start() runs through these in order:
acquireLock(port)— single-instance guard via~/.purplemux/pmux.lockinitConfigStore()+initShellPath()(resolves the user's login shellPATH)initAuthCredentials()— load scrypt-hashed password and HMAC secret into envscanSessions()+applyConfig()— clean up dead tmux sessions, applytmux.confinitWorkspaceStore()— loadworkspaces.jsonand per-workspacelayout.jsonautoResumeOnStartup()— relaunch shells in saved directories, attempt Claude resumegetStatusManager().init()— start the metadata pollapp.prepare()(Next.js dev) orrequire('.next/standalone/server.js')(prod)listenWithFallback()onbindPlan.host:port(0.0.0.0or127.0.0.1based on access policy)ensureHookSettings(result.port)— write or refresh hook scripts with the actual portgetCliToken()— read or generate~/.purplemux/cli-tokenwriteAllClaudePromptFiles()— refresh each workspace'sclaude-prompt.md
The window between port resolution and step 10 is why hook scripts are regenerated on every startup: they need the live port baked in.
Custom server vs. Next.js module graph
Where to read more
docs/TMUX.md— tmux config, command wrapper, process tree walking, terminal binary protocol.docs/STATUS.md— Claude CLI state machine, hook flow, synthetic interrupt event, JSONL watcher.docs/DATA-DIR.md— every file purplemux writes.
What's next
- Data directory — every file the architecture above touches.
- CLI reference — talking to the server from outside the browser.
- Troubleshooting — diagnosing it when something here misbehaves.