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

@@ -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();
}
}