From 17e95754b156081f3c82d982907724ae112325e0 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 5 Aug 2026 11:35:51 +0200 Subject: [PATCH] =?UTF-8?q?fix(server,=20desktop):=20plus=20de=20transcrip?= =?UTF-8?q?t=20perdu,=20et=20la=20mise=20=C3=A0=20jour=20s'applique=20tout?= =?UTF-8?q?e=20seule?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/desktop/src/main/main.ts | 69 ++++++++++++++++--- packages/desktop/src/main/upgrade-watch.ts | 43 ++++++++++++ packages/desktop/test/upgrade-watch.test.ts | 71 +++++++++++++++++++- packages/server/scripts/acceptance-p17.mjs | 31 ++++++++- packages/server/src/core/claude-launcher.ts | 34 +++++++++- packages/server/test/claude-launcher.test.ts | 55 ++++++++++++++- 6 files changed, 289 insertions(+), 14 deletions(-) diff --git a/packages/desktop/src/main/main.ts b/packages/desktop/src/main/main.ts index 3da64d1..40a4f62 100644 --- a/packages/desktop/src/main/main.ts +++ b/packages/desktop/src/main/main.ts @@ -18,7 +18,7 @@ import { registerClipboardBridge } from './clipboard'; import { initUpdater } from './updater'; import { resolveIconPath } from './paths'; import { describeStartFailure } from './start-failure'; -import { installChanged, readInstallStamp, type InstallStamp } from './upgrade-watch'; +import { decideUpgradeAction, installChanged, pollInstall, readInstallStamp, type InstallStamp } from './upgrade-watch'; // WM_CLASS / app_id déterministe, posé AVANT app.whenReady(). Sous Wayland (défaut Debian/GNOME) // l'option `icon:` de BrowserWindow est ignorée : l'icône de fenêtre/dock vient du fichier .desktop @@ -44,6 +44,11 @@ let serverReady = false; const bootStamp = readInstallStamp(process.execPath); let dismissedStamp: InstallStamp | null = null; let restartPromptOpen = false; +/** Surveillance de l'installation : une mise à jour posée à chaud doit se voir sans que l'utilisateur + * ait à toucher à quoi que ce soit, fenêtre ouverte comprise. */ +let installPoll: { stop: () => void } | null = null; +/** Intervalle du poll : un `stat` toutes les 30 s est indolore et suffit largement. */ +const INSTALL_POLL_MS = 30_000; // Instance unique : deux instances = deux daemons/ports en conflit. if (!app.requestSingleInstanceLock()) { @@ -103,6 +108,16 @@ async function bootstrap(): Promise { installAppMenu({ url: daemonUrl(), onQuit: quitApp }); if (!tray) tray = createTray({ show: showWindow, quit: quitApp }); initUpdater(); + // Détection CONTINUE : sans elle, une mise à jour installée pendant que la fenêtre est ouverte + // n'était remarquée qu'au prochain passage par le tray ou le Dock, donc parfois jamais. + if (app.isPackaged && !installPoll) { + installPoll = pollInstall({ + path: process.execPath, + intervalMs: INSTALL_POLL_MS, + boot: bootStamp, + onChanged: () => void handleUpgradeInstalled(), + }); + } } /** Daemon + cookie de session : le strict nécessaire pour charger la SPA (aussi utilisé au redémarrage). */ @@ -205,33 +220,71 @@ app.on('activate', showWindow); function showWindow(): void { // Tray, second-instance et Dock passent tous ici : c'est le moment où l'utilisateur redemande - // l'app, donc le bon moment pour signaler une mise à jour installée entre-temps. - void maybePromptRestartAfterUpgrade(); + // l'app, donc le bon moment pour traiter une mise à jour installée entre-temps. + void handleUpgradeInstalled(); if (!win) return; if (win.isMinimized()) win.restore(); win.show(); win.focus(); } +/** + * Nombre de sessions VIVANTES hébergées par le daemon : ce sont les seules choses qu'un redémarrage + * détruit. `null` quand on n'a pas pu savoir (daemon injoignable) : l'appelant demandera alors. + * Pas d'en-tête Origin sur ce fetch, donc le check Origin strict du serveur ne s'y applique pas. + */ +async function countLiveSessions(): Promise { + const handle = daemon; + if (!handle || !serverReady) return null; + try { + const res = await fetch(`${handle.url}/api/v1/sessions`, { headers: { Authorization: `Bearer ${handle.token}` } }); + if (!res.ok) return null; + const body = (await res.json()) as { sessions?: Array<{ live?: boolean }> }; + return (body.sessions ?? []).filter((s) => s.live).length; + } catch { + return null; + } +} + /** * Mise à jour installée pendant que l'app tournait : le lock d'instance unique renvoie les lancements * suivants sur la fenêtre de l'ANCIENNE version, sans un mot, et l'utilisateur croit avoir migré. + * + * Objectif : zéro manipulation. Quand un redémarrage ne coûte RIEN (aucune session vivante), on + * redémarre tout seul. On ne demande que s'il y a du travail en cours à interrompre, ou si le daemon + * ne répond pas. Un « Later » précédent est définitif pour cette version. */ -async function maybePromptRestartAfterUpgrade(): Promise { - if (restartPromptOpen || isQuitting || !app.isPackaged) return; +async function handleUpgradeInstalled(): Promise { + if (restartPromptOpen || isQuitting || shuttingDown || !app.isPackaged) return; const current = readInstallStamp(process.execPath); - if (!installChanged(bootStamp, current)) return; - if (dismissedStamp && !installChanged(dismissedStamp, current)) return; + if (!current || !installChanged(bootStamp, current)) return; + + const dismissed = !!dismissedStamp && !installChanged(dismissedStamp, current); + const liveSessions = dismissed ? 0 : await countLiveSessions(); + const action = decideUpgradeAction({ changed: true, liveSessions, dismissed }); + if (action === 'none') return; + + if (action === 'restart') { + // Rien à perdre : on applique la mise à jour sans rien demander. C'est le cas courant. + installPoll?.stop(); + installPoll = null; + relaunchAfterQuit = true; + quitApp(); + return; + } restartPromptOpen = true; try { + const running = liveSessions ?? 0; const { response } = await dialog.showMessageBox({ type: 'info', title: 'Arboretum', message: 'A new version of Arboretum has been installed', detail: `This window still runs version ${app.getVersion()}, started before the update. ` + - 'Restart to load the installed version. Running sessions will be stopped.', + (running > 0 + ? `Restarting stops ${running} running session${running > 1 ? 's' : ''}. They can be resumed afterwards.` + : 'Restart to load the installed version.'), buttons: ['Restart now', 'Later'], defaultId: 0, cancelId: 1, diff --git a/packages/desktop/src/main/upgrade-watch.ts b/packages/desktop/src/main/upgrade-watch.ts index 70fdcdc..cacf436 100644 --- a/packages/desktop/src/main/upgrade-watch.ts +++ b/packages/desktop/src/main/upgrade-watch.ts @@ -26,3 +26,46 @@ export function installChanged(boot: InstallStamp | null, current: InstallStamp if (!boot || !current) return false; return boot.ino !== current.ino || boot.mtimeMs !== current.mtimeMs || boot.size !== current.size; } + +/** + * Ce qu'il faut faire d'une mise à jour installée à chaud. + * + * Le but est de ne PAS faire porter la manipulation à l'utilisateur : quand redémarrer ne coûte rien, + * on redémarre. La seule chose qu'un redémarrage détruit, ce sont les sessions vivantes hébergées par + * le daemon (des agents en train de travailler) : là seulement, on demande. + * + * `liveSessions === null` = on n'a pas pu le savoir (daemon injoignable) : on demande, par prudence. + * Un refus précédent (« Later ») est respecté, y compris si les sessions se terminent ensuite : on ne + * redémarre jamais dans le dos de quelqu'un qui a dit non. + */ +export function decideUpgradeAction(input: { + changed: boolean; + liveSessions: number | null; + dismissed: boolean; +}): 'none' | 'restart' | 'ask' { + if (!input.changed || input.dismissed) return 'none'; + return input.liveSessions === 0 ? 'restart' : 'ask'; +} + +/** + * Surveillance de l'installation par POLL, et non par `fs.watch` : un paquet remplace le binaire + * (nouvel inode) ou tout un répertoire, et selon le gestionnaire de paquets et le système de + * fichiers, `fs.watch` sur le fichier ne voit alors plus rien. Un `stat` périodique est trivial en + * coût et se comporte pareil partout. Le premier changement suffit : on arrête de surveiller. + */ +export function pollInstall(opts: { + path: string; + intervalMs: number; + boot: InstallStamp | null; + onChanged: (current: InstallStamp) => void; +}): { stop: () => void } { + const timer = setInterval(() => { + const current = readInstallStamp(opts.path); + if (!current || !installChanged(opts.boot, current)) return; + clearInterval(timer); + opts.onChanged(current); + }, opts.intervalMs); + // Ne retient pas la boucle d'événements : ce timer ne doit jamais empêcher l'app de quitter. + timer.unref?.(); + return { stop: () => clearInterval(timer) }; +} diff --git a/packages/desktop/test/upgrade-watch.test.ts b/packages/desktop/test/upgrade-watch.test.ts index f86789f..2011900 100644 --- a/packages/desktop/test/upgrade-watch.test.ts +++ b/packages/desktop/test/upgrade-watch.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { mkdtempSync, utimesSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { installChanged, readInstallStamp } from '../src/main/upgrade-watch'; +import { decideUpgradeAction, installChanged, pollInstall, readInstallStamp } from '../src/main/upgrade-watch'; describe('upgrade-watch', () => { it('lit une empreinte de fichier, et rien pour un chemin absent', () => { @@ -31,3 +31,70 @@ describe('upgrade-watch', () => { expect(installChanged(null, null)).toBe(false); }); }); + +// Objectif produit : ZÉRO manipulation quand c'est sans risque. Un redémarrage ne détruit qu'une +// chose, les sessions vivantes hébergées par le daemon : elles seules justifient de demander. +describe('decideUpgradeAction', () => { + it('rien à faire si l’installation n’a pas changé', () => { + expect(decideUpgradeAction({ changed: false, liveSessions: 0, dismissed: false })).toBe('none'); + expect(decideUpgradeAction({ changed: false, liveSessions: 3, dismissed: false })).toBe('none'); + }); + + it('aucune session vivante : redémarrage automatique, sans dialogue', () => { + expect(decideUpgradeAction({ changed: true, liveSessions: 0, dismissed: false })).toBe('restart'); + }); + + it('des sessions tournent : on demande avant de les interrompre', () => { + expect(decideUpgradeAction({ changed: true, liveSessions: 1, dismissed: false })).toBe('ask'); + expect(decideUpgradeAction({ changed: true, liveSessions: 9, dismissed: false })).toBe('ask'); + }); + + it('état inconnu (daemon injoignable) : on demande, par prudence', () => { + expect(decideUpgradeAction({ changed: true, liveSessions: null, dismissed: false })).toBe('ask'); + }); + + it('un « Later » est définitif : jamais de redémarrage dans le dos de l’utilisateur', () => { + expect(decideUpgradeAction({ changed: true, liveSessions: 0, dismissed: true })).toBe('none'); + expect(decideUpgradeAction({ changed: true, liveSessions: null, dismissed: true })).toBe('none'); + }); +}); + +describe('pollInstall', () => { + it('signale le premier changement, puis s’arrête de lui-même', async () => { + const dir = mkdtempSync(join(tmpdir(), 'arb-poll-')); + const file = join(dir, 'app'); + try { + writeFileSync(file, 'v1'); + const boot = readInstallStamp(file); + const seen: number[] = []; + const handle = pollInstall({ path: file, intervalMs: 10, boot, onChanged: (c) => seen.push(c.size) }); + await new Promise((r) => setTimeout(r, 40)); + expect(seen).toHaveLength(0); // rien n'a bougé + + writeFileSync(file, 'v2-plus-long'); + await new Promise((r) => setTimeout(r, 80)); + expect(seen).toHaveLength(1); + + // un second changement ne doit PAS rappeler : le poll s'arrête au premier + writeFileSync(file, 'v3-encore-plus-long'); + await new Promise((r) => setTimeout(r, 60)); + expect(seen).toHaveLength(1); + handle.stop(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('un chemin illisible ne déclenche rien', async () => { + const seen: string[] = []; + const handle = pollInstall({ + path: '/definitely/not/here', + intervalMs: 10, + boot: { ino: 1, mtimeMs: 1, size: 1 }, + onChanged: () => seen.push('x'), + }); + await new Promise((r) => setTimeout(r, 50)); + expect(seen).toHaveLength(0); + handle.stop(); + }); +}); diff --git a/packages/server/scripts/acceptance-p17.mjs b/packages/server/scripts/acceptance-p17.mjs index 72a94e6..1b5084c 100644 --- a/packages/server/scripts/acceptance-p17.mjs +++ b/packages/server/scripts/acceptance-p17.mjs @@ -29,8 +29,20 @@ const check = (name, ok, detail = '') => { }; const tmp = mkdtempSync(join(tmpdir(), 'arb-p17-')); +// Daemon lancé avec un environnement POLLUÉ, exactement comme lorsqu'il est démarré depuis une +// session Claude Code (cas vécu : l'app de bureau lancée par un agent). Ces marqueurs ne doivent +// JAMAIS atteindre les sessions qu'il lance, sinon le CLI se croit sous-session et coupe la +// sauvegarde de son transcript (plus d'historique, plus de --resume). const srv = spawn('node', [join(serverDir, 'dist', 'index.js'), '--port', String(PORT), '--db', join(tmp, 'a.db'), '--no-discover'], { - env: { ...process.env, ARBORETUM_LOG: 'warn' }, + env: { + ...process.env, + ARBORETUM_LOG: 'warn', + CLAUDECODE: '1', + CLAUDE_CODE_CHILD_SESSION: '1', + CLAUDE_CODE_SESSION_ID: 'parent-session-id', + CLAUDE_PID: '424242', + ARB_MARQUEUR_LEGITIME: 'conserve-moi', + }, stdio: ['ignore', 'pipe', 'pipe'], }); let srvOut = ''; @@ -181,7 +193,22 @@ try { check('épilogue : le code de sortie est écrit dans le terminal', epilogue.includes('[arboretum]') && epilogue.includes('exited with code 3'), epilogue.slice(-60).replace(/[\r\n]+/g, ' ')); check('épilogue reçu AVANT le detached', !!detached5); - // --- 6. Le PTY n'a pas été redimensionné par les attaches sans écran --- + // --- 6. L'environnement du PTY est assaini des marqueurs de la session parente --- + // Le nom du marqueur est CONCATÉNÉ dans la commande ('EN' + 'V:') pour que l'écho local du terminal + // ne ressemble pas au résultat : sinon on relit sa propre frappe et le test passe toujours. + c2.send({ type: 'stdin', channel: att2.channel, data: 'echo "EN""V:[$CLAUDECODE][$CLAUDE_CODE_CHILD_SESSION][$CLAUDE_CODE_SESSION_ID][$CLAUDE_PID]"\r' }); + await sleep(900); + const envLine = /ENV:\[[^\]]*\]\[[^\]]*\]\[[^\]]*\]\[[^\]]*\]/.exec(c2.outputOf(att2.channel).replace(/\r?\n/g, ''))?.[0] ?? ''; + check('les marqueurs de session parente ne sont pas transmis au PTY', envLine === 'ENV:[][][][]', envLine || 'non observé'); + + c2.send({ type: 'stdin', channel: att2.channel, data: 'echo "GARDE:[$ARB_MARQUEUR_LEGITIME]"\r' }); + await sleep(900); + check( + 'le reste de l’environnement est bien transmis', + c2.outputOf(att2.channel).includes('GARDE:[conserve-moi]'), + ); + + // --- 7. Le PTY n'a pas été redimensionné par les attaches sans écran --- const listed = await (await api('/api/v1/sessions')).json(); check('session toujours vivante après tout ça', listed.sessions.some((s) => s.id === sid && s.live)); diff --git a/packages/server/src/core/claude-launcher.ts b/packages/server/src/core/claude-launcher.ts index d0f23ca..5d743f4 100644 --- a/packages/server/src/core/claude-launcher.ts +++ b/packages/server/src/core/claude-launcher.ts @@ -146,11 +146,43 @@ export function resolvePlainShell(platform: NodeJS.Platform = process.platform): 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 = { - ...process.env, + ...sanitizeInheritedEnv(process.env), TERM: 'xterm-256color', COLORTERM: 'truecolor', }; diff --git a/packages/server/test/claude-launcher.test.ts b/packages/server/test/claude-launcher.test.ts index 90ac6f5..24f6b2a 100644 --- a/packages/server/test/claude-launcher.test.ts +++ b/packages/server/test/claude-launcher.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { buildSpawnSpec, diagnoseClaudeBin, resolveClaudeBin } from '../src/core/claude-launcher.js'; +import { buildSpawnSpec, diagnoseClaudeBin, INHERITED_CLAUDE_MARKERS, resolveClaudeBin, sanitizeInheritedEnv } from '../src/core/claude-launcher.js'; // resolveClaudeBin() fait `which claude` : on le stub pour ne pas dépendre d'un claude réel en PATH. vi.mock('node:child_process', () => ({ execFileSync: () => '/usr/bin/claude\n' })); @@ -52,3 +52,56 @@ describe('resolveClaudeBin / diagnoseClaudeBin · override de chemin (réglage U expect(diagnoseClaudeBin()).toEqual({ path: '/usr/bin/claude', source: 'path', ok: true }); }); }); + +// Régression vécue : l'app de bureau avait été lancée depuis une session Claude Code, donc le daemon +// héritait de `CLAUDE_CODE_CHILD_SESSION=1` et le repassait à CHAQUE session lancée. Le CLI se croyait +// sous-session et coupait la sauvegarde du transcript : « Transcript saving is off », plus aucun +// historique, plus de `--resume`, et `claudeSessionId` restait null (l'état fin tombe avec lui). +describe('sanitizeInheritedEnv · marqueurs de session parente', () => { + const polluted = { + PATH: '/usr/bin', + CLAUDECODE: '1', + CLAUDE_CODE_CHILD_SESSION: '1', + CLAUDE_CODE_SESSION_ID: 'afad72f8-a987-462a-9406-7fd144e05905', + CLAUDE_CODE_ENTRYPOINT: 'cli', + CLAUDE_CODE_EXECPATH: '/home/u/.local/share/claude/versions/2.1.222', + CLAUDE_PID: '1813704', + CLAUDE_EFFORT: 'xhigh', + CLAUDE_CONFIG_DIR: '/home/u/.claude', + ANTHROPIC_API_KEY: 'sk-test', + HTTPS_PROXY: 'http://proxy:3128', + }; + + it('retire les marqueurs d’exécution hérités', () => { + const env = sanitizeInheritedEnv(polluted); + for (const key of INHERITED_CLAUDE_MARKERS) expect(env[key]).toBeUndefined(); + }); + + it('conserve la configuration légitime de l’utilisateur', () => { + const env = sanitizeInheritedEnv(polluted); + expect(env.CLAUDE_CONFIG_DIR).toBe('/home/u/.claude'); + expect(env.ANTHROPIC_API_KEY).toBe('sk-test'); + expect(env.HTTPS_PROXY).toBe('http://proxy:3128'); + expect(env.PATH).toBe('/usr/bin'); + }); + + it('ne mute pas la source', () => { + const copy = { ...polluted }; + sanitizeInheritedEnv(copy); + expect(copy.CLAUDE_CODE_CHILD_SESSION).toBe('1'); + }); + + it('buildSpawnSpec assainit l’env, pour claude ET pour le shell', () => { + const previous = process.env.CLAUDE_CODE_CHILD_SESSION; + process.env.CLAUDE_CODE_CHILD_SESSION = '1'; + try { + expect(buildSpawnSpec({ command: 'claude' }).env.CLAUDE_CODE_CHILD_SESSION).toBeUndefined(); + // un `claude` tapé à la main dans ce shell hériterait sinon du même marqueur + expect(buildSpawnSpec({ command: 'bash' }).env.CLAUDE_CODE_CHILD_SESSION).toBeUndefined(); + expect(buildSpawnSpec({ command: 'claude' }).env.TERM).toBe('xterm-256color'); + } finally { + if (previous === undefined) delete process.env.CLAUDE_CODE_CHILD_SESSION; + else process.env.CLAUDE_CODE_CHILD_SESSION = previous; + } + }); +});