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. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
90
packages/server/src/core/claude-adapter.ts
Normal file
90
packages/server/src/core/claude-adapter.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user