P2: découverte & reprise des sessions Claude

Arboretum découvre désormais toutes les sessions Claude de la machine
(scan ~/.claude/projects + registre ~/.claude/sessions), distingue
vivantes/mortes par pid+procStart, et permet de reprendre une morte
(--resume dans son cwd d'origine) ou forker une vivante sans la corrompre.

- shared: SessionSummary enrichi (source, claudeSessionId, pid, resumable,
  attachable, registryStatus) — additif, PROTOCOL_VERSION inchangé ;
  types REST resume/fork.
- db: migration id:2 (claude_session_id, resumed_from).
- core: jsonl-discovery (parseur tolérant, scan asynchrone non bloquant),
  session-registry (vivacité pid+procStart), discovery-service (cache +
  refresh périodique + diff/broadcast), pty-manager (resume/fork + capture
  du claudeSessionId via le registre).
- routes: /sessions/:id/resume (garde-fou 409 anti-corruption sur session
  vivante) et /fork ; GET fusionné managées + découvertes ; relais WS.
- web: badges managed/discovered + busy/idle/waiting, actions conditionnelles
  (Open/Observe/Kill vs Fork/View vs Resume/Fork), vue read-only des sessions
  externes, i18n EN/FR.
- tests: jsonl-discovery, session-registry, discovery-service + resume/fork
  (130 verts) ; acceptation E2E acceptance-p2.mjs (sans quota) ALL GREEN.

Conforme aux verdicts S1 (resume dans cwd d'origine, vivacité pid+procStart)
et S4 (munge cwd, parseur tête+queue, priorité de titre).
This commit is contained in:
2026-06-12 18:20:21 +02:00
parent e9404cb567
commit 92c48f0978
27 changed files with 1363 additions and 41 deletions

View File

@@ -1,14 +1,20 @@
import { EventEmitter } from 'node:events';
import { existsSync, statSync } from 'node:fs';
import { randomUUID } from 'node:crypto';
import { homedir } from 'node:os';
import { join } from 'node:path';
import pty from '@homebridge/node-pty-prebuilt-multiarch';
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 type { Db } from '../db/index.js';
const RING_CAPACITY = 2 * 1024 * 1024;
const KILL_GRACE_MS = 5000;
/** Capture du claudeSessionId après spawn : poll du registre par pid (waitReady validé S1). */
const CLAUDE_ID_POLL_MS = 400;
const CLAUDE_ID_TIMEOUT_MS = 60_000;
/** Lien entre un client WS attaché et une session. La gateway fournit les callbacks d'envoi. */
export interface ClientBinding {
@@ -36,6 +42,8 @@ interface ManagedSession {
paused: boolean;
exited: { exitCode: number | null; signal: number | null } | 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). */
claudeSessionId: string | null;
}
export interface PtyManagerEvents {
@@ -46,17 +54,21 @@ export interface PtyManagerEvents {
export class PtyManager extends EventEmitter<PtyManagerEvents> {
private readonly live = new Map<string, ManagedSession>();
constructor(private readonly db: Db) {
constructor(
private readonly db: Db,
private readonly sessionsDir: string = join(homedir(), '.claude', 'sessions'),
) {
super();
}
spawn(opts: { cwd: string; command?: 'claude' | 'bash' }): SessionSummary {
spawn(opts: { cwd: string; command?: 'claude' | 'bash'; resume?: { claudeSessionId: string; fork?: boolean } }): SessionSummary {
const cwd = opts.cwd;
if (!existsSync(cwd) || !statSync(cwd).isDirectory()) {
throw Object.assign(new Error(`Not a directory: ${cwd}`), { statusCode: 400 });
}
const command = opts.command ?? 'claude';
const spec = buildSpawnSpec(command);
// Un resume/fork est toujours une session claude (le cwd d'origine est garanti par l'appelant — S1).
const command = opts.resume ? 'claude' : (opts.command ?? 'claude');
const spec = buildSpawnSpec({ command, ...(opts.resume ? { resume: opts.resume } : {}) });
const id = randomUUID();
const proc = pty.spawn(spec.file, spec.args, {
name: 'xterm-256color',
@@ -77,26 +89,66 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
paused: false,
exited: null,
killTimer: null,
claudeSessionId: null,
};
this.live.set(id, session);
this.db
.prepare('INSERT INTO sessions (id, cwd, command, created_at) VALUES (?, ?, ?, ?)')
.run(id, cwd, command, session.createdAt);
.prepare('INSERT INTO sessions (id, cwd, command, created_at, resumed_from) VALUES (?, ?, ?, ?, ?)')
.run(id, cwd, command, session.createdAt, opts.resume?.claudeSessionId ?? null);
proc.onData((data) => this.handleOutput(session, Buffer.from(data, 'utf8')));
proc.onExit(({ exitCode, signal }) => this.handleExit(session, exitCode, signal ?? null));
if (command === 'claude') this.captureClaudeSessionId(session);
const summary = this.summarize(session);
this.emit('session_update', summary);
return summary;
}
/** Résout le claudeSessionId du CLI en pollant le registre par pid, puis le persiste (waitReady — S1). */
private captureClaudeSessionId(s: ManagedSession): void {
const deadline = Date.now() + CLAUDE_ID_TIMEOUT_MS;
const tick = (): void => {
if (s.exited || s.claudeSessionId) return;
const entry = findByPid(this.sessionsDir, s.proc.pid);
if (entry?.claudeSessionId) {
s.claudeSessionId = entry.claudeSessionId;
this.db.prepare('UPDATE sessions SET claude_session_id = ? WHERE id = ?').run(entry.claudeSessionId, s.id);
this.emit('session_update', this.summarize(s));
return;
}
if (Date.now() < deadline) setTimeout(tick, CLAUDE_ID_POLL_MS).unref();
};
setTimeout(tick, CLAUDE_ID_POLL_MS).unref();
}
/** Session managée VIVANTE portant ce claudeSessionId (garde-fou anti-resume d'une session vivante). */
findLiveByClaudeSessionId(claudeSessionId: string): SessionSummary | null {
for (const s of this.live.values()) {
if (s.claudeSessionId === claudeSessionId) return this.summarize(s);
}
return null;
}
/**
* Ensemble des claudeSessionId déjà connus d'Arboretum (sessions managées vivantes + historique DB).
* Sert au DiscoveryService à exclure les doublons (une managée ne doit pas réapparaître en découverte).
*/
knownClaudeSessionIds(): Set<string> {
const rows = this.db
.prepare('SELECT claude_session_id FROM sessions WHERE claude_session_id IS NOT NULL')
.all() as Array<{ claude_session_id: string }>;
const set = new Set(rows.map((r) => r.claude_session_id));
for (const s of this.live.values()) if (s.claudeSessionId) set.add(s.claudeSessionId);
return set;
}
list(): SessionSummary[] {
const liveSummaries = [...this.live.values()].map((s) => this.summarize(s));
const liveIds = new Set(this.live.keys());
const rows = this.db
.prepare('SELECT id, cwd, command, title, created_at, ended_at, exit_code FROM sessions ORDER BY created_at DESC LIMIT 100')
.all() as Array<{ id: string; cwd: string; command: string; title: string | null; created_at: string; ended_at: string | null; exit_code: number | null }>;
.prepare('SELECT id, cwd, command, title, created_at, ended_at, exit_code, claude_session_id FROM sessions ORDER BY created_at DESC LIMIT 100')
.all() as Array<{ id: string; cwd: string; command: string; title: string | null; created_at: string; ended_at: string | null; exit_code: number | null; claude_session_id: string | null }>;
const historical: SessionSummary[] = rows
.filter((r) => !liveIds.has(r.id))
.map((r) => ({
@@ -110,6 +162,13 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
endedAt: r.ended_at,
exitCode: r.exit_code,
clients: 0,
source: 'managed',
claudeSessionId: r.claude_session_id,
pid: null,
// une session claude morte avec un claudeSessionId connu est reprenable (--resume direct).
resumable: r.command === 'claude' && r.claude_session_id != null,
attachable: false,
registryStatus: null,
}));
return [...liveSummaries, ...historical];
}
@@ -265,6 +324,13 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
endedAt: null,
exitCode: s.exited?.exitCode ?? null,
clients: s.clients.size,
source: 'managed',
claudeSessionId: s.claudeSessionId,
pid: s.exited ? null : s.proc.pid,
// 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)
};
}
}