fix(server, web, vscode): les terminaux ne restent plus noirs, panes côte à côte, git scopé au terminal

Un terminal pouvait rester tout noir alors que sa session tournait. Le PTY était
vivant et avait bien écrit sa sortie : la panne était dans le transport. Le replay
d'attache est une frame BINAIRE, mais un client n'apprend son numéro de canal
qu'avec le message `attached` ; le serveur envoyait le replay AVANT, donc tout
client le jetait sur un canal inconnu. Rien n'était peint, et un TUI au repos
(Claude à son prompt) ne réémet jamais rien de lui-même. `attach()` renvoie
désormais le replay et la gateway l'émet APRÈS `attached` : un seul correctif
serveur répare le web, l'app de bureau et l'extension VS Code, qui portaient le
même défaut client. Le resize de l'attache masquait le bug en provoquant un
SIGWINCH, d'où son apparence intermittente.

Seconde moitié du symptôme (« je tape et rien ne se passe ») : le dock montait
avant la liste des sessions, en déduisait « non attachable » et s'attachait en
observateur, à vie et en silence. Un pane n'attache plus avant de connaître sa
session (`sessions.loaded`).

Attaches sans écran : le message `attach` accepte un `screen` optionnel (défaut
true). Un client qui n'affiche rien et veut seulement répondre à un dialogue ne
prend plus le contrôle de la session, ne lui impose plus ses dimensions (ce qui
figeait la géométrie du vrai terminal) et ne reçoit plus le flux pour le jeter.

Rendre les pannes visibles : la raison d'un exit est écrite dans le terminal
(`[arboretum] bash exited with code 3`) avant le détachement ; un repaint est
forcé si rien n'arrive 1,2 s après l'attache, puis annoncé avec « Refresh
screen » ; les refus de canal remontent à l'écran au lieu d'un console.warn ; le
chemin du CLI claude est revalidé (périmé après une bascule nvm/asdf, le PTY
mourait sans un octet).

Colonnes de terminaux : le dock devient une rangée de colonnes redimensionnables
(3 max), chacune avec ses onglets. Algèbre pure dans lib/dock-model.ts, cinq
invariants documentés, ratios plutôt que pixels. `dockSessionIds` et
`activeDockSessionId` deviennent des computed dérivés : aucun consommateur ni
test existant ne change. Alt+clic ouvre à côté depuis les quatre panneaux. Le
plafond de hauteur du dock suit le viewport au lieu d'un 640 px figé. Correctif
préexistant au passage : PanelSplitter passait ses bornes par valeur, figées au
premier rendu, alors que le clavier les relisait.

Portée git : la vue Changements suit le worktree du terminal focalisé, ou tous
les dépôts de son groupe pour une session de groupe, avec « tout voir » à un
clic. L'index Git de la sidebar reste global (c'est la sortie d'une portée
étroite) et le badge d'activité aussi (il sert à signaler le travail qu'on ne
regarde pas). Seul le TERMINAL impose le contexte : le repli sur l'onglet
éditeur, essayé d'abord, rétrécissait la vue multi-projet dès qu'on ouvrait un
fichier.

Vérifications : acceptance-p17.mjs prouve l'ordre des trames sur un vrai
WebSocket (avec l'ancien ordre : 0 octet rejoué, échec), verify-terminals.mjs
prouve par interaction réelle que le terminal peint, que deux colonnes
coexistent, que la frappe atteint le bon PTY (fichier témoin par cwd) et que la
vue suit le terminal.
This commit is contained in:
2026-08-05 10:56:42 +02:00
parent 9390b62249
commit 8aea0ae32d
48 changed files with 2917 additions and 130 deletions
+200
View File
@@ -0,0 +1,200 @@
#!/usr/bin/env node
// Acceptation P17 : « le terminal reste tout noir alors que la session tourne ».
//
// Cause racine reproduite ici : le replay d'attache est une frame BINAIRE, et le client n'apprend le
// numéro de canal qu'avec le message `attached`. Émis AVANT, le replay tombait sur un canal inconnu et
// était jeté en silence : rien à l'écran jusqu'au prochain octet spontané du PTY, c'est-à-dire jamais
// pour un TUI au repos. Ce script vérifie l'ordre réel des trames sur un VRAI WebSocket, et couvre au
// passage les attaches sans écran et l'épilogue de sortie.
//
// Aucun quota Claude consommé : commande `bash`.
import { spawn } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const WebSocket = require('ws');
const PORT = 7549;
const ORIGIN = `http://127.0.0.1:${PORT}`;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const serverDir = join(dirname(fileURLToPath(import.meta.url)), '..');
const results = [];
const check = (name, ok, detail = '') => {
results.push({ name, ok, detail });
console.log(`${ok ? '✅' : '❌'} ${name}${detail ? `: ${detail}` : ''}`);
};
const tmp = mkdtempSync(join(tmpdir(), 'arb-p17-'));
const srv = spawn('node', [join(serverDir, 'dist', 'index.js'), '--port', String(PORT), '--db', join(tmp, 'a.db'), '--no-discover'], {
env: { ...process.env, ARBORETUM_LOG: 'warn' },
stdio: ['ignore', 'pipe', 'pipe'],
});
let srvOut = '';
srv.stdout.on('data', (d) => (srvOut += d));
srv.stderr.on('data', (d) => (srvOut += d));
/**
* Client WS qui conserve la CHRONOLOGIE des trames (`frames`), texte et binaire mêlés : c'est le seul
* moyen de tester un ordre. Les frames binaires sont décodées en {type, channel, payload}.
*/
function wsClient(cookie) {
const ws = new WebSocket(`ws://127.0.0.1:${PORT}/ws`, { headers: { Origin: ORIGIN, Cookie: cookie } });
ws.binaryType = 'arraybuffer';
const frames = [];
const msgs = [];
ws.on('message', (data, isBinary) => {
if (!isBinary) {
const msg = JSON.parse(String(data));
msgs.push(msg);
frames.push({ kind: 'text', msg });
return;
}
const buf = Buffer.from(data);
frames.push({ kind: 'binary', type: buf.readUInt8(0), channel: buf.readUInt32LE(1), payload: buf.subarray(5) });
});
const waitMsg = async (pred, timeout = 8000) => {
const t0 = Date.now();
while (Date.now() - t0 < timeout) {
const m = msgs.find(pred);
if (m) return m;
await sleep(50);
}
return null;
};
/**
* Sortie telle que le VRAI client la peindrait : il n'enregistre un canal qu'en recevant `attached`
* et jette toute frame binaire arrivée avant. On imite ce comportement, sinon ce script verrait un
* écran que le navigateur, lui, n'affiche pas.
*/
const outputOf = (channel) => {
const known = frames.findIndex((f) => f.kind === 'text' && f.msg.type === 'attached' && f.msg.channel === channel);
if (known < 0) return '';
return frames
.slice(known)
.filter((f) => f.kind === 'binary' && f.channel === channel)
.map((f) => f.payload.toString('latin1'))
.join('');
};
return { ws, frames, msgs, waitMsg, outputOf, send: (m) => ws.send(JSON.stringify(m)) };
}
try {
await sleep(1500);
const token = /arb_[0-9a-f]+/.exec(srvOut)?.[0];
check('boot + token bootstrap', !!token);
const login = await fetch(`${ORIGIN}/api/v1/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Origin: ORIGIN },
body: JSON.stringify({ token }),
});
const cookie = login.headers.get('set-cookie')?.split(';')[0] ?? '';
check('login → cookie', login.status === 200 && cookie.startsWith('arb_session='));
const api = (path, init = {}) =>
fetch(`${ORIGIN}${path}`, { ...init, headers: { 'Content-Type': 'application/json', Origin: ORIGIN, Cookie: cookie, ...(init.headers ?? {}) } });
const created = await api('/api/v1/sessions', { method: 'POST', body: JSON.stringify({ cwd: tmp, command: 'bash' }) });
const sid = (await created.json()).session.id;
check('spawn bash', created.status === 201 && !!sid);
// --- 1. Première attache : de la sortie existe déjà dans le ring ---
const c1 = wsClient(cookie);
await new Promise((res, rej) => (c1.ws.on('open', res), c1.ws.on('error', rej)));
c1.send({ type: 'hello', protocol: 1 });
await c1.waitMsg((m) => m.type === 'hello_ok');
c1.send({ type: 'attach', sessionId: sid, mode: 'interactive', cols: 120, rows: 32 });
const att1 = await c1.waitMsg((m) => m.type === 'attached');
check('attach interactif + controlling', att1?.controlling === true);
await sleep(300);
c1.send({ type: 'stdin', channel: att1.channel, data: 'echo MARQUEUR-ECRAN-1\r' });
await sleep(800);
check('stdin → output', c1.outputOf(att1.channel).includes('MARQUEUR-ECRAN-1'));
// --- 2. Ré-attache (nouvelle connexion, PTY silencieux) : l'écran DOIT revenir ---
// C'est le scénario vécu : l'app est rechargée, Claude est à son prompt et n'émet plus rien.
const c2 = wsClient(cookie);
await new Promise((res, rej) => (c2.ws.on('open', res), c2.ws.on('error', rej)));
c2.send({ type: 'hello', protocol: 1 });
await c2.waitMsg((m) => m.type === 'hello_ok');
c2.send({ type: 'attach', sessionId: sid, mode: 'interactive', cols: 120, rows: 32 });
const att2 = await c2.waitMsg((m) => m.type === 'attached');
await sleep(400);
const idxAttached = c2.frames.findIndex((f) => f.kind === 'text' && f.msg.type === 'attached');
const idxResync = c2.frames.findIndex((f) => f.kind === 'binary' && f.type === 0x02);
check('ORDRE : `attached` précède le replay binaire', idxAttached >= 0 && idxResync > idxAttached, `attached@${idxAttached}, resync@${idxResync}`);
check('le replay porte le canal annoncé', c2.frames[idxResync]?.channel === att2.channel);
const replay = c2.outputOf(att2.channel);
check('l’écran se reconstitue à la ré-attache (fin de l’écran noir)', replay.includes('MARQUEUR-ECRAN-1'), `${replay.length} octets rejoués`);
// --- 3. Un observateur peint aussi : il doit recevoir son replay ---
const c3 = wsClient(cookie);
await new Promise((res, rej) => (c3.ws.on('open', res), c3.ws.on('error', rej)));
c3.send({ type: 'hello', protocol: 1 });
await c3.waitMsg((m) => m.type === 'hello_ok');
c3.send({ type: 'attach', sessionId: sid, mode: 'observer', cols: 100, rows: 30 });
const att3 = await c3.waitMsg((m) => m.type === 'attached');
await sleep(400);
check('un observateur reçoit son replay (non-controlling)', att3?.controlling === false && c3.outputOf(att3.channel).includes('MARQUEUR-ECRAN-1'));
// --- 4. Attache SANS écran : ne vole pas le contrôle, ne reçoit rien ---
// Régression : le DialogPrompt attachait en interactif avec des dimensions bidon, prenait le
// `controlling` et figeait la géométrie du TUI pour le terminal ouvert ensuite.
const c4 = wsClient(cookie);
await new Promise((res, rej) => (c4.ws.on('open', res), c4.ws.on('error', rej)));
c4.send({ type: 'hello', protocol: 1 });
await c4.waitMsg((m) => m.type === 'hello_ok');
c4.send({ type: 'attach', sessionId: sid, mode: 'interactive', cols: 80, rows: 24, screen: false });
const att4 = await c4.waitMsg((m) => m.type === 'attached');
await sleep(300);
check('attache sans écran : jamais controlling', att4?.controlling === false);
check('attache sans écran : aucune frame binaire', !c4.frames.some((f) => f.kind === 'binary'));
c2.send({ type: 'stdin', channel: att2.channel, data: 'echo APRES-AVEUGLE\r' });
await sleep(800);
check('attache sans écran : ne reçoit pas la sortie du PTY', !c4.frames.some((f) => f.kind === 'binary'));
check('le terminal à écran garde le contrôle et fonctionne', c2.outputOf(att2.channel).includes('APRES-AVEUGLE'));
// elle garde en revanche le droit d'écrire (c'est sa seule raison d'être)
c4.send({ type: 'stdin', channel: att4.channel, data: 'echo ECRIT-PAR-AVEUGLE\r' });
await sleep(800);
check('attache sans écran : peut écrire', c2.outputOf(att2.channel).includes('ECRIT-PAR-AVEUGLE'));
// --- 5. Épilogue de sortie : la raison de la mort est visible DANS le terminal ---
const dying = await api('/api/v1/sessions', { method: 'POST', body: JSON.stringify({ cwd: tmp, command: 'bash' }) });
const dsid = (await dying.json()).session.id;
const c5 = wsClient(cookie);
await new Promise((res, rej) => (c5.ws.on('open', res), c5.ws.on('error', rej)));
c5.send({ type: 'hello', protocol: 1 });
await c5.waitMsg((m) => m.type === 'hello_ok');
c5.send({ type: 'attach', sessionId: dsid, mode: 'interactive', cols: 80, rows: 24 });
const att5 = await c5.waitMsg((m) => m.type === 'attached');
await sleep(300);
c5.send({ type: 'stdin', channel: att5.channel, data: 'exit 3\r' });
const detached5 = await c5.waitMsg((m) => m.type === 'detached' && m.channel === att5.channel, 8000);
const epilogue = c5.outputOf(att5.channel);
check('épilogue : le code de sortie est écrit dans le terminal', epilogue.includes('[arboretum]') && epilogue.includes('exited with code 3'), epilogue.slice(-60).replace(/[\r\n]+/g, ' '));
check('épilogue reçu AVANT le detached', !!detached5);
// --- 6. Le PTY n'a pas été redimensionné par les attaches sans écran ---
const listed = await (await api('/api/v1/sessions')).json();
check('session toujours vivante après tout ça', listed.sessions.some((s) => s.id === sid && s.live));
for (const c of [c1, c2, c3, c4, c5]) c.ws.close();
} catch (err) {
check('exception', false, String(err));
} finally {
srv.kill('SIGTERM');
await sleep(1500);
check('arrêt propre du daemon (SIGTERM)', srv.exitCode === 0 || srv.exitCode === null);
rmSync(tmp, { recursive: true, force: true });
const failed = results.filter((r) => !r.ok);
if (failed.length > 0) console.log(`\n--- sortie du daemon ---\n${srvOut.slice(-2000)}`);
console.log(failed.length === 0 ? '\nACCEPTANCE P17: ALL GREEN' : `\nACCEPTANCE P17: ${failed.length} FAILURE(S)`);
process.exit(failed.length === 0 ? 0 : 1);
}