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:
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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': {
|
||||
|
||||
Reference in New Issue
Block a user