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).
93 lines
3.6 KiB
TypeScript
93 lines
3.6 KiB
TypeScript
// 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;
|
|
}
|