Files
arboretum/packages/shared/src/protocol.ts
Johan LEROY 663ae7ace1 fix: recherche worktrees, sessions externes masquées, rendu/historique terminal & plein écran grille
- Recherche : le watcher débouncé du singleton useWorktreeView est créé dans un effectScope
  détaché → ne gèle plus après une navigation ; recherche aussi par label de dépôt ; accès à
  la palette depuis le MoreSheet mobile (sidebar/⌘K indisponibles sur mobile).
- Sessions : sessionsForCwd exclut désormais toujours les sessions masquées ; les sessions
  externes (discovered) sont masquées par défaut côté UI, avec puce de révélation par carte et
  case globale dans le dashboard.
- Terminal : ACK découplé du callback de rendu (armé à la réception + flush au retour de
  visibilité) → plus de pause PTY quand l'onglet passe en arrière-plan ; repaint forcé sur
  perte de contexte WebGL / resize / retour en avant-plan. Historique élargi : REPLAY_TAIL_BYTES
  1 Mo, RING_CAPACITY 4 Mo, scrollback xterm 20k.
- Grille de groupe : bouton « plein écran » sur chaque cellule ouvrant /sessions/:id.
2026-06-23 17:42:49 +02:00

288 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 (≈ 1015k 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;
// ---- Masquage (additif) ----
/** true = session découverte masquée par l'utilisateur (exclue de la liste sauf includeHidden). */
hidden?: 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;
}
export interface RepoSummary {
id: string;
/** chemin absolu de la racine du repo (main worktree). */
path: string;
label: string;
defaultBranch: string | null;
postCreateHooks: PostCreateHook[];
/** 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;
}
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;
}
// ---- 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'> }
| { 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 }
| { type: 'repo_update'; repo: RepoSummary }
| { type: 'repo_removed'; repoId: string }
| { type: 'worktree_update'; repoId: string; worktree: WorktreeSummary }
| { type: 'worktree_removed'; 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<string, unknown>;
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')
? { type: 'sub', topics: m.topics as Array<'sessions' | 'worktrees' | 'groups'> }
: null;
case 'ping':
return { type: 'ping' };
default:
return null;
}
}