/** * In-memory per-token session registry kept by the daemon. * * `claudemesh launch` POSTs `/v1/sessions/register` with the token it * minted plus session metadata (sessionId, mesh, displayName, pid, * cwd, role, groups). Subsequent CLI invocations from inside that * session present the token via `Authorization: ClaudeMesh-Session * ` and the daemon's IPC auth middleware resolves it here in O(1). * * Lifecycle: * - register replaces any prior entry under the same `sessionId` * (handles re-launch and `--resume` flows cleanly). * - reaper polls every 5 s. An entry is dropped when its pid is dead * OR when its captured start-time no longer matches the running * process (PID reuse — original is gone, OS recycled the number). * - hard ttl ceiling of 24 h is a leak guard for forgotten sessions. * * Persistence: in-memory only for v1. A daemon restart clears the * registry — every launched session needs to re-register. That's fine * for now because launch.ts re-registers on `ensureDaemonRunning`'s * success path, and most ad-hoc CLI invocations from outside a launched * session have no token to begin with. */ import { getProcessStartTime, getProcessStartTimes, isPidAlive } from "./process-info.js"; /** * Optional per-launch presence material. Carried opaquely through the * registry; the daemon's session-broker subsystem (1.30.0+) reads it to * open a long-lived broker WebSocket per session. Absent on older CLIs * — register accepts payloads without it for backward compat. */ export interface SessionPresence { /** Hex ed25519 pubkey, 64 chars. */ sessionPubkey: string; /** Hex ed25519 secret key (held in-memory only; never disk). */ sessionSecretKey: string; /** Parent-member-signed attestation; see signParentAttestation. */ parentAttestation: { sessionPubkey: string; parentMemberPubkey: string; expiresAt: number; signature: string; }; } export interface SessionInfo { token: string; sessionId: string; mesh: string; displayName: string; pid: number; cwd?: string; role?: string; groups?: string[]; /** 1.30.0+: per-launch presence material. */ presence?: SessionPresence; /** * 1.31.0+: opaque per-process start-time captured at register. The * reaper compares the live value against this on every sweep — a * mismatch means the original process exited and the pid was reused * by an unrelated program, so the registry entry must be dropped. * `undefined` when capture failed (process already dead at register * time, ps unavailable, etc.) — the reaper falls back to bare * liveness in that case. */ startTime?: string; registeredAt: number; } /** Lifecycle callbacks invoked synchronously after registry mutation. */ export interface RegistryHooks { onRegister?: (info: SessionInfo) => void; onDeregister?: (info: SessionInfo) => void; } const TTL_MS = 24 * 60 * 60 * 1000; const REAPER_INTERVAL_MS = 5 * 1000; const byToken = new Map(); const bySessionId = new Map(); const hooks: RegistryHooks = {}; let reaperHandle: NodeJS.Timeout | null = null; export function startReaper(): void { if (reaperHandle) return; // The sweep is async (batched ps) — wrap in `void` so setInterval // doesn't try to await us, and so an unexpected throw doesn't crash // the daemon. Errors are swallowed inside reapDead. reaperHandle = setInterval(() => { void reapDead(); }, REAPER_INTERVAL_MS).unref?.() ?? reaperHandle; } export function stopReaper(): void { if (reaperHandle) { clearInterval(reaperHandle); reaperHandle = null; } } /** * Wire daemon-level lifecycle hooks. Called once at daemon boot — passing * `{}` clears them. Idempotent across calls so tests can re-bind. */ export function setRegistryHooks(next: RegistryHooks): void { hooks.onRegister = next.onRegister; hooks.onDeregister = next.onDeregister; } export function registerSession(info: Omit): SessionInfo { // Replace any prior entry under the same sessionId. const priorToken = bySessionId.get(info.sessionId); if (priorToken && priorToken !== info.token) { const prior = byToken.get(priorToken); if (prior) { byToken.delete(priorToken); try { hooks.onDeregister?.(prior); } catch { /* hook errors must never throttle the registry */ } } } // Caller may pre-fill info.startTime (tests do this for determinism). // For the real path we fire-and-forget an async ps probe — register // stays sync and microsecond-fast, and the start-time lands on the // entry within a few ms. Until it lands, the reaper falls back to // bare liveness for this entry, which is fine for the common case // (PID reuse is rare; the brief window without the guard is // tolerable). const stored: SessionInfo = { ...info, registeredAt: Date.now() }; byToken.set(info.token, stored); bySessionId.set(info.sessionId, info.token); try { hooks.onRegister?.(stored); } catch { /* see above */ } if (stored.startTime === undefined) { void captureStartTimeAsync(info.token, info.pid); } return stored; } async function captureStartTimeAsync(token: string, pid: number): Promise { const lstart = await getProcessStartTime(pid); if (lstart === null) return; const entry = byToken.get(token); if (!entry || entry.pid !== pid) return; // entry was replaced; skip entry.startTime = lstart; } export function deregisterByToken(token: string): boolean { const entry = byToken.get(token); if (!entry) return false; byToken.delete(token); if (bySessionId.get(entry.sessionId) === token) bySessionId.delete(entry.sessionId); try { hooks.onDeregister?.(entry); } catch { /* see above */ } return true; } export function resolveToken(token: string): SessionInfo | null { const entry = byToken.get(token); if (!entry) return null; if (Date.now() - entry.registeredAt > TTL_MS) { deregisterByToken(token); return null; } return entry; } export function listSessions(): SessionInfo[] { return [...byToken.values()]; } async function reapDead(): Promise { // Snapshot first; the second (async) phase calls ps and we must not // mutate the registry mid-iteration. const entries = [...byToken.entries()]; // Phase 1 — TTL + bare liveness. Sync, microsecond-fast. const dead: string[] = []; const survivors: Array<[string, SessionInfo]> = []; for (const [token, info] of entries) { if (Date.now() - info.registeredAt > TTL_MS) { dead.push(token); continue; } if (!isPidAlive(info.pid)) { dead.push(token); continue; } survivors.push([token, info]); } // Phase 2 — PID-reuse guard for survivors that have a captured // start-time. Single batched ps call: O(1) forks regardless of // session count. Survivors without a start-time keep the bare- // liveness verdict from phase 1 (their captureStartTimeAsync may // still be in-flight from a recent register). const guardedPids = survivors .filter(([, info]) => info.startTime !== undefined) .map(([, info]) => info.pid); if (guardedPids.length > 0) { try { const live = await getProcessStartTimes(guardedPids); for (const [token, info] of survivors) { if (info.startTime === undefined) continue; const lstart = live.get(info.pid); // ps may transiently miss a pid that was alive when isPidAlive // ran — treat absence as "racing", let the next sweep decide. if (lstart === undefined) continue; if (lstart !== info.startTime) dead.push(token); } } catch { // ps failure here is non-fatal: survivors keep their phase-1 // verdict. Logging is the daemon's responsibility — the // registry deliberately stays log-free. } } for (const t of dead) deregisterByToken(t); } /** Test helper: run a single reaper pass. */ export async function _runReaperOnce(): Promise { await reapDead(); } /** Test helper. */ export function _resetRegistry(): void { byToken.clear(); bySessionId.clear(); hooks.onRegister = undefined; hooks.onDeregister = undefined; }