// 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) */ export const REPLAY_TAIL_BYTES = 256 * 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'; 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; } // ---- 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 } | { type: 'resize'; channel: number; cols: number; rows: number } | { type: 'ack'; channel: number; bytes: number } | { type: 'sub'; topics: Array<'sessions'> } | { 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: 'error'; code: ErrorCode; message: string; channel?: number } | { type: 'pong' }; export type ErrorCode = | 'BAD_PROTOCOL' | 'BAD_MESSAGE' | 'NOT_FOUND' | 'NOT_ATTACHED' | 'NOT_CONTROLLING' | 'SESSION_EXITED' | '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 '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') ? { type: 'sub', topics: m.topics as Array<'sessions'> } : null; case 'ping': return { type: 'ping' }; default: return null; } }