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.
205 lines
9.1 KiB
TypeScript
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 };
|
|
}
|