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>
136 lines
6.0 KiB
JavaScript
136 lines
6.0 KiB
JavaScript
// 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'));
|
||
}
|