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,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;
}