Files
arboretum/spikes/s1-resume/harness.mjs
Johan LEROY 65ef616867
Some checks failed
CI / Build & test (Node 24) (push) Has been cancelled
CI / Pack & boot smoke (Node 22) (push) Has been cancelled
CI / No em/en dashes (push) Has been cancelled
CI / Build & test (Node 22) (push) Has been cancelled
Deploy site (production) / build-and-deploy (push) Successful in 20s
chore(typo): retire tous les tirets cadratins/demi-cadratins + garde CI
Remplace les 547 tirets cadratins (U+2014) et demi-cadratins (U+2013) des fichiers versionnés par la ponctuation contextuelle adaptée (point médian, deux-points, virgule, parenthèses ; tiret simple pour les plages), sur 122 fichiers (appli, vitrine, doc, tests, workflows, scripts).

Ajoute le job CI « lint-dashes » (git grep -P) qui échoue si un tiret cadratin/demi-cadratin réapparaît, hors logo binaire et captures brutes du terminal (fidélité des fixtures de détection de dialogue).
2026-07-17 16:44:00 +02:00

136 lines
5.9 KiB
JavaScript
Raw Permalink 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'));
}