Files
arboretum/spikes/s3-tui/run.mjs
Johan LEROY 3d2f7a2041 Spike S1: resume/fork/liveness — GO; flood: pause/resume — GO
Demonstrated on CLI 2.1.173: resuming a live session interleaves both
TUIs into one transcript (no lock, no warning) — liveness detection via
registry pid+procStart is mandatory; --fork-session is safe on live
sessions; --resume must run in the session's original cwd ("No
conversation found" otherwise); registry files are cleaned on graceful
exit AND SIGTERM, may be GC'd later after SIGKILL — never reason on
file presence. ANSI-stripped TUI text loses spaces (cursor-positioned
painting) — confirms @xterm/headless for screen parsing.
Flood: 21 MB through node-pty with 10s pause => 0 bytes leaked, no
loss, 4.7 ms echo after flood.
2026-06-11 17:47:05 +02:00

191 lines
7.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// Spike S3 — dialogues TUI : détection (registre + écran), extraction des options, frappes de réponse.
// Types couverts : trust dialog (nouveau dossier), permission Bash, permission Write (édition),
// AskUserQuestion, refus par Esc. (Approbation de plan : suivi manuel, voir VERDICT.)
import { execSync } from 'node:child_process';
import { mkdirSync, writeFileSync, rmSync, readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { ClaudeSession, sleep, setLogFile, log } from '../s1-resume/harness.mjs';
const CAPTURES = new URL('./captures/', import.meta.url).pathname;
mkdirSync(CAPTURES, { recursive: true });
setLogFile(join(CAPTURES, 'steps.jsonl'));
const results = {};
const save = () => writeFileSync(join(CAPTURES, 'results.json'), JSON.stringify(results, null, 1));
function freshRepo(name) {
const d = `/tmp/spike-s3-${name}`;
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' });
return d;
}
// Extraction des options d'un dialogue depuis l'écran (regex du design moteur).
// NB : le strip ANSI mange les espaces peints par déplacements de curseur → \s* après le point,
// et les labels peuvent être concaténés (« Yes,Itrustthisfolder »).
function extractOptions(screenTail) {
const opts = [];
for (const m of screenTail.matchAll(/^\s*(\s*)?(\d+)\.\s*(.+)$/gm)) {
opts.push({ selected: !!m[1], n: m[2], label: m[3].trim().slice(0, 90) });
}
return opts;
}
async function waitWaiting(S, timeoutMs = 120000) {
return S.until('registre → waiting', () => {
const r = S.registry();
return r && r.status === 'waiting' ? r : false;
}, timeoutMs, 400);
}
// Répond au dialogue : essaie la touche chiffre seule, puis Entrée, puis flèches+Entrée.
async function respond(S, digit) {
const tries = [];
S.write(digit);
await sleep(2500);
let r = S.registry();
tries.push({ method: `digit ${digit}`, statusAfter: r?.status });
if (r?.status !== 'waiting') return { ok: true, tries };
S.write('\r');
await sleep(2500);
r = S.registry();
tries.push({ method: 'enter', statusAfter: r?.status });
if (r?.status !== 'waiting') return { ok: true, tries };
const downs = Math.max(0, parseInt(digit, 10) - 1);
for (let i = 0; i < downs; i++) { S.write('\x1b[B'); await sleep(250); }
S.write('\r');
await sleep(2500);
r = S.registry();
tries.push({ method: `arrows x${downs} + enter`, statusAfter: r?.status });
return { ok: r?.status !== 'waiting', tries };
}
try {
// ---------- D1 : trust dialog sur dossier neuf + état ~/.claude.json ----------
log('=== D1 trust dialog ===');
const d1 = freshRepo('trust');
const claudeJsonBefore = existsSync(join(homedir(), '.claude.json'))
? JSON.parse(readFileSync(join(homedir(), '.claude.json'), 'utf8')) : null;
const trustBefore = claudeJsonBefore?.projects?.[d1] ?? null;
const T = new ClaudeSession([], d1, 'trust', CAPTURES);
// waitReady gère le trust par Entrée et logge l'écran du dialogue
const regT = await T.waitReady(60000);
await sleep(1500);
const claudeJsonAfter = JSON.parse(readFileSync(join(homedir(), '.claude.json'), 'utf8'));
results.d1_trust = {
sessionStarted: !!regT,
projectEntryBefore: trustBefore,
projectEntryAfter: claudeJsonAfter?.projects?.[d1] ?? null,
note: 'écran du dialogue dans trust.raw.log + steps.jsonl',
};
T.kill('SIGTERM');
save();
// ---------- D2 : permission Bash → réponse "1" (Yes) ----------
log('=== D2 permission Bash, approbation ===');
const d2 = freshRepo('bash');
const P = new ClaudeSession([], d2, 'perm-bash', CAPTURES);
await P.waitReady(60000);
await P.type('Run the bash command `echo spike-s3-marker-xyz` and show me its output. Do not do anything else.');
const regP = await waitWaiting(P);
await sleep(1200); // laisse Ink finir de peindre
const screenP = P.tail(2500);
const optsP = extractOptions(screenP);
const respP = await respond(P, '1');
let completed = false;
try { await P.waitTurnDone(120000); completed = true; } catch {}
results.d2_permBash = {
waitingFor: regP.waitingFor ?? null,
options: optsP,
screenExcerpt: screenP.slice(-1100),
respond: respP,
turnCompleted: completed,
markerInOutput: P.plain().includes('spike-s3-marker-xyz'),
};
P.kill('SIGTERM');
save();
// ---------- D3 : permission Write (édition) → réponse "1" ----------
log('=== D3 permission Write ===');
const d3 = freshRepo('write');
const W = new ClaudeSession([], d3, 'perm-write', CAPTURES);
await W.waitReady(60000);
await W.type('Create a file named s3-edit.txt containing exactly the word hello. Do not do anything else.');
const regW = await waitWaiting(W);
await sleep(1200);
const screenW = W.tail(2500);
const respW = await respond(W, '1');
let completedW = false;
try { await W.waitTurnDone(120000); completedW = true; } catch {}
results.d3_permWrite = {
waitingFor: regW.waitingFor ?? null,
options: extractOptions(screenW),
screenExcerpt: screenW.slice(-1100),
respond: respW,
turnCompleted: completedW,
fileCreated: existsSync(join(d3, 's3-edit.txt')),
};
W.kill('SIGTERM');
save();
// ---------- D4 : refus par Esc ----------
log('=== D4 refus par Esc ===');
const d4 = freshRepo('deny');
const X = new ClaudeSession([], d4, 'deny-esc', CAPTURES);
await X.waitReady(60000);
await X.type('Run the bash command `echo should-be-denied` and show me its output. Do not do anything else.');
await waitWaiting(X);
await sleep(1200);
X.write('\x1b'); // Esc
await sleep(3500);
const regX = X.registry();
results.d4_denyEsc = {
statusAfterEsc: regX?.status,
denied: !X.plain().includes('should-be-denied\r\nshould-be-denied') /* marqueur non exécuté */,
screenTail: X.tail(900),
};
X.kill('SIGTERM');
save();
// ---------- D5 : AskUserQuestion ----------
log('=== D5 AskUserQuestion ===');
const d5 = freshRepo('ask');
const Q = new ClaudeSession([], d5, 'ask-question', CAPTURES);
await Q.waitReady(60000);
await Q.type('Use the AskUserQuestion tool to ask me whether I prefer red or blue. Offer exactly those two options.');
const regQ = await waitWaiting(Q);
await sleep(1200);
const screenQ = Q.tail(2500);
// Sur AskUserQuestion : essaie flèche bas + Entrée (sélection de la 2e option)
Q.write('\x1b[B'); await sleep(400); Q.write('\r');
await sleep(3000);
let regQ2 = Q.registry();
const arrowsWorked = regQ2?.status !== 'waiting';
let digitTried = null;
if (!arrowsWorked) { Q.write('2'); await sleep(2500); regQ2 = Q.registry(); digitTried = regQ2?.status; }
let completedQ = false;
try { await Q.waitTurnDone(90000); completedQ = true; } catch {}
results.d5_askUserQuestion = {
waitingFor: regQ.waitingFor ?? null,
options: extractOptions(screenQ),
screenExcerpt: screenQ.slice(-1100),
arrowsEnterWorked: arrowsWorked,
digitFallbackStatus: digitTried,
turnCompleted: completedQ,
};
Q.kill('SIGTERM');
save();
log('=== S3 dialogues terminé ===');
} catch (err) {
results.fatalError = String(err);
save();
log('ERREUR FATALE', { err: String(err) });
} finally {
save();
process.exit(0);
}