#!/usr/bin/env node // Spike S1 — resume/fork/vivacité. Scénarios : // 1. session A vivante → claude --resume en B : comportement (interleave ?) // 2. --fork-session sur session vivante : nouveau fichier, original intact // 3. kill -9 : fichier registre stale détectable (pid/procStart), resume sûr ensuite // 4. resume depuis un autre cwd : cwd effectif // 5. sortie gracieuse : nettoyage du fichier registre ? import { execSync } from 'node:child_process'; import { mkdirSync, writeFileSync, existsSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { ClaudeSession, registrySnapshot, jsonlInfo, listJsonl, sleep, setLogFile, log, munge, } from './harness.mjs'; const CAPTURES = new URL('./captures/', import.meta.url).pathname; mkdirSync(CAPTURES, { recursive: true }); setLogFile(join(CAPTURES, 'steps.jsonl')); const REPO = '/tmp/spike-s1-repo'; const OTHER = '/tmp/spike-s1-other'; for (const d of [REPO, OTHER]) { rmSync(d, { recursive: true, force: true }); mkdirSync(d, { recursive: true }); execSync(`git init -q -b main && echo hello > witness.txt && git add -A && git -c user.email=s@s -c user.name=spike commit -qm init`, { cwd: d, shell: '/bin/bash' }); } const results = {}; const save = () => writeFileSync(join(CAPTURES, 'results.json'), JSON.stringify(results, null, 1)); try { // ---------- Scénario 1 : resume d'une session VIVANTE ---------- log('=== S1.1 spawn session A ==='); const A = new ClaudeSession([], REPO, 'A', CAPTURES); const regA = await A.waitReady(); const sid = regA.sessionId; log('A: sessionId', { sid, pid: A.pid }); await A.type('Reply with exactly: PONG-ALPHA'); await A.waitTurnDone(); results.s1_initial = { sid, jsonl: jsonlInfo(REPO, sid) }; save(); log('=== S1.1 spawn B = claude --resume pendant que A est vivante ==='); const B = new ClaudeSession(['--resume', sid], REPO, 'B', CAPTURES); let regB = null; try { regB = await B.waitReady(30000); } catch { /* peut refuser/ne pas s'inscrire */ } await sleep(3000); results.s1_resumeLive = { bStarted: !!regB, bSessionId: regB?.sessionId ?? null, bSameId: regB?.sessionId === sid, bExit: B.exited, bScreenTail: B.tail(900), registryAfterB: registrySnapshot().filter((r) => [A.pid, B.pid].includes(r.pid)), }; save(); if (regB && !B.exited) { await B.type('Reply with exactly: PONG-BRAVO'); await B.waitTurnDone(); await A.type('Reply with exactly: PONG-CHARLIE'); await A.waitTurnDone(); await sleep(2000); const fileSid = jsonlInfo(REPO, sid); const fileB = regB.sessionId !== sid ? jsonlInfo(REPO, regB.sessionId) : null; results.s1_interleave = { originalFile: fileSid, bFile: fileB, interleaved: fileSid.userTexts.some((t) => t.includes('PONG-BRAVO')) && fileSid.userTexts.some((t) => t.includes('PONG-CHARLIE')), aScreenTail: A.tail(600), }; save(); } // ---------- Scénario 2 : fork d'une session vivante ---------- log('=== S1.2 fork-session pendant que A est vivante ==='); if (regB && !B.exited) { B.kill('SIGTERM'); await sleep(1500); } const beforeFork = jsonlInfo(REPO, sid); const C = new ClaudeSession(['--resume', sid, '--fork-session'], REPO, 'C', CAPTURES); const regC = await C.waitReady(); await C.type('Reply with exactly: PONG-DELTA'); await C.waitTurnDone(); await sleep(2000); const afterFork = jsonlInfo(REPO, sid); results.s2_fork = { cSessionId: regC.sessionId, forked: regC.sessionId !== sid, cFile: jsonlInfo(REPO, regC.sessionId), originalUserTexts: afterFork.userTexts, originalGotDelta: afterFork.userTexts.some((t) => t.includes('PONG-DELTA')), allJsonl: listJsonl(REPO), }; save(); // ---------- Scénario 3 : kill -9 → registre stale → resume sûr ---------- log('=== S1.3 kill -9 de C, détection stale, resume après mort ==='); const cPid = C.pid, cSid = regC.sessionId; C.kill('SIGKILL'); await sleep(2500); const staleSnap = registrySnapshot().filter((r) => r.pid === cPid); results.s3_kill9 = { registryFileRemains: staleSnap.length > 0, entry: staleSnap[0] ?? null }; const D = new ClaudeSession(['--resume', cSid], REPO, 'D', CAPTURES); const regD = await D.waitReady(); await D.type('Reply with exactly: PONG-ECHO'); await D.waitTurnDone(); results.s3_resumeAfterDeath = { dSessionId: regD.sessionId, sameAsC: regD.sessionId === cSid, dFile: jsonlInfo(REPO, regD.sessionId), }; save(); // ---------- Scénario 5 : sortie gracieuse — nettoyage du registre ? ---------- log('=== S1.5 sortie gracieuse de D (/exit) ==='); await D.type('/exit'); await sleep(4000); if (!D.exited) { D.write('\r'); await sleep(4000); } results.s5_gracefulExit = { exited: D.exited, registryCleaned: registrySnapshot().filter((r) => r.pid === D.pid).length === 0, }; save(); // ---------- Scénario 4 : resume depuis un AUTRE cwd ---------- log('=== S1.4 resume de la session de A depuis un autre cwd ==='); A.kill('SIGTERM'); await sleep(2000); const E = new ClaudeSession(['--resume', sid], OTHER, 'E', CAPTURES); let regE = null; try { regE = await E.waitReady(30000); } catch {} if (regE) { await E.type('Without using any tool, what is the current working directory given in your environment context? Reply with the path only.'); await E.waitTurnDone(); await sleep(1500); } results.s4_otherCwd = { started: !!regE, eSessionId: regE?.sessionId ?? null, registryCwd: regE?.cwd ?? null, eScreenTail: E.tail(900), jsonlInRepoDir: listJsonl(REPO), jsonlInOtherDir: listJsonl(OTHER), }; E.kill('SIGTERM'); save(); log('=== S1 terminé ==='); } catch (err) { results.fatalError = String(err); save(); log('ERREUR FATALE', { err: String(err) }); } finally { // Nettoyage : tuer tout claude restant lancé par ce script (pas ceux de l'utilisateur !) await sleep(1000); save(); process.exit(0); }