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
+8 -1
View File
@@ -86,7 +86,14 @@ export function resolveClaudeBin(configuredPath?: string | null): string {
}
return configuredPath;
}
if (cachedClaudeBin) return cachedClaudeBin;
// Cache REVALIDÉ : le daemon vit des jours. Un changement de version nvm/asdf, une réinstallation
// du CLI ou un simple `npm i -g` remplace le chemin, et le cache pointait alors sur un fichier
// disparu : node-pty spawnait dans le vide, le PTY mourait sans un octet, et l'utilisateur n'avait
// qu'un terminal vide sans explication.
if (cachedClaudeBin) {
if (isExecutable(cachedClaudeBin)) return cachedClaudeBin;
cachedClaudeBin = null;
}
const found = findClaudeOnPath();
if (!found) {
throw new Error(
+38 -9
View File
@@ -23,6 +23,8 @@ const NOTIFY_DEBOUNCE_MS = 1500;
/** Capture du claudeSessionId après spawn : poll du registre par pid (waitReady validé S1). */
const CLAUDE_ID_POLL_MS = 400;
const CLAUDE_ID_TIMEOUT_MS = 60_000;
/** Replay d'une attache sans écran : rien à peindre (alloué une fois, jamais muté). */
const EMPTY_REPLAY = Buffer.alloc(0);
/**
* Ligne `sessions` telle que lue pour construire un SessionSummary historique (session terminée).
@@ -79,6 +81,14 @@ function parseAddedDirs(raw: string | null): string[] {
export interface ClientBinding {
channel: number;
mode: 'interactive' | 'observer';
/**
* false = attache SANS écran (le client ne peint rien : il n'est là que pour écrire, cf. le
* DialogPrompt qui répond à un dialogue depuis la liste « À traiter »). Un tel binding ne prend
* jamais le `controlling` et ne redimensionne donc jamais le PTY : sinon ses dimensions
* arbitraires figeaient la géométrie du TUI pour le vrai terminal ouvert ensuite. Il ne reçoit
* pas non plus la sortie (inutile) et ne pèse pas dans le flow control.
*/
screen: boolean;
controlling: boolean;
sentBytes: number;
ackedBytes: number;
@@ -403,22 +413,32 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
// ---- attach / detach / io ----
attach(sessionId: string, binding: ClientBinding, cols: number, rows: number): { ok: true; controlling: boolean } | { ok: false; code: 'NOT_FOUND' | 'SESSION_EXITED' } {
/**
* Attache un client. Le payload de replay (reset terminal + queue du ring, l'écran TUI courant se
* reconstitue) est RENVOYÉ, pas envoyé : la gateway doit l'émettre APRÈS le message `attached`,
* sinon le client reçoit une frame binaire sur un canal qu'il ne connaît pas encore et la jette,
* ce qui laissait un terminal vide jusqu'au prochain octet spontané du PTY (jamais, pour un TUI
* au repos). Invariant à ne pas casser.
*/
attach(
sessionId: string,
binding: ClientBinding,
cols: number,
rows: number,
): { ok: true; controlling: boolean; replay: Buffer } | { ok: false; code: 'NOT_FOUND' | 'SESSION_EXITED' } {
const s = this.live.get(sessionId);
if (!s) return { ok: false, code: 'NOT_FOUND' };
if (s.exited) return { ok: false, code: 'SESSION_EXITED' };
const hasController = [...s.clients].some((c) => c.controlling);
binding.controlling = binding.mode === 'interactive' && !hasController;
binding.controlling = binding.mode === 'interactive' && binding.screen && !hasController;
s.clients.add(binding);
if (binding.controlling) {
s.proc.resize(cols, rows);
s.tracker?.resize(cols, rows);
}
// Replay : reset terminal + queue du ring (l'écran TUI courant se reconstitue)
binding.sendResync(s.ring.tail(REPLAY_TAIL_BYTES));
binding.sentBytes = 0;
binding.ackedBytes = 0;
return { ok: true, controlling: binding.controlling };
return { ok: true, controlling: binding.controlling, replay: binding.screen ? s.ring.tail(REPLAY_TAIL_BYTES) : EMPTY_REPLAY };
}
detach(sessionId: string, binding: ClientBinding): void {
@@ -426,7 +446,9 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
if (!s) return;
s.clients.delete(binding);
if (binding.controlling) {
const next = [...s.clients].find((c) => c.mode === 'interactive');
// Le relais ne peut aller qu'à un client qui PEINT : un binding sans écran redimensionnerait
// le PTY à des dimensions arbitraires (cf. ClientBinding.screen).
const next = [...s.clients].find((c) => c.mode === 'interactive' && c.screen);
if (next) {
next.controlling = true;
next.onControlChanged(true);
@@ -535,7 +557,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
s.ring.write(chunk);
s.tracker?.feed(chunk);
for (const c of s.clients) {
if (c.lagging) continue;
if (c.lagging || !c.screen) continue;
c.sendOutput(chunk);
c.sentBytes += chunk.length;
if (c.sentBytes - c.ackedBytes > FLOW.LAGGING_BYTES) c.lagging = true;
@@ -545,11 +567,13 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
/**
* pause() seulement quand TOUS les clients interactifs non-lagging dépassent HIGH ;
* resume() quand le min repasse sous LOW. Les observers ne freinent jamais le PTY.
* resume() quand le min repasse sous LOW. Les observers ne freinent jamais le PTY, et les
* attaches sans écran ne reçoivent rien : les compter maintiendrait un `outstanding` nul et
* empêcherait toute pause, donc on les écarte.
*/
private updateFlowControl(s: ManagedSession): void {
if (s.exited) return;
const interactive = [...s.clients].filter((c) => c.mode === 'interactive' && !c.lagging);
const interactive = [...s.clients].filter((c) => c.mode === 'interactive' && c.screen && !c.lagging);
if (interactive.length === 0) {
if (s.paused) {
s.proc.resume();
@@ -576,6 +600,11 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
if (s.notifyTimer) clearTimeout(s.notifyTimer);
const endedAt = new Date().toISOString();
this.db.prepare('UPDATE sessions SET ended_at = ?, exit_code = ? WHERE id = ?').run(endedAt, exitCode, s.id);
// Épilogue visible DANS le terminal : sans lui, un process mort à l'instant du spawn (binaire
// introuvable, auth expirée, commande de lancement qui sort aussitôt) ne laissait qu'un écran
// vide et un bandeau « Session terminée », sans jamais dire pourquoi. Diffusé AVANT le
// `onDetached` : après, le client a déjà oublié le canal et jetterait la frame.
this.handleOutput(s, Buffer.from(`\r\n[arboretum] ${s.command} ${signal ? `terminated by signal ${signal}` : `exited with code ${exitCode ?? 0}`}\r\n`));
for (const c of s.clients) c.onDetached('session_exit');
s.clients.clear();
this.live.delete(s.id);
+7
View File
@@ -182,6 +182,7 @@ export function registerWsGateway(
const binding: ClientBinding = {
channel,
mode: msg.mode,
screen: msg.screen ?? true,
controlling: false,
sentBytes: 0,
ackedBytes: 0,
@@ -200,7 +201,13 @@ export function registerWsGateway(
return;
}
channels.set(channel, { sessionId: msg.sessionId, binding });
// ORDRE CRITIQUE : `attached` d'abord, le replay ENSUITE. Le client n'apprend le numéro de
// canal qu'avec `attached` ; une frame binaire émise avant tombe sur un canal inconnu et
// est jetée en silence, ce qui laissait le terminal vide (un TUI au repos ne réémet rien).
send({ type: 'attached', channel, sessionId: msg.sessionId, mode: msg.mode, controlling: res.controlling });
// Toujours envoyé quand le client peint, même vide : le resync porte AUSSI l'ordre de reset,
// sans quoi une ré-attache après reconnexion empilerait le nouveau flux sur un écran périmé.
if (binding.screen) binding.sendResync(res.replay);
return;
}
case 'detach': {