P3-B: claude-adapter — états fins busy/waiting/idle

Détection de l'état fin d'une session claude et du dialogue en cours, pour
savoir « quelle session attend une réponse ». Source primaire = registre
~/.claude/sessions (stable) ; l'écran reconstruit via @xterm/headless TYPE le
dialogue (trust/permission/question/plan) et couvre le cas Trust (avant registre).

- dép: @xterm/headless ^6.0.0 (aligné sur @xterm/xterm du front).
- core/screen-reader.ts: terminal headless persistant par session, alimenté
  par le flux PTY ; snapshotLines() préserve les espaces (bat le strip ANSI naïf).
- core/dialog-classifier.ts: typage pur (texte aplati + regex tolérante inter-
  versions) + extraction des options numérotées (❯ = sélection).
- core/claude-adapter.ts: SessionActivityTracker par session (registre + écran
  → activity + dialog), évaluation débouncée sur output + poll registre léger,
  émission sur changement effectif uniquement.
- shared: SessionSummary += activity?/waitingFor?/dialog? (optionnels, additif).
- pty-manager: tracker instancié pour claude, feed dans handleOutput, summarize
  enrichi, resize propagé, dispose à l'exit.
- web: composant SessionStateBadge (busy/waiting/idle colorés) réutilisable.
- tests: screen-reader + classifier sur fixtures réelles S3 (perm-write/ask/
  trust), claude-adapter (machine à états). 159 verts.
This commit is contained in:
2026-06-12 18:39:38 +02:00
parent 08320e166e
commit 8d963beaac
13 changed files with 421 additions and 10 deletions

View File

@@ -27,6 +27,7 @@
"@fastify/static": "^8.0.0",
"@fastify/websocket": "^11.0.0",
"@homebridge/node-pty-prebuilt-multiarch": "^0.13.0",
"@xterm/headless": "^6.0.0",
"fastify": "^5.0.0"
},
"devDependencies": {

View File

@@ -0,0 +1,90 @@
// claude-adapter : déduit l'état fin d'une session (busy / waiting / idle) + le dialogue typé.
// Source PRIMAIRE = le registre ~/.claude/sessions (status stable inter-versions) ; l'écran reconstruit
// (@xterm/headless) sert uniquement à TYPER le dialogue et à couvrir le cas Trust (qui précède le
// registre). Un tracker par session vive claude, alimenté par le flux PTY (pas de re-replay du ring).
import type { SessionActivity, SessionDialog } from '@arboretum/shared';
import { ScreenReader } from './screen-reader.js';
import { classifyDialog } from './dialog-classifier.js';
import { findByPid } from './session-registry.js';
const DEBOUNCE_MS = 200; // coalescence des rafales d'output avant ré-évaluation
const POLL_MS = 700; // capte les transitions busy↔idle qui n'émettent pas d'output discriminant
export interface AdapterState {
activity: SessionActivity | null;
waitingFor: string | null;
dialog: SessionDialog | null;
}
const EMPTY: AdapterState = { activity: null, waitingFor: null, dialog: null };
function sameState(a: AdapterState, b: AdapterState): boolean {
return a.activity === b.activity && a.waitingFor === b.waitingFor && JSON.stringify(a.dialog) === JSON.stringify(b.dialog);
}
export class SessionActivityTracker {
private readonly reader = new ScreenReader(120, 32);
private state: AdapterState = EMPTY;
private debounce: NodeJS.Timeout | null = null;
private readonly poll: NodeJS.Timeout;
private disposed = false;
constructor(
private readonly pid: number,
private readonly sessionsDir: string,
private readonly onChange: () => void,
) {
this.poll = setInterval(() => this.evaluate(), POLL_MS);
this.poll.unref();
}
feed(chunk: Uint8Array): void {
if (this.disposed) return;
void this.reader.feed(chunk);
if (this.debounce) return;
this.debounce = setTimeout(() => {
this.debounce = null;
this.evaluate();
}, DEBOUNCE_MS);
this.debounce.unref();
}
resize(cols: number, rows: number): void {
if (!this.disposed) this.reader.resize(cols, rows);
}
snapshot(): AdapterState {
return this.state;
}
/** Évalue l'état courant (debounce → snapshot fiable). Public pour les tests déterministes. */
evaluate(): void {
if (this.disposed) return;
const reg = findByPid(this.sessionsDir, this.pid);
const next = this.derive(reg?.status ?? null, reg?.waitingFor ?? null);
if (!sameState(this.state, next)) {
this.state = next;
this.onChange();
}
}
private derive(status: 'busy' | 'idle' | 'waiting' | null, waitingFor: string | null): AdapterState {
if (status === 'busy') return { activity: 'busy', waitingFor: null, dialog: null };
if (status === 'idle') return { activity: 'idle', waitingFor: null, dialog: null };
if (status === 'waiting') {
const c = classifyDialog(this.reader.snapshotLines());
return { activity: 'waiting', waitingFor, dialog: c ? { kind: c.kind, waitingFor, options: c.options } : null };
}
// Pas (encore) de registre : seul le dialogue Trust le précède (spike S1).
const c = classifyDialog(this.reader.snapshotLines());
if (c?.kind === 'trust') return { activity: 'waiting', waitingFor: 'trust', dialog: { kind: 'trust', waitingFor: 'trust', options: c.options } };
return EMPTY;
}
dispose(): void {
this.disposed = true;
if (this.debounce) clearTimeout(this.debounce);
clearInterval(this.poll);
this.reader.dispose();
}
}

View File

@@ -0,0 +1,46 @@
// Typage de dialogue à partir de l'écran reconstruit (ScreenReader) — fonctions PURES.
// L'écran sert à TYPER le dialogue et extraire ses options ; l'état (waiting vrai/faux) vient du
// registre (source primaire). Tolérant aux variations de rendu inter-versions (texte aplati + regex).
import type { DialogKind, DialogOption } from '@arboretum/shared';
export interface ClassifiedDialog {
kind: DialogKind;
options: DialogOption[];
}
// Option numérotée, éventuellement préfixée du curseur : « 1. Yes », « 2) No »…
const OPTION_RE = /^\s*()?\s*(\d+)[.)]\s+(.*\S)\s*$/;
export function parseOptions(lines: string[]): DialogOption[] {
const out: DialogOption[] = [];
for (const line of lines) {
const m = OPTION_RE.exec(line);
if (m && m[2] && m[3]) out.push({ n: Number(m[2]), label: m[3].trim(), selected: Boolean(m[1]) });
}
return out;
}
/**
* Retourne le dialogue typé visible à l'écran, ou null si aucun. `trust` et `question` priment sur
* `permission` (un écran AskUserQuestion contient aussi « Esc to cancel »). Un écran numéroté non
* typé est traité en `permission` générique (best-effort — le fallback reste le terminal web).
*/
export function classifyDialog(lines: string[]): ClassifiedDialog | null {
const text = lines.join('\n');
const flat = text.replace(/\s+/g, '').toLowerCase();
const options = parseOptions(lines);
const isTrust = flat.includes('trust') && (text.includes('') || flat.includes('entertoconfirm') || flat.includes('trustthisfolder'));
const isQuestion = flat.includes('entertoselect') || flat.includes('tonavigate') || text.includes('↑/↓');
const isPermission = flat.includes('doyouwant') || flat.includes('esctocancel') || flat.includes('tabtoamend');
const isPlan = flat.includes('readytocode') || flat.includes('wouldyouliketoproceed');
let kind: DialogKind | null = null;
if (isTrust) kind = 'trust';
else if (isQuestion) kind = 'question';
else if (isPlan) kind = 'plan';
else if (isPermission) kind = 'permission';
else if (options.length > 0) kind = 'permission';
return kind ? { kind, options } : null;
}

View File

@@ -8,6 +8,7 @@ import { FLOW, REPLAY_TAIL_BYTES, type SessionSummary } from '@arboretum/shared'
import { RingBuffer } from './ring-buffer.js';
import { buildSpawnSpec } from './claude-launcher.js';
import { findByPid } from './session-registry.js';
import { SessionActivityTracker } from './claude-adapter.js';
import type { Db } from '../db/index.js';
const RING_CAPACITY = 2 * 1024 * 1024;
@@ -44,6 +45,8 @@ interface ManagedSession {
killTimer: NodeJS.Timeout | null;
/** ID interne du CLI claude, résolu via le registre après spawn (null pour bash / pas encore prêt). */
claudeSessionId: string | null;
/** détection d'état fin (busy/waiting/idle + dialogue) ; null pour bash (P3-B). */
tracker: SessionActivityTracker | null;
}
export interface PtyManagerEvents {
@@ -90,7 +93,14 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
exited: null,
killTimer: null,
claudeSessionId: null,
tracker: null,
};
// Détection d'état fin (P3-B) : uniquement pour claude (bash n'a pas de registre).
if (command === 'claude') {
session.tracker = new SessionActivityTracker(proc.pid, this.sessionsDir, () => {
if (!session.exited) this.emit('session_update', this.summarize(session));
});
}
this.live.set(id, session);
this.db
.prepare('INSERT INTO sessions (id, cwd, command, created_at, resumed_from) VALUES (?, ?, ?, ?, ?)')
@@ -212,7 +222,10 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
const hasController = [...s.clients].some((c) => c.controlling);
binding.controlling = binding.mode === 'interactive' && !hasController;
s.clients.add(binding);
if (binding.controlling) s.proc.resize(cols, rows);
if (binding.controlling) {
s.proc.resize(cols, rows);
s.tracker?.resize(cols, rows);
}
// Replay : reset terminal + queue du ring (l'écran TUI courant se reconstitue)
binding.sendResync(s.ring.tail(REPLAY_TAIL_BYTES));
binding.sentBytes = 0;
@@ -247,6 +260,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
const s = this.live.get(sessionId);
if (!s || s.exited || !binding.controlling) return;
s.proc.resize(cols, rows);
s.tracker?.resize(cols, rows);
}
ack(sessionId: string, binding: ClientBinding, bytes: number): void {
@@ -266,6 +280,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
private handleOutput(s: ManagedSession, chunk: Buffer): void {
s.ring.write(chunk);
s.tracker?.feed(chunk);
for (const c of s.clients) {
if (c.lagging) continue;
c.sendOutput(chunk);
@@ -302,6 +317,8 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
private handleExit(s: ManagedSession, exitCode: number | null, signal: number | null): void {
s.exited = { exitCode, signal };
s.tracker?.dispose();
s.tracker = null;
if (s.killTimer) clearTimeout(s.killTimer);
const endedAt = new Date().toISOString();
this.db.prepare('UPDATE sessions SET ended_at = ?, exit_code = ? WHERE id = ?').run(endedAt, exitCode, s.id);
@@ -313,6 +330,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
}
private summarize(s: ManagedSession): SessionSummary {
const act = s.tracker?.snapshot();
return {
id: s.id,
cwd: s.cwd,
@@ -330,7 +348,11 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
// une managée vivante ne se resume pas (corruption) ; une managée claude morte oui.
resumable: !!s.exited && s.command === 'claude' && s.claudeSessionId != null,
attachable: !s.exited,
registryStatus: null, // P2 : statut fin des managées via claude-adapter (P3-B)
// statut brut du registre dérivé de l'activité fine (P3-B) ; null pour bash.
registryStatus: act?.activity ?? null,
activity: act?.activity ?? null,
waitingFor: act?.waitingFor ?? null,
dialog: act?.dialog ?? null,
};
}
}

View File

@@ -0,0 +1,39 @@
// Reconstruction d'écran via @xterm/headless : terminal headless PERSISTANT par session, alimenté
// incrémentalement par le flux PTY. Remplace le strip ANSI naïf (qui « mange les espaces » et casse
// la détection des dialogues — verdict S1/S3). Aucune dépendance DOM (usage Node).
import { Terminal } from '@xterm/headless';
export class ScreenReader {
private readonly term: Terminal;
constructor(cols = 120, rows = 40) {
this.term = new Terminal({ cols, rows, scrollback: 0, allowProposedApi: true });
}
/**
* Alimente le terminal avec un chunk PTY brut (la frontière UTF-8 et les séquences ANSI coupées
* sont gérées par xterm). La promesse se résout quand le chunk a été parsé (snapshot fiable ensuite).
*/
feed(chunk: Uint8Array | string): Promise<void> {
return new Promise((resolve) => this.term.write(chunk as Uint8Array, resolve));
}
resize(cols: number, rows: number): void {
this.term.resize(cols, rows);
}
/** Lignes visibles du viewport courant, espaces préservés (trim à droite uniquement). */
snapshotLines(): string[] {
const buf = this.term.buffer.active;
const lines: string[] = [];
for (let y = 0; y < this.term.rows; y++) {
const line = buf.getLine(buf.baseY + y);
lines.push(line ? line.translateToString(true) : '');
}
return lines;
}
dispose(): void {
this.term.dispose();
}
}

View File

@@ -0,0 +1,74 @@
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { SessionActivityTracker } from '../src/core/claude-adapter.js';
const PID = 999_999;
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
function writeRegistry(dir: string, status: string, waitingFor?: string): void {
writeFileSync(
join(dir, `${PID}.json`),
JSON.stringify({ pid: PID, procStart: '123', sessionId: 'sid', cwd: '/x', status, ...(waitingFor ? { waitingFor } : {}) }),
);
}
describe('SessionActivityTracker', () => {
let dir: string;
let tracker: SessionActivityTracker;
let changes: number;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'arb-adapter-'));
changes = 0;
tracker = new SessionActivityTracker(PID, dir, () => {
changes++;
});
});
afterEach(() => {
tracker.dispose();
rmSync(dir, { recursive: true, force: true });
});
it('registre busy → idle : transitions dactivité + onChange sur changement uniquement', () => {
writeRegistry(dir, 'busy');
tracker.evaluate();
expect(tracker.snapshot().activity).toBe('busy');
expect(changes).toBe(1);
tracker.evaluate(); // rien n'a changé
expect(changes).toBe(1);
writeRegistry(dir, 'idle');
tracker.evaluate();
expect(tracker.snapshot().activity).toBe('idle');
expect(changes).toBe(2);
});
it('registre waiting + écran permission → activity waiting, dialog typé permission', async () => {
await tracker['reader'].feed('\x1b[2J\x1b[HDo you want to create x.txt?\r\n 1. Yes\r\n2. No\r\nEsc to cancel · Tab to amend\r\n');
await sleep(30);
writeRegistry(dir, 'waiting', 'permission prompt');
tracker.evaluate();
const s = tracker.snapshot();
expect(s.activity).toBe('waiting');
expect(s.waitingFor).toBe('permission prompt');
expect(s.dialog?.kind).toBe('permission');
expect(s.dialog?.options.find((o) => o.n === 1)).toMatchObject({ selected: true });
});
it('pas de registre + écran Trust → waiting/trust (le Trust précède le registre)', async () => {
await tracker['reader'].feed('\x1b[2J\x1b[HDo you trust the files in this folder?\r\n 1. Yes, I trust this folder\r\n2. No, exit\r\n');
await sleep(30);
tracker.evaluate();
const s = tracker.snapshot();
expect(s.activity).toBe('waiting');
expect(s.dialog?.kind).toBe('trust');
});
it('pas de registre + pas de dialogue → état vide (inconnu)', () => {
tracker.evaluate();
expect(tracker.snapshot()).toMatchObject({ activity: null, dialog: null });
});
});

View File

@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { ScreenReader } from '../src/core/screen-reader.js';
import { classifyDialog, parseOptions, type ClassifiedDialog } from '../src/core/dialog-classifier.js';
const capturesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'spikes', 's3-tui', 'captures');
/** Rejoue un flux PTY brut par segments (comme l'adapter en prod) et collecte écran+dialogue détecté. */
async function replay(fixture: string): Promise<Array<{ dialog: ClassifiedDialog; text: string }>> {
const data = readFileSync(join(capturesDir, fixture));
const reader = new ScreenReader(120, 40);
const seen: Array<{ dialog: ClassifiedDialog; text: string }> = [];
const STEP = 512;
for (let i = 0; i < data.length; i += STEP) {
await reader.feed(data.subarray(i, i + STEP)); // xterm gère les séquences coupées en frontière
const lines = reader.snapshotLines();
const d = classifyDialog(lines);
if (d) seen.push({ dialog: d, text: lines.join('\n') });
}
reader.dispose();
return seen;
}
describe('ScreenReader (@xterm/headless) — anti strip-ANSI naïf', () => {
it('préserve les espaces du dialogue (« Do you want to create », pas « Doyouwant »)', async () => {
const seen = await replay('perm-write2.raw.log');
expect(seen.length).toBeGreaterThan(0);
expect(seen.some((s) => /Do you want to create/.test(s.text))).toBe(true);
});
});
describe('classifyDialog — unitaire (lignes synthétiques)', () => {
it('trust', () => {
const d = classifyDialog(['Do you trust the files in this folder?', ' 1. Yes, I trust this folder', '2. No, exit']);
expect(d?.kind).toBe('trust');
expect(d?.options).toHaveLength(2);
expect(d?.options[0]).toMatchObject({ n: 1, selected: true });
});
it('permission', () => {
const d = classifyDialog(['Do you want to create s3-edit.txt?', ' 1. Yes', '3. No', 'Esc to cancel · Tab to amend']);
expect(d?.kind).toBe('permission');
});
it('question (prime sur permission malgré « Esc to cancel »)', () => {
const d = classifyDialog(['☐ Couleur', ' 1. Rouge', '2. Bleu', 'Enter to select · ↑/↓ to navigate · Esc to cancel']);
expect(d?.kind).toBe('question');
});
it('aucun dialogue → null', () => {
expect(classifyDialog(['just some output', 'no options here'])).toBeNull();
});
it('parseOptions tolère « 2) Label » et trim', () => {
expect(parseOptions([' 2) Yes, allow all '])).toEqual([{ n: 2, label: 'Yes, allow all', selected: true }]);
});
});
describe('détection sur fixtures réelles S3 (replay headless)', () => {
it('perm-write2 → permission avec options Yes/…/No, option 1 sélectionnée', async () => {
const perms = (await replay('perm-write2.raw.log')).filter((s) => s.dialog.kind === 'permission');
expect(perms.length).toBeGreaterThan(0);
const withOpts = perms.find((s) => s.dialog.options.length >= 2);
expect(withOpts).toBeDefined();
const labels = withOpts!.dialog.options.map((o) => o.label).join(' | ');
expect(labels).toMatch(/Yes/);
expect(labels).toMatch(/No/);
expect(withOpts!.dialog.options.find((o) => o.n === 1)?.selected).toBe(true);
});
it('ask2 → question avec une option « Rouge »', async () => {
const qs = (await replay('ask2.raw.log')).filter((s) => s.dialog.kind === 'question');
expect(qs.length).toBeGreaterThan(0);
expect(qs.some((s) => s.dialog.options.some((o) => /Rouge/.test(o.label)))).toBe(true);
});
it('trust → dialogue trust détecté', async () => {
const seen = await replay('trust.raw.log');
expect(seen.some((s) => s.dialog.kind === 'trust')).toBe(true);
});
});

File diff suppressed because one or more lines are too long