Files
arboretum/spikes/s1-resume/harness.mjs
Johan LEROY 66dc21bf91 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>
2026-06-11 17:47:05 +02:00

136 lines
6.0 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.
// Spike S1 — harnais commun : spawn de claude dans un PTY + observation registre/JSONL
import ptyMod from '@homebridge/node-pty-prebuilt-multiarch';
import { readdirSync, readFileSync, existsSync, mkdirSync, appendFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
export const SESSIONS_DIR = join(homedir(), '.claude', 'sessions');
export const PROJECTS_DIR = join(homedir(), '.claude', 'projects');
export const munge = (p) => p.replace(/[^A-Za-z0-9]/g, '-');
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const ANSI = /\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][AB0-2]|\x1b[=>]|[\x00-\x08\x0b-\x1f]/g;
export const stripAnsi = (s) => s.replace(ANSI, '');
export function log(step, data = {}) {
const line = JSON.stringify({ t: new Date().toISOString(), step, ...data });
console.log(line);
appendFileSync(LOG_FILE, line + '\n');
}
let LOG_FILE = '/tmp/s1.log';
export function setLogFile(p) { LOG_FILE = p; }
export class ClaudeSession {
constructor(args, cwd, label, capturesDir) {
mkdirSync(capturesDir, { recursive: true });
this.label = label;
this.rawPath = join(capturesDir, `${label}.raw.log`);
this.buf = '';
this.pty = ptyMod.spawn('claude', args, {
name: 'xterm-256color', cols: 120, rows: 32, cwd,
env: { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor' },
});
this.pid = this.pty.pid;
this.exited = null;
this.pty.onData((d) => { this.buf += d; appendFileSync(this.rawPath, d); });
this.pty.onExit((e) => { this.exited = e; });
}
write(s) { this.pty.write(s); }
// Tape un prompt puis Entrée (séparés : le CLI traite les écritures multi-caractères comme un collage)
async type(text) { this.write(text); await sleep(400); this.write('\r'); }
plain() { return stripAnsi(this.buf); }
tail(n = 1200) { return this.plain().slice(-n); }
registry() {
if (!existsSync(SESSIONS_DIR)) return null;
for (const f of readdirSync(SESSIONS_DIR)) {
if (!f.endsWith('.json')) continue;
try {
const r = JSON.parse(readFileSync(join(SESSIONS_DIR, f), 'utf8'));
if (r.pid === this.pid) return { file: f, ...r };
} catch { /* fichier en cours d'écriture */ }
}
return null;
}
async until(desc, fn, timeoutMs = 60000, everyMs = 400) {
const t0 = Date.now();
while (Date.now() - t0 < timeoutMs) {
const v = await fn();
if (v) { log(`${this.label}: ${desc}`, { afterMs: Date.now() - t0 }); return v; }
await sleep(everyMs);
}
log(`${this.label}: TIMEOUT — ${desc}`, { timeoutMs, screenTail: this.tail(600) });
throw new Error(`timeout: ${this.label} ${desc}`);
}
// Démarrage : gère le trust dialog (précède l'inscription au registre) puis attend le registre.
// NB : le TUI peint avec des déplacements de curseur, pas des espaces → matcher sur texte SANS espaces.
async waitReady(timeoutMs = 90000) {
const t0 = Date.now();
let trustHandled = false;
while (Date.now() - t0 < timeoutMs) {
const reg = this.registry();
if (reg) { log(`${this.label}: prêt (registre)`, { sessionId: reg.sessionId, status: reg.status, trustHandled }); return reg; }
const flat = this.tail(2500).replace(/\s+/g, '');
if (!trustHandled && /trust/i.test(flat) && /(|Entertoconfirm)/i.test(flat)) {
log(`${this.label}: trust dialog détecté → Entrée`, { screenTail: this.tail(500) });
this.write('\r');
trustHandled = true;
}
await sleep(400);
}
log(`${this.label}: TIMEOUT waitReady`, { screenTail: this.tail(1500) });
throw new Error(`timeout: ${this.label} waitReady`);
}
// Attend la fin d'un tour : busy observé (ou déjà passé) puis retour à idle/waiting
async waitTurnDone(timeoutMs = 150000) {
let sawBusy = false;
await this.until('tour terminé', () => {
const r = this.registry();
if (!r) return false;
if (r.status === 'busy') { sawBusy = true; return false; }
return sawBusy && r.status !== 'busy' ? r : false;
}, timeoutMs, 500);
return this.registry();
}
kill(signal = 'SIGTERM') { try { process.kill(this.pid, signal); } catch {} }
}
export function registrySnapshot() {
if (!existsSync(SESSIONS_DIR)) return [];
return readdirSync(SESSIONS_DIR).filter((f) => f.endsWith('.json')).map((f) => {
try {
const r = JSON.parse(readFileSync(join(SESSIONS_DIR, f), 'utf8'));
let alive = false, procStartOk = null;
try {
const stat = readFileSync(`/proc/${r.pid}/stat`, 'utf8');
const starttime = stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19];
alive = true; procStartOk = String(r.procStart) === String(starttime);
} catch {}
return { file: f, pid: r.pid, sessionId: r.sessionId, status: r.status, waitingFor: r.waitingFor ?? null, alive, procStartOk };
} catch (e) { return { file: f, error: e.message }; }
});
}
export function jsonlInfo(cwd, sessionId) {
const p = join(PROJECTS_DIR, munge(cwd), `${sessionId}.jsonl`);
if (!existsSync(p)) return { path: p, exists: false };
const lines = readFileSync(p, 'utf8').split('\n').filter((l) => l.trim());
const objs = [];
for (const l of lines) { try { objs.push(JSON.parse(l)); } catch {} }
const users = objs.filter((o) => o.type === 'user' && typeof o.message?.content === 'string');
const userTexts = users.map((o) => o.message.content.slice(0, 80));
const uuids = new Map(objs.filter((o) => o.uuid).map((o) => [o.uuid, o]));
let brokenParents = 0;
for (const o of objs) if (o.parentUuid && !uuids.has(o.parentUuid)) brokenParents++;
return {
path: p, exists: true, mtimeMs: statSync(p).mtimeMs, lines: lines.length,
userTexts, brokenParents,
sessionIds: [...new Set(objs.map((o) => o.sessionId).filter(Boolean))],
};
}
export function listJsonl(cwd) {
const dir = join(PROJECTS_DIR, munge(cwd));
if (!existsSync(dir)) return [];
return readdirSync(dir).filter((f) => f.endsWith('.jsonl'));
}