// Lecture du registre ~/.claude/sessions (un fichier .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//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): 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; 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 .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; }