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:
10
package-lock.json
generated
10
package-lock.json
generated
@@ -2217,6 +2217,15 @@
|
|||||||
"integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==",
|
"integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@xterm/headless": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"addons/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/@xterm/xterm": {
|
"node_modules/@xterm/xterm": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
||||||
@@ -4743,6 +4752,7 @@
|
|||||||
"@fastify/static": "^8.0.0",
|
"@fastify/static": "^8.0.0",
|
||||||
"@fastify/websocket": "^11.0.0",
|
"@fastify/websocket": "^11.0.0",
|
||||||
"@homebridge/node-pty-prebuilt-multiarch": "^0.13.0",
|
"@homebridge/node-pty-prebuilt-multiarch": "^0.13.0",
|
||||||
|
"@xterm/headless": "^6.0.0",
|
||||||
"fastify": "^5.0.0"
|
"fastify": "^5.0.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
"@fastify/static": "^8.0.0",
|
"@fastify/static": "^8.0.0",
|
||||||
"@fastify/websocket": "^11.0.0",
|
"@fastify/websocket": "^11.0.0",
|
||||||
"@homebridge/node-pty-prebuilt-multiarch": "^0.13.0",
|
"@homebridge/node-pty-prebuilt-multiarch": "^0.13.0",
|
||||||
|
"@xterm/headless": "^6.0.0",
|
||||||
"fastify": "^5.0.0"
|
"fastify": "^5.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
46
packages/server/src/core/dialog-classifier.ts
Normal file
46
packages/server/src/core/dialog-classifier.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { FLOW, REPLAY_TAIL_BYTES, type SessionSummary } from '@arboretum/shared'
|
|||||||
import { RingBuffer } from './ring-buffer.js';
|
import { RingBuffer } from './ring-buffer.js';
|
||||||
import { buildSpawnSpec } from './claude-launcher.js';
|
import { buildSpawnSpec } from './claude-launcher.js';
|
||||||
import { findByPid } from './session-registry.js';
|
import { findByPid } from './session-registry.js';
|
||||||
|
import { SessionActivityTracker } from './claude-adapter.js';
|
||||||
import type { Db } from '../db/index.js';
|
import type { Db } from '../db/index.js';
|
||||||
|
|
||||||
const RING_CAPACITY = 2 * 1024 * 1024;
|
const RING_CAPACITY = 2 * 1024 * 1024;
|
||||||
@@ -44,6 +45,8 @@ interface ManagedSession {
|
|||||||
killTimer: NodeJS.Timeout | null;
|
killTimer: NodeJS.Timeout | null;
|
||||||
/** ID interne du CLI claude, résolu via le registre après spawn (null pour bash / pas encore prêt). */
|
/** ID interne du CLI claude, résolu via le registre après spawn (null pour bash / pas encore prêt). */
|
||||||
claudeSessionId: string | null;
|
claudeSessionId: string | null;
|
||||||
|
/** détection d'état fin (busy/waiting/idle + dialogue) ; null pour bash (P3-B). */
|
||||||
|
tracker: SessionActivityTracker | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PtyManagerEvents {
|
export interface PtyManagerEvents {
|
||||||
@@ -90,7 +93,14 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
exited: null,
|
exited: null,
|
||||||
killTimer: null,
|
killTimer: null,
|
||||||
claudeSessionId: 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.live.set(id, session);
|
||||||
this.db
|
this.db
|
||||||
.prepare('INSERT INTO sessions (id, cwd, command, created_at, resumed_from) VALUES (?, ?, ?, ?, ?)')
|
.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);
|
const hasController = [...s.clients].some((c) => c.controlling);
|
||||||
binding.controlling = binding.mode === 'interactive' && !hasController;
|
binding.controlling = binding.mode === 'interactive' && !hasController;
|
||||||
s.clients.add(binding);
|
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)
|
// Replay : reset terminal + queue du ring (l'écran TUI courant se reconstitue)
|
||||||
binding.sendResync(s.ring.tail(REPLAY_TAIL_BYTES));
|
binding.sendResync(s.ring.tail(REPLAY_TAIL_BYTES));
|
||||||
binding.sentBytes = 0;
|
binding.sentBytes = 0;
|
||||||
@@ -247,6 +260,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
const s = this.live.get(sessionId);
|
const s = this.live.get(sessionId);
|
||||||
if (!s || s.exited || !binding.controlling) return;
|
if (!s || s.exited || !binding.controlling) return;
|
||||||
s.proc.resize(cols, rows);
|
s.proc.resize(cols, rows);
|
||||||
|
s.tracker?.resize(cols, rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
ack(sessionId: string, binding: ClientBinding, bytes: number): void {
|
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 {
|
private handleOutput(s: ManagedSession, chunk: Buffer): void {
|
||||||
s.ring.write(chunk);
|
s.ring.write(chunk);
|
||||||
|
s.tracker?.feed(chunk);
|
||||||
for (const c of s.clients) {
|
for (const c of s.clients) {
|
||||||
if (c.lagging) continue;
|
if (c.lagging) continue;
|
||||||
c.sendOutput(chunk);
|
c.sendOutput(chunk);
|
||||||
@@ -302,6 +317,8 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
|
|
||||||
private handleExit(s: ManagedSession, exitCode: number | null, signal: number | null): void {
|
private handleExit(s: ManagedSession, exitCode: number | null, signal: number | null): void {
|
||||||
s.exited = { exitCode, signal };
|
s.exited = { exitCode, signal };
|
||||||
|
s.tracker?.dispose();
|
||||||
|
s.tracker = null;
|
||||||
if (s.killTimer) clearTimeout(s.killTimer);
|
if (s.killTimer) clearTimeout(s.killTimer);
|
||||||
const endedAt = new Date().toISOString();
|
const endedAt = new Date().toISOString();
|
||||||
this.db.prepare('UPDATE sessions SET ended_at = ?, exit_code = ? WHERE id = ?').run(endedAt, exitCode, s.id);
|
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 {
|
private summarize(s: ManagedSession): SessionSummary {
|
||||||
|
const act = s.tracker?.snapshot();
|
||||||
return {
|
return {
|
||||||
id: s.id,
|
id: s.id,
|
||||||
cwd: s.cwd,
|
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.
|
// une managée vivante ne se resume pas (corruption) ; une managée claude morte oui.
|
||||||
resumable: !!s.exited && s.command === 'claude' && s.claudeSessionId != null,
|
resumable: !!s.exited && s.command === 'claude' && s.claudeSessionId != null,
|
||||||
attachable: !s.exited,
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
39
packages/server/src/core/screen-reader.ts
Normal file
39
packages/server/src/core/screen-reader.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
// Reconstruction d'écran via @xterm/headless : terminal headless PERSISTANT par session, alimenté
|
||||||
|
// incrémentalement par le flux PTY. Remplace le strip ANSI naïf (qui « mange les espaces » et casse
|
||||||
|
// la détection des dialogues — verdict S1/S3). Aucune dépendance DOM (usage Node).
|
||||||
|
import { Terminal } from '@xterm/headless';
|
||||||
|
|
||||||
|
export class ScreenReader {
|
||||||
|
private readonly term: Terminal;
|
||||||
|
|
||||||
|
constructor(cols = 120, rows = 40) {
|
||||||
|
this.term = new Terminal({ cols, rows, scrollback: 0, allowProposedApi: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alimente le terminal avec un chunk PTY brut (la frontière UTF-8 et les séquences ANSI coupées
|
||||||
|
* sont gérées par xterm). La promesse se résout quand le chunk a été parsé (snapshot fiable ensuite).
|
||||||
|
*/
|
||||||
|
feed(chunk: Uint8Array | string): Promise<void> {
|
||||||
|
return new Promise((resolve) => this.term.write(chunk as Uint8Array, resolve));
|
||||||
|
}
|
||||||
|
|
||||||
|
resize(cols: number, rows: number): void {
|
||||||
|
this.term.resize(cols, rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lignes visibles du viewport courant, espaces préservés (trim à droite uniquement). */
|
||||||
|
snapshotLines(): string[] {
|
||||||
|
const buf = this.term.buffer.active;
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (let y = 0; y < this.term.rows; y++) {
|
||||||
|
const line = buf.getLine(buf.baseY + y);
|
||||||
|
lines.push(line ? line.translateToString(true) : '');
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.term.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
74
packages/server/test/claude-adapter.test.ts
Normal file
74
packages/server/test/claude-adapter.test.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { SessionActivityTracker } from '../src/core/claude-adapter.js';
|
||||||
|
|
||||||
|
const PID = 999_999;
|
||||||
|
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
function writeRegistry(dir: string, status: string, waitingFor?: string): void {
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, `${PID}.json`),
|
||||||
|
JSON.stringify({ pid: PID, procStart: '123', sessionId: 'sid', cwd: '/x', status, ...(waitingFor ? { waitingFor } : {}) }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SessionActivityTracker', () => {
|
||||||
|
let dir: string;
|
||||||
|
let tracker: SessionActivityTracker;
|
||||||
|
let changes: number;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), 'arb-adapter-'));
|
||||||
|
changes = 0;
|
||||||
|
tracker = new SessionActivityTracker(PID, dir, () => {
|
||||||
|
changes++;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
tracker.dispose();
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registre busy → idle : transitions d’activité + onChange sur changement uniquement', () => {
|
||||||
|
writeRegistry(dir, 'busy');
|
||||||
|
tracker.evaluate();
|
||||||
|
expect(tracker.snapshot().activity).toBe('busy');
|
||||||
|
expect(changes).toBe(1);
|
||||||
|
|
||||||
|
tracker.evaluate(); // rien n'a changé
|
||||||
|
expect(changes).toBe(1);
|
||||||
|
|
||||||
|
writeRegistry(dir, 'idle');
|
||||||
|
tracker.evaluate();
|
||||||
|
expect(tracker.snapshot().activity).toBe('idle');
|
||||||
|
expect(changes).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registre waiting + écran permission → activity waiting, dialog typé permission', async () => {
|
||||||
|
await tracker['reader'].feed('\x1b[2J\x1b[HDo you want to create x.txt?\r\n❯ 1. Yes\r\n2. No\r\nEsc to cancel · Tab to amend\r\n');
|
||||||
|
await sleep(30);
|
||||||
|
writeRegistry(dir, 'waiting', 'permission prompt');
|
||||||
|
tracker.evaluate();
|
||||||
|
const s = tracker.snapshot();
|
||||||
|
expect(s.activity).toBe('waiting');
|
||||||
|
expect(s.waitingFor).toBe('permission prompt');
|
||||||
|
expect(s.dialog?.kind).toBe('permission');
|
||||||
|
expect(s.dialog?.options.find((o) => o.n === 1)).toMatchObject({ selected: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pas de registre + écran Trust → waiting/trust (le Trust précède le registre)', async () => {
|
||||||
|
await tracker['reader'].feed('\x1b[2J\x1b[HDo you trust the files in this folder?\r\n❯ 1. Yes, I trust this folder\r\n2. No, exit\r\n');
|
||||||
|
await sleep(30);
|
||||||
|
tracker.evaluate();
|
||||||
|
const s = tracker.snapshot();
|
||||||
|
expect(s.activity).toBe('waiting');
|
||||||
|
expect(s.dialog?.kind).toBe('trust');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pas de registre + pas de dialogue → état vide (inconnu)', () => {
|
||||||
|
tracker.evaluate();
|
||||||
|
expect(tracker.snapshot()).toMatchObject({ activity: null, dialog: null });
|
||||||
|
});
|
||||||
|
});
|
||||||
79
packages/server/test/dialog-detection.test.ts
Normal file
79
packages/server/test/dialog-detection.test.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { ScreenReader } from '../src/core/screen-reader.js';
|
||||||
|
import { classifyDialog, parseOptions, type ClassifiedDialog } from '../src/core/dialog-classifier.js';
|
||||||
|
|
||||||
|
const capturesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'spikes', 's3-tui', 'captures');
|
||||||
|
|
||||||
|
/** Rejoue un flux PTY brut par segments (comme l'adapter en prod) et collecte écran+dialogue détecté. */
|
||||||
|
async function replay(fixture: string): Promise<Array<{ dialog: ClassifiedDialog; text: string }>> {
|
||||||
|
const data = readFileSync(join(capturesDir, fixture));
|
||||||
|
const reader = new ScreenReader(120, 40);
|
||||||
|
const seen: Array<{ dialog: ClassifiedDialog; text: string }> = [];
|
||||||
|
const STEP = 512;
|
||||||
|
for (let i = 0; i < data.length; i += STEP) {
|
||||||
|
await reader.feed(data.subarray(i, i + STEP)); // xterm gère les séquences coupées en frontière
|
||||||
|
const lines = reader.snapshotLines();
|
||||||
|
const d = classifyDialog(lines);
|
||||||
|
if (d) seen.push({ dialog: d, text: lines.join('\n') });
|
||||||
|
}
|
||||||
|
reader.dispose();
|
||||||
|
return seen;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ScreenReader (@xterm/headless) — anti strip-ANSI naïf', () => {
|
||||||
|
it('préserve les espaces du dialogue (« Do you want to create », pas « Doyouwant »)', async () => {
|
||||||
|
const seen = await replay('perm-write2.raw.log');
|
||||||
|
expect(seen.length).toBeGreaterThan(0);
|
||||||
|
expect(seen.some((s) => /Do you want to create/.test(s.text))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('classifyDialog — unitaire (lignes synthétiques)', () => {
|
||||||
|
it('trust', () => {
|
||||||
|
const d = classifyDialog(['Do you trust the files in this folder?', '❯ 1. Yes, I trust this folder', '2. No, exit']);
|
||||||
|
expect(d?.kind).toBe('trust');
|
||||||
|
expect(d?.options).toHaveLength(2);
|
||||||
|
expect(d?.options[0]).toMatchObject({ n: 1, selected: true });
|
||||||
|
});
|
||||||
|
it('permission', () => {
|
||||||
|
const d = classifyDialog(['Do you want to create s3-edit.txt?', '❯ 1. Yes', '3. No', 'Esc to cancel · Tab to amend']);
|
||||||
|
expect(d?.kind).toBe('permission');
|
||||||
|
});
|
||||||
|
it('question (prime sur permission malgré « Esc to cancel »)', () => {
|
||||||
|
const d = classifyDialog(['☐ Couleur', '❯ 1. Rouge', '2. Bleu', 'Enter to select · ↑/↓ to navigate · Esc to cancel']);
|
||||||
|
expect(d?.kind).toBe('question');
|
||||||
|
});
|
||||||
|
it('aucun dialogue → null', () => {
|
||||||
|
expect(classifyDialog(['just some output', 'no options here'])).toBeNull();
|
||||||
|
});
|
||||||
|
it('parseOptions tolère « ❯ 2) Label » et trim', () => {
|
||||||
|
expect(parseOptions([' ❯ 2) Yes, allow all '])).toEqual([{ n: 2, label: 'Yes, allow all', selected: true }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('détection sur fixtures réelles S3 (replay headless)', () => {
|
||||||
|
it('perm-write2 → permission avec options Yes/…/No, option 1 sélectionnée', async () => {
|
||||||
|
const perms = (await replay('perm-write2.raw.log')).filter((s) => s.dialog.kind === 'permission');
|
||||||
|
expect(perms.length).toBeGreaterThan(0);
|
||||||
|
const withOpts = perms.find((s) => s.dialog.options.length >= 2);
|
||||||
|
expect(withOpts).toBeDefined();
|
||||||
|
const labels = withOpts!.dialog.options.map((o) => o.label).join(' | ');
|
||||||
|
expect(labels).toMatch(/Yes/);
|
||||||
|
expect(labels).toMatch(/No/);
|
||||||
|
expect(withOpts!.dialog.options.find((o) => o.n === 1)?.selected).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ask2 → question avec une option « Rouge »', async () => {
|
||||||
|
const qs = (await replay('ask2.raw.log')).filter((s) => s.dialog.kind === 'question');
|
||||||
|
expect(qs.length).toBeGreaterThan(0);
|
||||||
|
expect(qs.some((s) => s.dialog.options.some((o) => /Rouge/.test(o.label)))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trust → dialogue trust détecté', async () => {
|
||||||
|
const seen = await replay('trust.raw.log');
|
||||||
|
expect(seen.some((s) => s.dialog.kind === 'trust')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because one or more lines are too long
@@ -68,6 +68,21 @@ export type SessionSource =
|
|||||||
/** Statut brut tel qu'écrit par le CLI dans ~/.claude/sessions (interprété finement en P3). */
|
/** Statut brut tel qu'écrit par le CLI dans ~/.claude/sessions (interprété finement en P3). */
|
||||||
export type SessionRegistryStatus = 'busy' | 'idle' | 'waiting';
|
export type SessionRegistryStatus = 'busy' | 'idle' | 'waiting';
|
||||||
|
|
||||||
|
// ---- États fins de session (P3-B, claude-adapter) ----
|
||||||
|
/** busy = Claude traite ; waiting = bloqué sur un dialogue ; idle = prêt pour une instruction. */
|
||||||
|
export type SessionActivity = 'busy' | 'waiting' | 'idle';
|
||||||
|
export type DialogKind = 'trust' | 'permission' | 'question' | 'plan';
|
||||||
|
export interface DialogOption {
|
||||||
|
n: number;
|
||||||
|
label: string;
|
||||||
|
selected: boolean;
|
||||||
|
}
|
||||||
|
export interface SessionDialog {
|
||||||
|
kind: DialogKind;
|
||||||
|
waitingFor: string | null;
|
||||||
|
options: DialogOption[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface SessionSummary {
|
export interface SessionSummary {
|
||||||
id: string;
|
id: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
@@ -92,6 +107,12 @@ export interface SessionSummary {
|
|||||||
attachable: boolean;
|
attachable: boolean;
|
||||||
/** statut brut du registre ~/.claude/sessions (P2) ; interprété finement en P3 (claude-adapter). */
|
/** statut brut du registre ~/.claude/sessions (P2) ; interprété finement en P3 (claude-adapter). */
|
||||||
registryStatus: SessionRegistryStatus | null;
|
registryStatus: SessionRegistryStatus | null;
|
||||||
|
// ---- P3-B : états fins (optionnels, remplis par le claude-adapter) ----
|
||||||
|
/** absent/null = inconnu (bash, démarrage, historique). */
|
||||||
|
activity?: SessionActivity | null;
|
||||||
|
waitingFor?: string | null;
|
||||||
|
/** dialogue typé en cours (présent quand activity === 'waiting'). */
|
||||||
|
dialog?: SessionDialog | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Worktrees & repos (P3) ----
|
// ---- Worktrees & repos (P3) ----
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
33
packages/web/src/components/SessionStateBadge.vue
Normal file
33
packages/web/src/components/SessionStateBadge.vue
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<template>
|
||||||
|
<span v-if="!session.live && session.resumable" class="badge bg-amber-950 text-amber-400">
|
||||||
|
{{ t('sessions.resumable') }}
|
||||||
|
</span>
|
||||||
|
<span v-else-if="!session.live" class="badge bg-zinc-800 text-zinc-500">{{ t('sessions.statusLabel.exited') }}</span>
|
||||||
|
<span v-else class="badge gap-1" :class="badgeClass">
|
||||||
|
<span class="inline-block h-1.5 w-1.5 rounded-full" :class="dotClass" />
|
||||||
|
{{ label }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import type { SessionSummary } from '@arboretum/shared';
|
||||||
|
|
||||||
|
const props = defineProps<{ session: SessionSummary }>();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
// activity (P3-B, managées) prime ; registryStatus (P2, découvertes) en repli.
|
||||||
|
const activity = computed(() => props.session.activity ?? props.session.registryStatus ?? null);
|
||||||
|
const label = computed(() => (activity.value ? t(`sessions.registryStatus.${activity.value}`) : t('sessions.live')));
|
||||||
|
const badgeClass = computed(() => ({
|
||||||
|
'bg-amber-950 text-amber-300': activity.value === 'waiting',
|
||||||
|
'bg-sky-950 text-sky-300': activity.value === 'busy',
|
||||||
|
'bg-emerald-950 text-emerald-400': activity.value === 'idle' || activity.value === null,
|
||||||
|
}));
|
||||||
|
const dotClass = computed(() => ({
|
||||||
|
'bg-amber-300 animate-pulse': activity.value === 'waiting',
|
||||||
|
'bg-sky-300 animate-pulse': activity.value === 'busy',
|
||||||
|
'bg-emerald-400': activity.value === 'idle' || activity.value === null,
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
@@ -58,12 +58,7 @@
|
|||||||
<span v-if="s.source === 'discovered'" class="badge bg-sky-950 text-sky-400">
|
<span v-if="s.source === 'discovered'" class="badge bg-sky-950 text-sky-400">
|
||||||
{{ t('sessions.sourceDiscovered') }}
|
{{ t('sessions.sourceDiscovered') }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="s.live" class="badge gap-1 bg-emerald-950 text-emerald-400">
|
<SessionStateBadge :session="s" />
|
||||||
<span class="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-400" />
|
|
||||||
{{ s.registryStatus ? t(`sessions.registryStatus.${s.registryStatus}`) : t('sessions.live') }}
|
|
||||||
</span>
|
|
||||||
<span v-else-if="s.resumable" class="badge bg-amber-950 text-amber-400">{{ t('sessions.resumable') }}</span>
|
|
||||||
<span v-else class="badge bg-zinc-800 text-zinc-500">{{ t('sessions.statusLabel.exited') }}</span>
|
|
||||||
<span class="ml-auto text-xs text-zinc-500">{{ formatDate(s.createdAt) }}</span>
|
<span class="ml-auto text-xs text-zinc-500">{{ formatDate(s.createdAt) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="s.title" class="truncate text-sm text-zinc-200" :title="s.title">{{ s.title }}</p>
|
<p v-if="s.title" class="truncate text-sm text-zinc-200" :title="s.title">{{ s.title }}</p>
|
||||||
@@ -113,6 +108,7 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import { useAuthStore } from '../stores/auth';
|
import { useAuthStore } from '../stores/auth';
|
||||||
import { useSessionsStore } from '../stores/sessions';
|
import { useSessionsStore } from '../stores/sessions';
|
||||||
import LanguageSwitcher from '../components/LanguageSwitcher.vue';
|
import LanguageSwitcher from '../components/LanguageSwitcher.vue';
|
||||||
|
import SessionStateBadge from '../components/SessionStateBadge.vue';
|
||||||
|
|
||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|||||||
Reference in New Issue
Block a user