// Service de découverte : second producteur de SessionSummary (à côté du PtyManager). // Scanne ~/.claude/projects (JSONL) + ~/.claude/sessions (registre), corrèle par claudeSessionId, // calcule la vivacité, met en cache et notifie les changements. Lecture seule du disque. import { EventEmitter } from 'node:events'; import { homedir } from 'node:os'; import { join } from 'node:path'; import type { SessionSummary } from '@arboretum/shared'; import { scanProjects, type DiscoveredJsonl } from './jsonl-discovery.js'; import { readRegistry, type RegistryEntry } from './session-registry.js'; import { listHiddenSessionIds, type Db } from '../db/index.js'; import type { PtyManager } from './pty-manager.js'; const DEFAULT_REFRESH_MS = 10_000; export interface DiscoveryServiceEvents { /** session découverte nouvelle ou modifiée (relayée en session_update par la gateway). */ discovery_update: [SessionSummary]; } export interface DiscoveryOptions { db: Db; ptyManager: PtyManager; projectsDir?: string; sessionsDir?: string; refreshMs?: number; } export class DiscoveryService extends EventEmitter { private readonly db: Db; private readonly projectsDir: string; private readonly sessionsDir: string; private readonly ptyManager: PtyManager; private readonly refreshMs: number; private timer: NodeJS.Timeout | null = null; private cache: SessionSummary[] = []; private byId = new Map(); private prevJson = new Map(); constructor(opts: DiscoveryOptions) { super(); this.db = opts.db; this.ptyManager = opts.ptyManager; this.projectsDir = opts.projectsDir ?? join(homedir(), '.claude', 'projects'); this.sessionsDir = opts.sessionsDir ?? join(homedir(), '.claude', 'sessions'); this.refreshMs = opts.refreshMs ?? DEFAULT_REFRESH_MS; } start(): void { if (this.timer) return; void this.refresh(); // premier scan asynchrone : ne bloque pas le boot this.timer = setInterval(() => void this.refresh(), this.refreshMs); this.timer.unref(); // ne maintient pas le process en vie } stop(): void { if (this.timer) { clearInterval(this.timer); this.timer = null; } } /** Cache courant (jamais de scan synchrone dans le chemin chaud). */ list(): SessionSummary[] { return this.cache; } /** Session découverte (avec son cwd d'origine lu sur disque) — pour resume/fork. null si absente. */ getDiscovered(claudeSessionId: string): DiscoveredJsonl | null { return this.byId.get(claudeSessionId) ?? null; } /** * Vivacité FRAÎCHE d'une session (relit le registre, ne se fie pas au cache) — garde-fou * anti-corruption : la route /resume doit refuser une session devenue vivante depuis le dernier scan. */ isClaudeSessionLive(claudeSessionId: string): boolean { const entry = readRegistry(this.sessionsDir).find((r) => r.claudeSessionId === claudeSessionId); return entry?.live ?? false; } /** Recalcule le cache depuis le disque. Tolérant : ne lève jamais. */ async refresh(): Promise { const regBySid = new Map(); for (const r of readRegistry(this.sessionsDir)) { if (r.claudeSessionId) regBySid.set(r.claudeSessionId, r); } const known = this.ptyManager.knownClaudeSessionIds(); // Sessions masquées par l'utilisateur : on les garde dans le cache (resume/fork possibles) mais // marquées `hidden` → la route /sessions les exclut par défaut. const hiddenIds = listHiddenSessionIds(this.db); // Dédoublonnage des JSONL par claudeSessionId (on retient le plus récent). const latest = new Map(); for (const d of await scanProjects(this.projectsDir)) { const prev = latest.get(d.claudeSessionId); if (!prev || d.mtimeMs > prev.mtimeMs) latest.set(d.claudeSessionId, d); } const summaries: SessionSummary[] = []; const byId = new Map(); for (const d of latest.values()) { if (known.has(d.claudeSessionId)) continue; // déjà gérée comme session managée byId.set(d.claudeSessionId, d); const r = regBySid.get(d.claudeSessionId); const live = r?.live ?? false; summaries.push({ id: d.claudeSessionId, // les découvertes s'identifient par leur claudeSessionId cwd: d.cwd, command: 'claude', title: d.title, status: live ? 'running' : 'exited', live, createdAt: new Date(d.mtimeMs).toISOString(), endedAt: null, exitCode: null, clients: 0, source: 'discovered', claudeSessionId: d.claudeSessionId, pid: live ? (r?.pid ?? null) : null, resumable: !live, // morte → --resume direct ; vivante → fork/observe (jamais resume : corruption) attachable: false, // Arboretum ne tient pas le PTY d'une session externe registryStatus: r?.status ?? null, hidden: hiddenIds.has(d.claudeSessionId), }); } this.cache = summaries; this.byId = byId; // Diff : n'émettre que les sessions nouvelles ou modifiées. const nextJson = new Map(); for (const s of summaries) { const j = JSON.stringify(s); nextJson.set(s.id, j); if (this.prevJson.get(s.id) !== j) this.emit('discovery_update', s); } this.prevJson = nextJson; } } /** Fusionne sessions managées + découvertes en dédoublonnant par claudeSessionId (managé prioritaire). */ export function mergeSessions(managed: SessionSummary[], discovered: SessionSummary[]): SessionSummary[] { const managedSids = new Set(managed.map((s) => s.claudeSessionId).filter((x): x is string => x != null)); return [...managed, ...discovered.filter((s) => !(s.claudeSessionId && managedSids.has(s.claudeSessionId)))]; }