Architektur
Wie Browser, Node.js-Server, tmux und die Claude-CLI zusammenpassen.
purplemux sind drei zusammengenähte Schichten: ein Browser-Frontend, ein Node.js-Server auf :8022 und tmux + die Claude-CLI auf dem Host. Alles dazwischen ist entweder ein binärer WebSocket oder ein kleiner HTTP-POST.
Die drei Schichten
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 ──liest── ~/.claude/projects/**/*.jsonl
Jeder WebSocket hat einen einzigen Zweck; sie multiplexen nicht. Authentifizierung ist ein NextAuth-JWT-Cookie, der beim WS-Upgrade verifiziert wird.
Browser
Das Frontend ist eine Next.js-(Pages-Router-)App. Die Teile, die mit dem Server reden:
| Komponente | Bibliothek | Zweck |
|---|---|---|
| Terminal-Panel | xterm.js |
Rendert Bytes von /api/terminal. Emittiert Tastendrücke, Resize-Events, Title-Changes (onTitleChange). |
| Session-Timeline | React + useTimeline |
Rendert Claude-Turns von /api/timeline. Keine cliState-Ableitung — das ist alles server-seitig. |
| Status-Indikatoren | Zustand useTabStore |
Tab-Badges, Sidebar-Punkte, Notification-Counts, getrieben durch /api/status-Nachrichten. |
| Multi-Device-Sync | useSyncClient |
Beobachtet Workspace-/Layout-Edits von einem anderen Gerät via /api/sync. |
Tab-Titel und der Vordergrundprozess kommen aus xterm.js' onTitleChange-Event — tmux ist (src/config/tmux.conf) so konfiguriert, dass es alle zwei Sekunden #{pane_current_command}|#{pane_current_path} emittiert, und lib/tab-title.ts parst das.
Node.js-Server
server.ts ist ein Custom-HTTP-Server, der Next.js plus vier ws-WebSocketServer-Instanzen auf demselben Port hostet.
WebSocket-Endpunkte
| Pfad | Handler | Richtung | Zweck |
|---|---|---|---|
/api/terminal |
terminal-server.ts |
bidirektional, binär | Terminal-I/O via node-pty, an eine tmux-Session gebunden |
/api/timeline |
timeline-server.ts |
Server → Client | Streamt Claude-Session-Einträge, geparst aus JSONL |
/api/status |
status-server.ts |
bidirektional, JSON | status:sync / status:update / status:hook-event vom Server, status:tab-dismissed / status:ack-notification / status:request-sync vom Client |
/api/sync |
sync-server.ts |
bidirektional, JSON | Cross-Device-Workspace-State |
Plus /api/install für den First-Run-Installer (keine Auth nötig).
Terminal-Binärprotokoll
/api/terminal nutzt ein winziges Binärprotokoll, definiert in src/lib/terminal-protocol.ts:
| Code | Name | Richtung | 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 |
beide | 30 s Intervall, 90 s Timeout |
0x04 |
MSG_KILL_SESSION |
Client → Server | Beendet die zugrundeliegende tmux-Session |
0x05 |
MSG_WEB_STDIN |
Client → Server | Web-Eingabeleisten-Text (geliefert nach Copy-Mode-Ausstieg) |
Backpressure: pty.pause wenn WS bufferedAmount > 1 MB, Resume unter 256 KB. Maximal 32 gleichzeitige Verbindungen pro Server, älteste werden darüber hinaus verworfen.
Status-Manager
src/lib/status-manager.ts ist die einzige Quelle der Wahrheit für cliState. Hook-Events fließen durch /api/status/hook (token-authentifiziertes POST), werden sequenziert (eventSeq pro Tab) und durch deriveStateFromEvent zu idle / busy / needs-input / ready-for-review / unknown reduziert. Der JSONL-Watcher aktualisiert nur Metadaten, mit Ausnahme eines synthetischen interrupt-Events.
Für die vollständige State-Machine siehe Session-Status (STATUS.md).
tmux-Schicht
purplemux betreibt ein isoliertes tmux auf einem dedizierten Socket — -L purple — mit eigener Config in src/config/tmux.conf. Deine ~/.tmux.conf wird nie gelesen.
Sessions heißen pt-{workspaceId}-{paneId}-{tabId}. Ein Terminal-Panel im Browser entspricht einer tmux-Session, angebunden 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-Panel, Tab 1
prefix ist deaktiviert, die Status-Bar ist aus (xterm.js zeichnet das Chrome), set-titles ist an, und mouse on legt das Mausrad in den Copy-Modus. tmux ist der Grund, warum Sessions einen geschlossenen Browser, einen WLAN-Drop oder einen Server-Restart überleben.
Für das vollständige tmux-Setup, den Command-Wrapper und die Prozess-Detection siehe tmux & Prozess-Detection (TMUX.md).
Claude-CLI-Integration
purplemux forkt oder wrappt Claude nicht — das claude-Binary ist genau das, was du installiert hast. Zwei Dinge werden ergänzt:
- Hook-Settings — beim Startup schreibt
ensureHookSettings()~/.purplemux/hooks.json,status-hook.shundstatusline.sh. Jeder Claude-Tab startet mit--settings ~/.purplemux/hooks.json, sodassSessionStart,UserPromptSubmit,Notification,Stop,PreCompact,PostCompactalle an den Server zurück-POSTen. - JSONL-Reads —
~/.claude/projects/**/*.jsonlwird vontimeline-server.tsfür die Live-Konversations-Ansicht geparst und vonsession-detection.tsbeobachtet, um einen laufenden Claude-Prozess über die PID-Dateien unter~/.claude/sessions/zu erkennen.
Hook-Skripte lesen ~/.purplemux/port und ~/.purplemux/cli-token und POSTen mit x-pmux-token. Sie schlagen still fehl, wenn der Server down ist, sodass das Schließen von purplemux während Claude läuft nichts crasht.
Startup-Sequenz
server.ts:start() läuft diese in Reihenfolge durch:
acquireLock(port)— Single-Instance-Guard via~/.purplemux/pmux.lockinitConfigStore()+initShellPath()(löst den Login-Shell-PATHdes Users auf)initAuthCredentials()— lädt scrypt-gehashtes Passwort und HMAC-Secret in die EnvscanSessions()+applyConfig()— räumt tote tmux-Sessions auf, wendettmux.confaninitWorkspaceStore()— lädtworkspaces.jsonund pro-Workspace-layout.jsonautoResumeOnStartup()— startet Shells in gespeicherten Verzeichnissen neu, versucht Claude-ResumegetStatusManager().init()— startet das Metadaten-Pollingapp.prepare()(Next.js dev) oderrequire('.next/standalone/server.js')(prod)listenWithFallback()aufbindPlan.host:port(0.0.0.0oder127.0.0.1je nach Access-Policy)ensureHookSettings(result.port)— schreibt oder aktualisiert Hook-Skripte mit dem tatsächlichen PortgetCliToken()— liest oder generiert~/.purplemux/cli-tokenwriteAllClaudePromptFiles()— refreshet jedes Workspace-claude-prompt.md
Das Fenster zwischen Port-Auflösung und Schritt 10 ist der Grund, warum Hook-Skripte bei jedem Start regeneriert werden: Sie brauchen den Live-Port eingebrannt.
Custom-Server vs. Next.js-Modul-Graph
Wo du mehr lesen kannst
docs/TMUX.md— tmux-Config, Command-Wrapper, Process-Tree-Walking, Terminal-Binärprotokoll.docs/STATUS.md— Claude-CLI-State-Machine, Hook-Flow, synthetisches Interrupt-Event, JSONL-Watcher.docs/DATA-DIR.md— jede Datei, die purplemux schreibt.
Wie es weitergeht
- Daten-Verzeichnis — jede Datei, die die obige Architektur berührt.
- CLI-Referenz — von außerhalb des Browsers mit dem Server reden.
- Troubleshooting — diagnostizieren, wenn etwas hier daneben läuft.