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.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
190
spikes/s3-tui/run.mjs
Normal file
190
spikes/s3-tui/run.mjs
Normal file
@@ -0,0 +1,190 @@
|
||||
#!/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);
|
||||
}
|
||||
Reference in New Issue
Block a user