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

@@ -8,6 +8,7 @@ import { FLOW, REPLAY_TAIL_BYTES, type SessionSummary } from '@arboretum/shared'
import { RingBuffer } from './ring-buffer.js';
import { buildSpawnSpec } from './claude-launcher.js';
import { findByPid } from './session-registry.js';
import { SessionActivityTracker } from './claude-adapter.js';
import type { Db } from '../db/index.js';
const RING_CAPACITY = 2 * 1024 * 1024;
@@ -44,6 +45,8 @@ interface ManagedSession {
killTimer: NodeJS.Timeout | null;
/** ID interne du CLI claude, résolu via le registre après spawn (null pour bash / pas encore prêt). */
claudeSessionId: string | null;
/** détection d'état fin (busy/waiting/idle + dialogue) ; null pour bash (P3-B). */
tracker: SessionActivityTracker | null;
}
export interface PtyManagerEvents {
@@ -90,7 +93,14 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
exited: null,
killTimer: null,
claudeSessionId: null,
tracker: null,
};
// Détection d'état fin (P3-B) : uniquement pour claude (bash n'a pas de registre).
if (command === 'claude') {
session.tracker = new SessionActivityTracker(proc.pid, this.sessionsDir, () => {
if (!session.exited) this.emit('session_update', this.summarize(session));
});
}
this.live.set(id, session);
this.db
.prepare('INSERT INTO sessions (id, cwd, command, created_at, resumed_from) VALUES (?, ?, ?, ?, ?)')
@@ -212,7 +222,10 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
const hasController = [...s.clients].some((c) => c.controlling);
binding.controlling = binding.mode === 'interactive' && !hasController;
s.clients.add(binding);
if (binding.controlling) s.proc.resize(cols, rows);
if (binding.controlling) {
s.proc.resize(cols, rows);
s.tracker?.resize(cols, rows);
}
// Replay : reset terminal + queue du ring (l'écran TUI courant se reconstitue)
binding.sendResync(s.ring.tail(REPLAY_TAIL_BYTES));
binding.sentBytes = 0;
@@ -247,6 +260,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
const s = this.live.get(sessionId);
if (!s || s.exited || !binding.controlling) return;
s.proc.resize(cols, rows);
s.tracker?.resize(cols, rows);
}
ack(sessionId: string, binding: ClientBinding, bytes: number): void {
@@ -266,6 +280,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
private handleOutput(s: ManagedSession, chunk: Buffer): void {
s.ring.write(chunk);
s.tracker?.feed(chunk);
for (const c of s.clients) {
if (c.lagging) continue;
c.sendOutput(chunk);
@@ -302,6 +317,8 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
private handleExit(s: ManagedSession, exitCode: number | null, signal: number | null): void {
s.exited = { exitCode, signal };
s.tracker?.dispose();
s.tracker = null;
if (s.killTimer) clearTimeout(s.killTimer);
const endedAt = new Date().toISOString();
this.db.prepare('UPDATE sessions SET ended_at = ?, exit_code = ? WHERE id = ?').run(endedAt, exitCode, s.id);
@@ -313,6 +330,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
}
private summarize(s: ManagedSession): SessionSummary {
const act = s.tracker?.snapshot();
return {
id: s.id,
cwd: s.cwd,
@@ -330,7 +348,11 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
// une managée vivante ne se resume pas (corruption) ; une managée claude morte oui.
resumable: !!s.exited && s.command === 'claude' && s.claudeSessionId != null,
attachable: !s.exited,
registryStatus: null, // P2 : statut fin des managées via claude-adapter (P3-B)
// statut brut du registre dérivé de l'activité fine (P3-B) ; null pour bash.
registryStatus: act?.activity ?? null,
activity: act?.activity ?? null,
waitingFor: act?.waitingFor ?? null,
dialog: act?.dialog ?? null,
};
}
}