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.
173 lines
7.5 KiB
TypeScript
173 lines
7.5 KiB
TypeScript
import { execFileSync } from 'node:child_process';
|
|
import { accessSync, constants, existsSync } from 'node:fs';
|
|
|
|
export interface SpawnSpec {
|
|
file: string;
|
|
args: string[];
|
|
env: NodeJS.ProcessEnv;
|
|
}
|
|
|
|
export interface SpawnOptions {
|
|
command: 'claude' | 'bash';
|
|
/** reprise d'une session existante (P2) : `--resume <id>`, `--fork-session` si fork. */
|
|
resume?: { claudeSessionId: string; fork?: boolean };
|
|
/** répertoires supplémentaires à relier dans une seule session (P6) : `--add-dir <path>` répété. */
|
|
addDirs?: string[];
|
|
/** chemin explicite du binaire `claude` (réglage UI) ; sinon résolution via le PATH. */
|
|
claudeBinPath?: string | null;
|
|
/**
|
|
* Lancement de projet (« Démarrer le projet ») : au lieu de `bash --norc`, lance le shell de
|
|
* login interactif de l'utilisateur (`$SHELL -l -i`) pour charger son environnement complet
|
|
* (PATH nvm/asdf/~/.local/bin). Indispensable quand le daemon tourne en service systemd/launchd
|
|
* (PATH minimal, cf. resolveClaudeBin) : sinon `npm`/`docker` seraient introuvables. Ignoré pour claude.
|
|
*/
|
|
login?: boolean;
|
|
/** plateforme cible (injectable pour les tests) ; défaut `process.platform`. */
|
|
platform?: NodeJS.Platform;
|
|
}
|
|
|
|
/** Diagnostic de résolution du binaire `claude` (exposé en lecture dans Réglages). */
|
|
export interface ClaudeBinDiagnostic {
|
|
/** chemin résolu du binaire, ou null si introuvable. */
|
|
path: string | null;
|
|
/** 'configured' = réglage explicite ; 'path' = trouvé via PATH ; null = introuvable. */
|
|
source: 'configured' | 'path' | null;
|
|
/** true si le binaire est présent et exécutable. */
|
|
ok: boolean;
|
|
}
|
|
|
|
let cachedClaudeBin: string | null = null;
|
|
|
|
/**
|
|
* Commande de recherche dans le PATH selon la plateforme : `which` n'existe PAS sur Windows, c'est
|
|
* `where.exe` (qui peut renvoyer plusieurs lignes, la première étant la retenue).
|
|
*/
|
|
export function whichCommand(platform: NodeJS.Platform = process.platform): { file: string; args: string[] } {
|
|
return platform === 'win32' ? { file: 'where.exe', args: ['claude'] } : { file: 'which', args: ['claude'] };
|
|
}
|
|
|
|
/** Recherche `claude` dans le PATH (sans throw). null si absent. */
|
|
function findClaudeOnPath(platform: NodeJS.Platform = process.platform): string | null {
|
|
const { file, args } = whichCommand(platform);
|
|
try {
|
|
const out = execFileSync(file, args, { encoding: 'utf8' });
|
|
// `where.exe` liste toutes les correspondances : on garde la première.
|
|
return out.split(/\r?\n/).map((l) => l.trim()).find((l) => l.length > 0) ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* « Est-ce lançable ? ». Sur Windows, le bit d'exécution POSIX n'a aucun sens (NTFS n'en a pas) et
|
|
* `accessSync(X_OK)` y répond au hasard : on se contente donc de l'existence du fichier.
|
|
*/
|
|
function isExecutable(path: string, platform: NodeJS.Platform = process.platform): boolean {
|
|
if (platform === 'win32') return existsSync(path);
|
|
try {
|
|
accessSync(path, constants.X_OK);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Résout le binaire `claude`. Si `configuredPath` est fourni (réglage UI), il est utilisé tel quel
|
|
* (validé exécutable, message clair sinon) et JAMAIS mis en cache (modifiable à chaud). Sinon :
|
|
* recherche dans le PATH (`which` / `where.exe`), mise en cache. Un service systemd/launchd démarre
|
|
* avec un PATH minimal sans ~/.local/bin → la recherche y échoue ; d'où le réglage de chemin explicite
|
|
* (et le PATH figé par `arboretum install`).
|
|
*/
|
|
export function resolveClaudeBin(configuredPath?: string | null): string {
|
|
if (configuredPath) {
|
|
if (!isExecutable(configuredPath)) {
|
|
throw new Error(`Configured Claude CLI path is not executable: ${configuredPath}`);
|
|
}
|
|
return configuredPath;
|
|
}
|
|
// 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(
|
|
'Claude Code CLI not found in PATH. Install it first: https://code.claude.com/docs/en/quickstart',
|
|
);
|
|
}
|
|
cachedClaudeBin = found;
|
|
return cachedClaudeBin;
|
|
}
|
|
|
|
/** Diagnostic non-throwing pour l'UI (Réglages) : recalculé à chaque appel, jamais caché. */
|
|
export function diagnoseClaudeBin(configuredPath?: string | null): ClaudeBinDiagnostic {
|
|
if (configuredPath) {
|
|
return { path: configuredPath, source: 'configured', ok: isExecutable(configuredPath) };
|
|
}
|
|
const found = findClaudeOnPath();
|
|
return found ? { path: found, source: 'path', ok: true } : { path: null, source: null, ok: false };
|
|
}
|
|
|
|
/** Shells interactifs connus supportant `-l -i` (login + interactif). */
|
|
const KNOWN_LOGIN_SHELLS = new Set(['bash', 'zsh', 'fish']);
|
|
|
|
/**
|
|
* Shell interactif pour « Démarrer le projet ».
|
|
*
|
|
* POSIX : `$SHELL -l -i` s'il fait partie des shells connus supportant ces options (bash/zsh/fish),
|
|
* sinon `bash` (un `$SHELL=dash` sortirait aussitôt avec `-l -i`, laissant un terminal vide).
|
|
*
|
|
* Windows : PowerShell, en restant attaché après la commande auto-tapée (`-NoExit`), avec repli sur
|
|
* `cmd.exe /K`. `%COMSPEC%` n'est PAS utilisé comme shell de lancement : il pointe cmd.exe, qui ne
|
|
* charge aucun profil utilisateur. La commande est ensuite auto-tapée par le PtyManager, exactement
|
|
* comme sous POSIX · le mécanisme est indépendant du shell.
|
|
*/
|
|
export function resolveInteractiveShell(
|
|
platform: NodeJS.Platform = process.platform,
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
): { file: string; args: string[] } {
|
|
if (platform === 'win32') {
|
|
const pwsh = env.ARBORETUM_SHELL ?? 'powershell.exe';
|
|
return { file: pwsh, args: ['-NoLogo', '-NoExit'] };
|
|
}
|
|
const shell = env.SHELL;
|
|
const file = shell && KNOWN_LOGIN_SHELLS.has(shell.split('/').pop() ?? '') ? shell : 'bash';
|
|
return { file, args: ['-l', '-i'] };
|
|
}
|
|
|
|
/** Shell non interactif « neutre » (terminal simple, hors lancement de projet). */
|
|
export function resolvePlainShell(platform: NodeJS.Platform = process.platform): { file: string; args: string[] } {
|
|
if (platform === 'win32') return { file: 'powershell.exe', args: ['-NoLogo', '-NoExit'] };
|
|
return { file: 'bash', args: ['--norc'] };
|
|
}
|
|
|
|
/** Module volontairement abstrait : le plan B « BYO API key / Agent SDK » se brancherait ici. */
|
|
export function buildSpawnSpec(opts: SpawnOptions): SpawnSpec {
|
|
const platform = opts.platform ?? process.platform;
|
|
const env: NodeJS.ProcessEnv = {
|
|
...process.env,
|
|
TERM: 'xterm-256color',
|
|
COLORTERM: 'truecolor',
|
|
};
|
|
if (opts.command === 'bash') {
|
|
// `'bash'` désigne « le shell de la machine », pas littéralement bash : le contrat d'API reste
|
|
// stable (claude|bash) et c'est ici qu'on choisit le shell réel par plateforme.
|
|
const { file, args } = opts.login ? resolveInteractiveShell(platform) : resolvePlainShell(platform);
|
|
return { file, args, env };
|
|
}
|
|
const args: string[] = [];
|
|
if (opts.resume) {
|
|
// `--resume` doit toujours s'exécuter dans le cwd d'origine (garanti par l'appelant, spike S1).
|
|
args.push('--resume', opts.resume.claudeSessionId);
|
|
if (opts.resume.fork) args.push('--fork-session');
|
|
}
|
|
// Session de groupe : relie plusieurs repos/worktrees dans une seule session (P6).
|
|
for (const dir of opts.addDirs ?? []) args.push('--add-dir', dir);
|
|
return { file: resolveClaudeBin(opts.claudeBinPath), args, env };
|
|
}
|