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