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).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-12 18:20:21 +02:00
parent 8bc48448c2
commit fb175bc7c5
27 changed files with 1363 additions and 41 deletions

View File

@@ -0,0 +1,92 @@
// Lecture du registre ~/.claude/sessions (un fichier <pid>.json par session CLI vive).
// Module partagé : P2 l'utilise pour la vivacité + le lookup par pid ; P3-B (claude-adapter)
// s'en sert comme source primaire d'état fin (busy/idle/waiting + waitingFor).
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import type { SessionRegistryStatus } from '@arboretum/shared';
export interface RegistryEntry {
claudeSessionId: string | null;
cwd: string | null;
pid: number;
procStart: string | null;
status: SessionRegistryStatus | null;
waitingFor: string | null;
/** vivacité réelle = pid + procStart concordants (jamais la simple présence du fichier). */
live: boolean;
}
/** starttime (champ 22 de /proc/<pid>/stat) ou null si le process n'existe pas. Linux-only. */
export function readProcStart(pid: number): string | null {
try {
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
// Le nom du process (champ 2) peut contenir espaces/parenthèses → parser après le dernier ')'.
const after = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
return after[19] ?? null; // champ 22 global = index 19 après le champ 3
} catch {
return null;
}
}
/**
* Vivacité par pid + procStart (spike S1/S4) : jamais la présence d'un fichier registre (stale possible).
* "Incertain → vivant" : si le process existe mais que le registre n'a pas de procStart, on traite vivant.
*/
export function isLive(pid: number, procStart: string | number | null | undefined): boolean {
const actual = readProcStart(pid);
if (actual === null) return false; // process absent → mort
if (procStart === null || procStart === undefined) return true; // incertain → vivant
return String(procStart) === actual; // procStart discordant → pid recyclé → stale → mort
}
function normalizeStatus(v: unknown): SessionRegistryStatus | null {
return v === 'busy' || v === 'idle' || v === 'waiting' ? v : null;
}
function toEntry(r: Record<string, unknown>): RegistryEntry | null {
const pid = typeof r.pid === 'number' ? r.pid : Number(r.pid);
if (!Number.isInteger(pid)) return null;
const procStart = r.procStart === undefined || r.procStart === null ? null : String(r.procStart);
return {
claudeSessionId: typeof r.sessionId === 'string' ? r.sessionId : null,
cwd: typeof r.cwd === 'string' ? r.cwd : null,
pid,
procStart,
status: normalizeStatus(r.status),
waitingFor: typeof r.waitingFor === 'string' ? r.waitingFor : null,
live: isLive(pid, procStart),
};
}
/**
* Lit toutes les entrées de `sessionsDir`. Tolérant aux fichiers en cours d'écriture / JSON invalide
* (ignorés). Dossier absent → tableau vide (pas une erreur).
*/
export function readRegistry(sessionsDir: string): RegistryEntry[] {
let files: string[];
try {
files = readdirSync(sessionsDir);
} catch {
return [];
}
const out: RegistryEntry[] = [];
for (const f of files) {
if (!f.endsWith('.json')) continue;
try {
const r = JSON.parse(readFileSync(join(sessionsDir, f), 'utf8')) as Record<string, unknown>;
const entry = toEntry(r);
if (entry) out.push(entry);
} catch {
/* fichier en cours d'écriture / JSON invalide : ignoré */
}
}
return out;
}
/**
* Entrée registre correspondant à un pid donné (le CLI nomme son fichier <pid>.json, mais on
* matche sur le champ `pid` pour ne pas dépendre du nommage). null si absente/illisible.
*/
export function findByPid(sessionsDir: string, pid: number): RegistryEntry | null {
return readRegistry(sessionsDir).find((e) => e.pid === pid) ?? null;
}