Files
arboretum/packages/server/src/core/claude-launcher.ts
T
johanleroy 17e95754b1 fix(server, desktop): plus de transcript perdu, et la mise à jour s'applique toute seule
Deux défauts vécus sur le poste, tous deux « invisibles » jusqu'à ce qu'on regarde.

1. Une console Claude ouverte depuis l'app affichait « Transcript saving is off,
   inherited CLAUDE_CODE_CHILD_SESSION marker ». Le daemon avait été lancé depuis
   une session Claude Code, il héritait donc de ses marqueurs d'exécution et les
   repassait à CHAQUE session qu'il lance. Le CLI se croyait sous-session et
   coupait la sauvegarde de son transcript : plus d'historique, plus de --resume,
   claudeSessionId restant null (et avec lui l'état fin busy/waiting/idle, ce qui
   explique les sessions sans activité détectée). L'environnement des PTY est
   desormais assaini de ces marqueurs, pour `claude` comme pour les shells (un
   `claude` tapé à la main en héritait aussi). La configuration légitime de
   l'utilisateur (CLAUDE_CONFIG_DIR, ANTHROPIC_*, proxies) passe intacte.
   Vérifié par acceptance-p17 : le daemon de test est lancé avec un environnement
   volontairement pollué, et le PTY n'en voit plus rien.

2. Une mise à jour installée à chaud demandait encore une manipulation. La 0.2.4
   détectait le remplacement du binaire et proposait un dialogue « Restart now » :
   le travail restait à la charge de l'utilisateur. L'app redémarre maintenant
   d'elle-même quand cela ne coûte rien, c'est-à-dire le cas courant, et ne
   demande que s'il y a quelque chose à perdre : des sessions vivantes à
   interrompre (le dialogue dit combien) ou un daemon injoignable. Un « Later »
   reste définitif pour cette version : rien ne redémarre dans le dos de
   personne. La détection ne dépend plus d'un retour par le tray ou le Dock : un
   `stat` toutes les 30 s la couvre même fenêtre ouverte, par poll et non par
   `fs.watch`, qui ne voit souvent rien quand un paquet remplace un binaire ou
   tout un répertoire.
2026-08-05 11:35:51 +02:00

205 lines
9.1 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'] };
}
/**
* Marqueurs d'EXÉCUTION que le CLI claude pose dans l'environnement de ses processus enfants. Si le
* daemon a lui-même été lancé depuis une session Claude Code (ce qui arrive : `arboretum` démarré
* depuis un terminal Claude, ou l'app de bureau lancée par un agent), il les hérite et les
* retransmettait à CHAQUE session qu'il lance. Conséquences observées :
* - `CLAUDE_CODE_CHILD_SESSION=1` fait croire au CLI qu'il est une sous-session : il DÉSACTIVE la
* sauvegarde du transcript (« Transcript saving is off »), donc plus d'historique, plus de
* `--resume`, et `claudeSessionId` reste null (l'état fin busy/waiting/idle tombe avec lui) ;
* - `CLAUDE_CODE_SESSION_ID` / `CLAUDE_PID` désignent la session PARENTE, pas celle qu'on lance.
* On ne retire QUE ces marqueurs : la configuration légitime de l'utilisateur (`CLAUDE_CONFIG_DIR`,
* `ANTHROPIC_*`, proxies...) doit passer telle quelle, sinon on casserait son installation.
*/
export const INHERITED_CLAUDE_MARKERS = [
'CLAUDECODE',
'CLAUDE_CODE_CHILD_SESSION',
'CLAUDE_CODE_SESSION_ID',
'CLAUDE_CODE_ENTRYPOINT',
'CLAUDE_CODE_EXECPATH',
'CLAUDE_PID',
'CLAUDE_EFFORT',
] as const;
/**
* Environnement assaini pour un PTY : pur et testable. Appliqué aussi au shell (`bash`), car un
* `claude` lancé à la main dans ce terminal hériterait des mêmes marqueurs.
*/
export function sanitizeInheritedEnv(source: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = { ...source };
for (const key of INHERITED_CLAUDE_MARKERS) delete env[key];
return env;
}
/** 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 = {
...sanitizeInheritedEnv(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 };
}