// Protocole WebSocket Arboretum : une connexion multiplexée par client. // Messages de contrôle : frames TEXTE JSON. Sortie terminal : frames BINAIRES // (un chunk PTY peut couper un caractère UTF-8 en frontière de frame ; le // décodage incombe à xterm.write(Uint8Array) côté client, jamais au transport). export const PROTOCOL_VERSION = 1; // ---- Frames binaires ---- // Layout : [type u8][channel u32le][payload...] export const BINARY_FRAME = { HEADER_BYTES: 5, /** serveur → client : sortie terminal */ OUTPUT: 0x01, /** serveur → client : resync, le client doit reset son terminal avant d'écrire le payload */ RESYNC: 0x02, } as const; export function encodeBinaryFrame(type: number, channel: number, payload: Uint8Array): Uint8Array { const frame = new Uint8Array(BINARY_FRAME.HEADER_BYTES + payload.byteLength); const view = new DataView(frame.buffer); view.setUint8(0, type); view.setUint32(1, channel, true); frame.set(payload, BINARY_FRAME.HEADER_BYTES); return frame; } export function decodeBinaryFrame(data: Uint8Array): { type: number; channel: number; payload: Uint8Array } { const view = new DataView(data.buffer, data.byteOffset, data.byteLength); return { type: view.getUint8(0), channel: view.getUint32(1, true), payload: data.subarray(BINARY_FRAME.HEADER_BYTES), }; } // ---- Flow control (pattern officiel xterm.js, watermarks du design) ---- // INVARIANT anti-deadlock : ACK_EVERY_BYTES <= LOW_WATERMARK. Le client n'ACK // qu'à réception de données ; si le serveur pouvait se mettre en pause avec un // reliquat non-ACKé > LOW mais < pas d'ACK, plus aucun des deux ne progresserait. // Avec ACK_EVERY <= LOW, une fois tout le flux en vol traité, le reliquat est // < LOW et le serveur reprend toujours. (Complété côté client par un ACK // traînant débouncé.) export const FLOW = { /** le client ACK tous les N octets réellement traités par xterm.write */ ACK_EVERY_BYTES: 64 * 1024, /** pause du PTY quand TOUS les clients interactifs dépassent HIGH */ HIGH_WATERMARK: 384 * 1024, /** reprise quand le min repasse sous LOW */ LOW_WATERMARK: 128 * 1024, /** au-delà : client marqué lagging, flux coupé, resync au rattrapage */ LAGGING_BYTES: 2 * 1024 * 1024, } as const; /** * Replay à l'attach : reset terminal + queue du ring (l'écran TUI se repeint en continu). * 1 Mo (≈ 10-15k lignes) pour permettre de remonter une vraie conversation Claude dans le terminal ; * reste < LAGGING_BYTES (pas de faux lagging) et bien dans RING_CAPACITY. */ export const REPLAY_TAIL_BYTES = 1024 * 1024; // ---- États de session (sous-ensemble P1 ; étendu en P3) ---- export type SessionRuntimeStatus = | 'starting' | 'running' // P1 : pas encore de distinction busy/waiting/idle (P3 via claude-adapter) | 'exited'; /** Origine d'une session listée par Arboretum. */ export type SessionSource = | 'managed' // PTY lancé par Arboretum (vit dans le PtyManager) | 'discovered'; // session Claude externe lue sur disque (~/.claude) /** Statut brut tel qu'écrit par le CLI dans ~/.claude/sessions (interprété finement en P3). */ export type SessionRegistryStatus = 'busy' | 'idle' | 'waiting'; // ---- États fins de session (P3-B, claude-adapter) ---- /** busy = Claude traite ; waiting = bloqué sur un dialogue ; idle = prêt pour une instruction. */ export type SessionActivity = 'busy' | 'waiting' | 'idle'; export type DialogKind = 'trust' | 'permission' | 'question' | 'plan'; export interface DialogOption { n: number; label: string; selected: boolean; } export interface SessionDialog { kind: DialogKind; waitingFor: string | null; options: DialogOption[]; } export interface SessionSummary { id: string; cwd: string; command: string; title: string | null; status: SessionRuntimeStatus; live: boolean; createdAt: string; endedAt: string | null; exitCode: number | null; clients: number; // ---- P2 : découverte & reprise (additif) ---- /** 'managed' = PtyManager ; 'discovered' = lue sur disque. */ source: SessionSource; /** ID interne du CLI claude (clé de corrélation managé↔découvert) ; null pour bash ou claude pas encore prêt. */ claudeSessionId: string | null; /** pid du process claude (découvertes vivantes & managées vivantes) ; null sinon. */ pid: number | null; /** true si un `--resume` direct est sûr (session morte) ; false pour une vivante (corruption). */ resumable: boolean; /** true si Arboretum tient le PTY (managée vivante) ; jamais pour une découverte. */ attachable: boolean; /** statut brut du registre ~/.claude/sessions (P2) ; interprété finement en P3 (claude-adapter). */ registryStatus: SessionRegistryStatus | null; // ---- P3-B : états fins (optionnels, remplis par le claude-adapter) ---- /** absent/null = inconnu (bash, démarrage, historique). */ activity?: SessionActivity | null; waitingFor?: string | null; /** dialogue typé en cours (présent quand activity === 'waiting'). */ dialog?: SessionDialog | null; // ---- P6 : session de groupe multi-repo (additif) ---- /** répertoires supplémentaires couverts via `--add-dir` ; absent/[] pour une session mono-repo. */ addedDirs?: string[]; /** groupe propriétaire d'une session de groupe (couvre plusieurs repos) ; null/absent sinon. */ groupId?: string | null; // ---- Lancement de projet multi-terminaux (additif) ---- /** identifiant partagé par tous les terminaux d'un même « Démarrer le projet » ; null/absent sinon. */ launchRunId?: string | null; // ---- Masquage (additif) ---- /** true = session découverte masquée par l'utilisateur (exclue de la liste sauf includeHidden). */ hidden?: boolean; // ---- Archivage auto (P10, additif) ---- /** true = session managée auto-archivée par ancienneté (exclue de la liste sauf includeArchived). */ archived?: boolean; } // ---- Worktrees & repos (P3) ---- /** Hook lancé après création d'un worktree (commande shell exécutée dans le nouveau worktree). */ export interface PostCreateHook { id: string; label: string; run: string; enabled: boolean; } /** * Commande de démarrage d'un projet (« Démarrer le projet ») : une commande shell longue durée * lancée dans un terminal PTY managé (serveur front, back, base…). Plusieurs commandes activées * = plusieurs terminaux ouverts d'un seul geste. Persistée en JSON sur le repo. */ export interface LaunchCommand { id: string; /** libellé court affiché dans l'onglet du terminal (ex. « web », « api »). */ label: string; /** commande shell auto-tapée dans un shell de login interactif (ex. `npm run dev`). */ run: string; /** sous-répertoire relatif au worktree où exécuter la commande (borné, jamais hors du worktree). */ cwd?: string; enabled: boolean; } export interface RepoSummary { id: string; /** chemin absolu de la racine du repo (main worktree). */ path: string; label: string; defaultBranch: string | null; postCreateHooks: PostCreateHook[]; /** commandes de démarrage du projet (« Démarrer le projet ») ; [] si aucune définie. */ launchCommands: LaunchCommand[]; /** pré-écrire hasTrustDialogAccepted dans ~/.claude.json à la création d'un worktree. */ preTrust: boolean; createdAt: string; /** false si le chemin n'est plus un repo git accessible. */ valid: boolean; /** true = masqué du dashboard (conservé en DB → non ré-ajouté au re-scan). */ hidden: boolean; } export interface WorktreeGitStatus { ahead: number; behind: number; dirtyCount: number; upstream: string | null; // ---- Champs additifs P7 (optionnels, rétrocompat) : compteurs bon marché calculés dans le // même passage `status --porcelain=v2` que dirtyCount. La liste détaillée des changements // (FileChange[]) n'est JAMAIS diffusée ici : elle est servie à la demande (REST) et poussée // uniquement aux clients qui « regardent » le worktree (voir `worktree_changes`). /** fichiers avec des modifications indexées (staged). */ stagedCount?: number; /** fichiers avec des modifications non indexées (unstaged, untracked inclus). */ unstagedCount?: number; /** fichiers en conflit de merge. */ conflictCount?: number; /** hash court du dernier commit (HEAD), null si le dépôt n'a aucun commit. */ lastCommitHash?: string | null; /** sujet (première ligne) du dernier commit. */ lastCommitSubject?: string | null; } export interface WorktreeSummary { repoId: string; /** chemin absolu du worktree (clé de corrélation avec le cwd des sessions). */ path: string; branch: string | null; head: string; detached: boolean; locked: boolean; prunable: boolean; isMain: boolean; git: WorktreeGitStatus; /** sessions corrélées par cwd (managées + découvertes) ; leur `activity` est remplie en P3-B. */ sessions: SessionSummary[]; } // ---- Groupes de travail (P5) ---- // Un groupe regroupe plusieurs repos pour piloter des sessions Claude en simultané sur // plusieurs worktrees. Membership légère : seule la liste d'ids de repos est persistée ; // les repos/worktrees/sessions du groupe sont dérivés par filtrage sur `repoId` côté client. export interface GroupSummary { id: string; label: string; description: string | null; /** couleur d'accent UI (hex `#rrggbb`) ou null. */ color: string | null; /** ids de repos membres, ordonnés par leur position dans le groupe. */ repoIds: string[]; createdAt: string; updatedAt: string; } // ---- Réglages diffusables (P11) ---- // Sous-ensemble NON sensible des réglages, transporté tel quel par `settings_update` et par la // réponse REST GET /settings (source de vérité unique du type). JAMAIS de secret ici. export interface SettingsBroadcast { /** racines absolues scannées pour la découverte auto des repos (défaut : aucune → pas de scan). */ scanRoots: string[]; /** intervalle du re-scan périodique en minutes ; 0 = périodique désactivé. */ scanIntervalMin: number; /** chemin explicite du binaire `claude` ; null = auto-détection via PATH. */ claudeBinPath: string | null; /** override de la racine ~/.claude ; null = défaut. */ claudeHome: string | null; /** rétention des sessions terminées : auto-archivage après N jours ; 0 = jamais (P10). */ retentionDays: number; /** purge définitive après N jours ; 0 = désactivée. */ purgeDays: number; } // ---- Clone d'un dépôt distant (P12) ---- export type CloneState = 'pending' | 'running' | 'done' | 'error'; /** État d'une opération de clone (poussé en WS sous le topic 'clones' ET lu en REST). */ export interface CloneOperation { id: string; state: CloneState; /** progression 0-100 si git la communique, sinon null. */ progress: number | null; /** phase courante rapportée par git (« Receiving objects », « Resolving deltas »…). */ phase: string | null; error: string | null; /** id du repo enregistré une fois le clone terminé (null avant). */ repoId: string | null; /** dossier de destination du clone. */ dest: string; } // ---- Messages client → serveur ---- export type ClientMessage = | { type: 'hello'; protocol: number } | { type: 'attach'; sessionId: string; mode: 'interactive' | 'observer'; cols: number; rows: number } | { type: 'detach'; channel: number } | { type: 'stdin'; channel: number; data: string } // P4-A : répondre à un dialogue Claude sans clavier. Le serveur traduit l'intention // en keystrokes (chiffre+Entrée pour 'select', Entrée pour 'confirm', Esc pour 'deny') // et valide l'option contre le dialogue courant (anti-frappe fantôme mobile). | { type: 'answer'; channel: number; action: 'select' | 'confirm' | 'deny'; optionN?: number } | { type: 'resize'; channel: number; cols: number; rows: number } | { type: 'ack'; channel: number; bytes: number } | { type: 'sub'; topics: Array<'sessions' | 'worktrees' | 'groups' | 'settings' | 'clones'> } // P7 : abonnement ciblé au détail d'un worktree (diff/statut fin) : on ne pousse le détail // qu'aux connexions qui « regardent » ce worktree (clé = repoId + path), indépendamment du // topic global 'worktrees' (qui ne transporte que les compteurs légers). | { type: 'watch'; repoId: string; path: string } | { type: 'unwatch'; repoId: string; path: string } | { type: 'ping' }; // ---- Messages serveur → client ---- export type ServerMessage = | { type: 'hello_ok'; protocol: number; serverVersion: string } | { type: 'attached'; channel: number; sessionId: string; mode: 'interactive' | 'observer'; controlling: boolean } | { type: 'detached'; channel: number; reason: 'client' | 'session_exit' | 'replaced' } | { type: 'control_changed'; channel: number; controlling: boolean } | { type: 'session_update'; session: SessionSummary } | { type: 'session_exit'; sessionId: string; exitCode: number | null; signal: number | null } // P10 : une session managée vient d'être archivée (auto par ancienneté ou manuellement). Signal // léger : le client met à jour son row (badge archived) et le retire si « Show archived » est off. | { type: 'session_archived'; sessionId: string } // P11 : un réglage a changé (PATCH /settings) : on diffuse le snapshot non sensible aux abonnés // du topic 'settings' pour que tous les clients (et onglets) se synchronisent sans polling. | { type: 'settings_update'; settings: SettingsBroadcast } // P12 : progression/fin/erreur d'un clone, poussé aux abonnés du topic 'clones'. L'état complet // (state) discrimine progress/done/error en un seul message. | { type: 'clone_update'; operation: CloneOperation } | { type: 'repo_update'; repo: RepoSummary } | { type: 'repo_removed'; repoId: string } | { type: 'worktree_update'; repoId: string; worktree: WorktreeSummary } | { type: 'worktree_removed'; repoId: string; path: string } // P7 : signal léger « le détail (diff/fichiers modifiés) de ce worktree a changé, refais un // GET /changes ou /diff ». Envoyé UNIQUEMENT aux connexions ayant `watch`é cette clé. On ne // pousse pas le diff complet dans la frame (taille/flow control) : le client re-fetch en REST. | { type: 'worktree_changes'; repoId: string; path: string } | { type: 'group_update'; group: GroupSummary } | { type: 'group_removed'; groupId: string } | { type: 'error'; code: ErrorCode; message: string; channel?: number } | { type: 'pong' }; export type ErrorCode = | 'BAD_PROTOCOL' | 'BAD_MESSAGE' | 'NOT_FOUND' | 'NOT_ATTACHED' | 'NOT_CONTROLLING' | 'SESSION_EXITED' | 'INVALID_ANSWER' | 'INTERNAL'; export function parseClientMessage(raw: string): ClientMessage | null { let obj: unknown; try { obj = JSON.parse(raw); } catch { return null; } if (typeof obj !== 'object' || obj === null || typeof (obj as { type?: unknown }).type !== 'string') return null; const m = obj as Record; const isU32 = (v: unknown): v is number => typeof v === 'number' && Number.isSafeInteger(v) && v >= 0 && v <= 0xffffffff; const isCount = (v: unknown): v is number => typeof v === 'number' && Number.isSafeInteger(v) && v >= 0; const isDim = (v: unknown): v is number => typeof v === 'number' && Number.isSafeInteger(v) && v >= 2 && v <= 1000; switch (m.type) { case 'hello': return isCount(m.protocol) ? { type: 'hello', protocol: m.protocol } : null; case 'attach': return typeof m.sessionId === 'string' && (m.mode === 'interactive' || m.mode === 'observer') && isDim(m.cols) && isDim(m.rows) ? { type: 'attach', sessionId: m.sessionId, mode: m.mode, cols: m.cols, rows: m.rows } : null; case 'detach': return isU32(m.channel) ? { type: 'detach', channel: m.channel } : null; case 'stdin': return isU32(m.channel) && typeof m.data === 'string' && m.data.length <= 65536 ? { type: 'stdin', channel: m.channel, data: m.data } : null; case 'answer': { if (!isU32(m.channel)) return null; if (m.action === 'select') return isCount(m.optionN) && (m.optionN as number) >= 1 && (m.optionN as number) <= 99 ? { type: 'answer', channel: m.channel, action: 'select', optionN: m.optionN as number } : null; return m.action === 'confirm' || m.action === 'deny' ? { type: 'answer', channel: m.channel, action: m.action } : null; } case 'resize': return isU32(m.channel) && isDim(m.cols) && isDim(m.rows) ? { type: 'resize', channel: m.channel, cols: m.cols, rows: m.rows } : null; case 'ack': return isU32(m.channel) && isCount(m.bytes) ? { type: 'ack', channel: m.channel, bytes: m.bytes } : null; case 'sub': return Array.isArray(m.topics) && m.topics.every((t) => t === 'sessions' || t === 'worktrees' || t === 'groups' || t === 'settings' || t === 'clones') ? { type: 'sub', topics: m.topics as Array<'sessions' | 'worktrees' | 'groups' | 'settings' | 'clones'> } : null; case 'watch': case 'unwatch': // repoId/path bornés ici (typage + longueur) ; l'existence repo/worktree et la sûreté du // chemin sont re-validées côté serveur (isSafeAbsolutePath + findWorktree) avant d'armer. return typeof m.repoId === 'string' && m.repoId.length > 0 && m.repoId.length <= 128 && typeof m.path === 'string' && m.path.length > 0 && m.path.length <= 4096 ? { type: m.type, repoId: m.repoId, path: m.path } : null; case 'ping': return { type: 'ping' }; default: return null; } }