Architecture
Comment le navigateur, le serveur Node.js, tmux et la CLI Claude s'emboîtent.
purplemux est constitué de trois couches cousues ensemble : un front-end navigateur, un serveur Node.js sur :8022, et tmux + la CLI Claude sur l'hôte. Tout entre eux est soit un WebSocket binaire, soit un petit POST HTTP.
Les trois couches
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
Chaque WebSocket a un seul rôle ; ils ne sont pas multiplexés. L'authentification est un cookie JWT NextAuth vérifié pendant l'upgrade WS.
Navigateur
Le front-end est une app Next.js (Pages Router). Les pièces qui parlent au serveur :
| Composant | Lib | Rôle |
|---|---|---|
| Volet terminal | xterm.js |
Rend les octets de /api/terminal. Émet frappes, événements de redimensionnement, changements de titre (onTitleChange). |
| Timeline de session | React + useTimeline |
Rend les tours Claude depuis /api/timeline. Pas de dérivation cliState — c'est tout côté serveur. |
| Indicateurs de statut | Zustand useTabStore |
Badges d'onglets, points de barre latérale, compteurs de notifications pilotés par les messages /api/status. |
| Sync multi-appareils | useSyncClient |
Surveille les éditions d'espace / mise en page faites sur un autre appareil via /api/sync. |
Les titres d'onglets et le processus au premier plan viennent de l'événement onTitleChange de xterm.js — tmux est configuré (src/config/tmux.conf) pour émettre #{pane_current_command}|#{pane_current_path} toutes les deux secondes, et lib/tab-title.ts le parse.
Serveur Node.js
server.ts est un serveur HTTP custom qui héberge Next.js plus quatre instances WebSocketServer ws sur le même port.
Endpoints WebSocket
| Chemin | Handler | Direction | Utilisation |
|---|---|---|---|
/api/terminal |
terminal-server.ts |
bidirectionnel, binaire | E/S terminal via node-pty rattaché à une session tmux |
/api/timeline |
timeline-server.ts |
serveur → client | Stream des entrées de session Claude parsées depuis JSONL |
/api/status |
status-server.ts |
bidirectionnel, JSON | status:sync / status:update / status:hook-event du serveur, status:tab-dismissed / status:ack-notification / status:request-sync du client |
/api/sync |
sync-server.ts |
bidirectionnel, JSON | État d'espace de travail cross-appareil |
Plus /api/install pour l'installeur de premier lancement (pas d'auth requise).
Protocole binaire terminal
/api/terminal utilise un petit protocole binaire défini dans src/lib/terminal-protocol.ts :
| Code | Nom | Direction | Payload |
|---|---|---|---|
0x00 |
MSG_STDIN |
client → serveur | Octets de touches |
0x01 |
MSG_STDOUT |
serveur → client | Sortie terminal |
0x02 |
MSG_RESIZE |
client → serveur | cols: u16, rows: u16 |
0x03 |
MSG_HEARTBEAT |
les deux | intervalle 30 s, timeout 90 s |
0x04 |
MSG_KILL_SESSION |
client → serveur | Termine la session tmux sous-jacente |
0x05 |
MSG_WEB_STDIN |
client → serveur | Texte de barre de saisie web (livré après sortie copy-mode) |
Backpressure : pty.pause quand WS bufferedAmount > 1 Mo, reprise sous 256 Ko. Au plus 32 connexions concurrentes par serveur, la plus ancienne est larguée au-delà.
Status manager
src/lib/status-manager.ts est la source unique de vérité pour cliState. Les événements de hook arrivent via /api/status/hook (POST authentifié par token), sont séquencés (eventSeq par onglet), et sont réduits en idle / busy / needs-input / ready-for-review / unknown par deriveStateFromEvent. Le watcher JSONL ne met à jour que les métadonnées sauf pour un événement interrupt synthétique.
Pour la machine d'état complète voir Statut de session (STATUS.md).
Couche tmux
purplemux fait tourner un tmux isolé sur un socket dédié — -L purple — avec sa propre config dans src/config/tmux.conf. Votre ~/.tmux.conf n'est jamais lu.
Les sessions sont nommées pt-{workspaceId}-{paneId}-{tabId}. Un volet terminal dans le navigateur correspond à une session tmux, rattachée via node-pty.
tmux socket: purple
├── pt-ws-MMKl07-pa-1-tb-1 ← onglet navigateur 1
├── pt-ws-MMKl07-pa-1-tb-2 ← onglet navigateur 2
└── pt-ws-MMKl07-pa-2-tb-1 ← volet divisé, onglet 1
prefix est désactivé, la barre de status est off (xterm.js dessine le chrome), set-titles est on, et mouse on met la molette en copy-mode. tmux est la raison pour laquelle les sessions survivent à un navigateur fermé, une coupure Wi-Fi ou un redémarrage du serveur.
Pour la config tmux complète, le wrapper de commandes et les détails de détection de processus, voir tmux & détection de processus (TMUX.md).
Intégration de la CLI Claude
purplemux ne fork ni n'enveloppe Claude — le binaire claude est juste celui que vous avez installé. Deux choses sont ajoutées :
- Réglages de hook — Au démarrage,
ensureHookSettings()écrit~/.purplemux/hooks.json,status-hook.shetstatusline.sh. Chaque onglet Claude se lance avec--settings ~/.purplemux/hooks.json, doncSessionStart,UserPromptSubmit,Notification,Stop,PreCompact,PostCompactfont tous un POST de retour vers le serveur. - Lectures JSONL —
~/.claude/projects/**/*.jsonlest parsé partimeline-server.tspour la vue de conversation en direct, et surveillé parsession-detection.tspour détecter un processus Claude en cours via les fichiers PID dans~/.claude/sessions/.
Les scripts hook lisent ~/.purplemux/port et ~/.purplemux/cli-token et POST avec x-pmux-token. Ils échouent silencieusement si le serveur est down, donc fermer purplemux pendant que Claude tourne ne crashe rien.
Séquence de démarrage
server.ts:start() exécute ces étapes dans l'ordre :
acquireLock(port)— garde-instance unique via~/.purplemux/pmux.lockinitConfigStore()+initShellPath()(résout lePATHdu shell de login utilisateur)initAuthCredentials()— charge le mot de passe haché en scrypt et le secret HMAC dans l'envscanSessions()+applyConfig()— nettoie les sessions tmux mortes, appliquetmux.confinitWorkspaceStore()— chargeworkspaces.jsonet leslayout.jsonpar espaceautoResumeOnStartup()— relance les shells dans les répertoires sauvegardés, tente une reprise ClaudegetStatusManager().init()— démarre le polling de métadonnéesapp.prepare()(Next.js dev) ourequire('.next/standalone/server.js')(prod)listenWithFallback()surbindPlan.host:port(0.0.0.0ou127.0.0.1selon politique d'accès)ensureHookSettings(result.port)— écrit ou rafraîchit les scripts hook avec le port réelgetCliToken()— lit ou génère~/.purplemux/cli-tokenwriteAllClaudePromptFiles()— rafraîchit leclaude-prompt.mdde chaque espace
La fenêtre entre la résolution du port et l'étape 10 est la raison pour laquelle les scripts hook sont régénérés à chaque démarrage : ils ont besoin du port réel inséré.
Serveur custom vs. graphe de modules Next.js
Pour lire plus
docs/TMUX.md— config tmux, wrapper de commandes, parcours d'arbre de processus, protocole binaire terminal.docs/STATUS.md— machine d'état CLI Claude, flux des hooks, événement interrupt synthétique, watcher JSONL.docs/DATA-DIR.md— chaque fichier que purplemux écrit.
Pour aller plus loin
- Répertoire de données — chaque fichier que l'architecture ci-dessus touche.
- Référence CLI — parler au serveur depuis l'extérieur du navigateur.
- Dépannage — diagnostic quand quelque chose ici dérape.