Spike S3: TUI dialogs, keystrokes — GO (documented partial)

Response protocol: digit positions + Enter confirms (digit alone is not
enough); arrows+Enter work everywhere. Registry detects waiting state
for both permission and AskUserQuestion dialogs (same waitingFor label
— fine-grained dialog typing needs screen reading). Sandboxed bash runs
no-prompt for most commands: real waits come from edits/network/
questions. Pre-trust via ~/.claude.json projects[dir]
.hasTrustDialogAccepted captured and plausible. Esc-deny and plan
approval deferred to P4 reliability campaign.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 17:58:28 +02:00
parent 66dc21bf91
commit 8623671c69
2 changed files with 198 additions and 0 deletions

32
spikes/s3-tui/VERDICT.md Normal file
View File

@@ -0,0 +1,32 @@
# Spike S3 — dialogues TUI, frappes, flood — VERDICT : ✅ GO (partiel documenté)
Exécuté le 2026-06-11 (CLI 2.1.173). Scripts : `run.mjs` (passe 1), `run2.mjs` (passe 2), `flood.mjs`. Résultats bruts dans `captures/`.
## Dialogues testés
| Dialogue | Détection | Options extraites | Réponse validée |
|---|---|---|---|
| **Trust dialog** (nouveau dossier) | écran seul (précède le registre) — motif `trust` + `` sur texte aplati | `1. Yes, I trust this folder / 2. No, exit` — « Enter to confirm · Esc to cancel » | **Entrée** ✅ (10+ occurrences via S1/S3) |
| **Permission Write** (création/édition de fichier) | registre `status: waiting`, `waitingFor: "permission prompt"` + écran | `1. Yes / 2. Yes, allow all edits during this session (shift+tab) / 3. No` — « Esc to cancel · Tab to amend » | chiffre seul **insuffisant** ; **chiffre puis Entrée** ✅ (fichier créé) |
| **AskUserQuestion** | registre `waiting` (même `waitingFor: "permission prompt"` — le registre ne distingue pas les types) | `1. Rouge / 2. Bleu / 3. Type something. / 4. Chat about this` — « Enter to select · ↑/↓ to navigate · Esc to cancel » | **↓ + Entrée** ✅ (tour complété, bonne option choisie) |
| **Permission Bash** | **non déclenchée** : `echo` est dans l'allowlist par défaut ET `node -e` s'exécute dans le **bash sandboxé** du CLI 2.x sans prompt | — | — |
| **Refus par Esc** | non exercé (quota) ; « Esc to cancel » affiché sur les deux dialogues observés | — | à valider en P4 |
## Enseignements structurants
1. **Le protocole de réponse est : (chiffre pour positionner) + Entrée pour confirmer.** Entrée seule valide l'option pré-sélectionnée (``). Flèches ↑/↓ + Entrée fonctionnent partout. → L'UI mobile enverra `<chiffre>` + `\r`.
2. **Le registre `~/.claude/sessions` détecte les états d'attente des dialogues outils** (permission ET AskUserQuestion) avec le même `waitingFor: "permission prompt"` → le typage fin du dialogue (question vs permission, options) exige la lecture d'écran (`@xterm/headless` en prod ; le strip ANSI naïf mange les espaces — voir VERDICT S1).
3. **Il y a moins de prompts de permission que prévu** : le bash sandboxé exécute sans prompt les commandes sans réseau/écriture hors workspace. Les vrais `waiting` viennent des éditions de fichiers, du réseau, d'AskUserQuestion et des plans. Bonne nouvelle pour l'UX (moins d'interruptions), à intégrer aux fixtures P4.
4. **Pré-trust programmatique plausible** : accepter le dialogue écrit `projects["<dir>"].hasTrustDialogAccepted: true` dans `~/.claude.json` (structure complète capturée). À la création d'un worktree, écrire cette entrée (avec consentement explicite dans la modal) éviterait le dialogue — à valider en P2/P3.
5. La regex d'options `^\s*(\s*)?(\d+)\.\s*(.+)$` fonctionne sur le texte aplati (labels concaténés mais exploitables).
## Volet flood (node-pty pause/resume) — ✅ GO complet
21,5 Mo streamés ; pause de 10 s → **0 octet** reçu pendant la pause, bash survit, flux complet après resume (marqueur final intact, aucun octet perdu) ; écho 4,7 ms après le flood ; ~21 Mo en ~0,7 s hors pause.
## Reste à faire (P4, avant les boutons mobiles)
- Exercer le refus par Esc et le dialogue « No, and tell Claude… » (champ texte).
- Approbation de plan (plan mode) non testée.
- Campagne de fiabilité 10/10 par type de dialogue (le critère complet du spike) — à automatiser comme tests d'intégration du claude-adapter.
- Le MVP (P3) n'a PAS besoin de tout ça : les boutons d'approbation sont en P4 ; le fallback « ouvrir le terminal » couvre tous les cas.

166
spikes/s3-tui/run2.mjs Normal file
View File

@@ -0,0 +1,166 @@
#!/usr/bin/env node
// Spike S3 (2e passe) — D2..D5 avec commandes hors allowlist, scénarios isolés,
// détection registre + fallback écran. D1 (trust) déjà validé en 1re passe.
import { execSync } from 'node:child_process';
import { mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs';
import { join } from 'node:path';
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, 'steps2.jsonl'));
const results = {};
const save = () => writeFileSync(join(CAPTURES, 'results2.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;
}
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;
}
// Attente d'un dialogue : registre 'waiting' OU motif de dialogue à l'écran (texte aplati sans espaces)
async function waitDialog(S, timeoutMs = 120000) {
return S.until('dialogue détecté', () => {
const r = S.registry();
const flat = S.tail(2500).replace(/\s+/g, '');
const screenDialog = /1\./.test(flat) && /(Doyouwant|permission|Allow|autoris)/i.test(flat);
if (r?.status === 'waiting' || screenDialog) {
return { registryStatus: r?.status ?? null, waitingFor: r?.waitingFor ?? null, screenDialog };
}
return false;
}, timeoutMs, 400);
}
async function respond(S, digit) {
const stillWaiting = () => {
const r = S.registry();
const flat = S.tail(2000).replace(/\s+/g, '');
return (r?.status === 'waiting') || /\d+\./.test(flat) && /(Doyouwant|permission|Allow)/i.test(flat);
};
const tries = [];
S.write(digit);
await sleep(2500);
tries.push({ method: `digit ${digit}`, dialogGone: !stillWaiting() });
if (tries.at(-1).dialogGone) return { ok: true, tries };
S.write('\r');
await sleep(2500);
tries.push({ method: 'enter', dialogGone: !stillWaiting() });
if (tries.at(-1).dialogGone) 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);
tries.push({ method: `arrows x${downs} + enter`, dialogGone: !stillWaiting() });
return { ok: tries.at(-1).dialogGone, tries };
}
async function scenario(name, fn) {
log(`=== ${name} ===`);
try { results[name] = await fn(); }
catch (err) { results[name] = { error: String(err) }; log(`${name}: ÉCHEC`, { err: String(err) }); }
save();
}
// ---------- D2 : permission Bash (node -e, hors allowlist) → approbation "1" ----------
await scenario('d2_permBash', async () => {
const d = freshRepo('bash2');
const S = new ClaudeSession([], d, 'perm-bash2', CAPTURES);
try {
await S.waitReady();
await S.type(`Run this exact bash command and show me its output: node -e "console.log('spike-s3-marker-xyz')" — do not do anything else.`);
const det = await waitDialog(S);
await sleep(1200);
const screen = S.tail(2500);
const resp = await respond(S, '1');
let completed = false;
try { await S.waitTurnDone(120000); completed = true; } catch {}
return {
detection: det, options: extractOptions(screen), screenExcerpt: screen.slice(-1100),
respond: resp, turnCompleted: completed,
markerInOutput: S.plain().includes('spike-s3-marker-xyz'),
};
} finally { S.kill('SIGTERM'); }
});
// ---------- D3 : permission Write (création de fichier) → approbation ----------
await scenario('d3_permWrite', async () => {
const d = freshRepo('write2');
const S = new ClaudeSession([], d, 'perm-write2', CAPTURES);
try {
await S.waitReady();
await S.type('Create a file named s3-edit.txt containing exactly the word hello. Use the Write tool. Do not do anything else.');
const det = await waitDialog(S);
await sleep(1200);
const screen = S.tail(2500);
const resp = await respond(S, '1');
let completed = false;
try { await S.waitTurnDone(120000); completed = true; } catch {}
return {
detection: det, options: extractOptions(screen), screenExcerpt: screen.slice(-1100),
respond: resp, turnCompleted: completed, fileCreated: existsSync(join(d, 's3-edit.txt')),
};
} finally { S.kill('SIGTERM'); }
});
// ---------- D4 : refus par Esc ----------
await scenario('d4_denyEsc', async () => {
const d = freshRepo('deny2');
const S = new ClaudeSession([], d, 'deny-esc2', CAPTURES);
try {
await S.waitReady();
await S.type(`Run this exact bash command and show me its output: node -e "console.log('should-be-denied')" — do not do anything else.`);
await waitDialog(S);
await sleep(1200);
const screenBefore = S.tail(1200);
S.write('\x1b');
await sleep(4000);
const reg = S.registry();
return {
screenBeforeEsc: screenBefore.slice(-800),
statusAfterEsc: reg?.status ?? null,
screenAfterEsc: S.tail(900),
commandExecuted: /should-be-denied\s*$/m.test(S.plain().replace(/\s+/g, ' ')),
};
} finally { S.kill('SIGTERM'); }
});
// ---------- D5 : AskUserQuestion ----------
await scenario('d5_askUserQuestion', async () => {
const d = freshRepo('ask2');
const S = new ClaudeSession([], d, 'ask2', CAPTURES);
try {
await S.waitReady();
await S.type('Use the AskUserQuestion tool to ask me whether I prefer red or blue. Offer exactly those two options.');
const det = await waitDialog(S);
await sleep(1200);
const screen = S.tail(2500);
// 2e option : flèche bas + Entrée d'abord (hypothèse sélecteur), chiffre en fallback
S.write('\x1b[B'); await sleep(400); S.write('\r');
await sleep(3000);
let reg = S.registry();
const arrowsWorked = reg?.status !== 'waiting';
let digitFallback = null;
if (!arrowsWorked) { S.write('2'); await sleep(2500); reg = S.registry(); digitFallback = reg?.status; }
let completed = false;
try { await S.waitTurnDone(90000); completed = true; } catch {}
return {
detection: det, options: extractOptions(screen), screenExcerpt: screen.slice(-1100),
arrowsEnterWorked: arrowsWorked, digitFallbackStatus: digitFallback, turnCompleted: completed,
finalScreen: S.tail(700),
};
} finally { S.kill('SIGTERM'); }
});
log('=== S3 passe 2 terminée ===');
process.exit(0);