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.
91 lines
3.4 KiB
TypeScript
91 lines
3.4 KiB
TypeScript
// 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();
|
|
}
|
|
}
|