/** * @fileoverview Pure merge/filter logic for the unified session list (COD-121). * * Combines four read-only views of a session — live (in-memory `Session`), * persisted (`state.json`), transcript history (`~/.claude/projects`), and the * lifecycle audit log — plus mux process stats, into one de-duplicated list * keyed by sessionId. Transcript-history rows are keyed by the Claude * conversation UUID (the `.jsonl` filename stem), which diverges from the * Codeman id for resumed sessions — an alias map (claudeSessionId → Codeman id, * built from the live/persisted views) folds them into the owning session item. * Higher-precedence sources overwrite scalar fields when present * (history < lifecycle < persisted < live), while the `sources` array * always accumulates every contributing view. A "meaningfulness floor" drops * noise (bare lifecycle/mux-only rows with no name and no first prompt). * * PURE: no fs/IO and no node imports. All IO happens in the route that feeds * this module its inputs, which keeps the merge/sort/filter behavior unit-testable. */ export type UnifiedSessionItem = { sessionId: string; name?: string; mode?: string; status?: string; isWorking?: boolean; workingDir?: string; createdAt?: number; lastActivityAt?: number; claudeSessionId?: string; firstPrompt?: string; /** Most recent user prompt from the transcript (COD-145), parallel to firstPrompt. */ lastPrompt?: string; sizeBytes?: number; projectKey?: string; remote?: boolean; /** Pinned to the top of the session manager list (COD-139). */ pinned?: boolean; /** When the session was pinned (epoch ms) — orders the pinned group desc. */ pinnedAt?: number; sources: string[]; stats?: { memoryMB: number; cpuPercent: number }; }; /** Live in-memory session view (subset of `Session.toState()`). */ export type LiveSessionInput = { id: string; name?: string; mode?: string; status?: string; isWorking?: boolean; workingDir?: string; createdAt?: number; lastActivityAt?: number; claudeSessionId?: string; pinned?: boolean; pinnedAt?: number; }; /** Persisted session view (subset of `SessionState`). */ export type PersistedSessionInput = { id: string; name?: string; mode?: string; status?: string; workingDir?: string; createdAt?: number; lastActivityAt?: number; /** Claude conversation ID this session resumes (`SessionState.resumeSessionId`). */ claudeSessionId?: string; pinned?: boolean; pinnedAt?: number; }; /** Lifecycle audit-log view. Entries are expected NEWEST-first (the order `SessionLifecycleLog.query()` returns). */ export type LifecycleInput = { sessionId: string; name?: string; mode?: string; ts: number; event?: string; }; /** Transcript-history view (one `.jsonl` per session). */ export type HistoryInput = { sessionId: string; workingDir: string; sizeBytes: number; lastModified: string; firstPrompt?: string; /** Most recent user prompt from the transcript (COD-145). */ lastPrompt?: string; projectKey?: string; }; /** Mux process-stat view. */ export type MuxStatInput = { sessionId: string; muxName?: string; mode?: string; stats?: { memoryMB: number; cpuPercent: number }; remote?: boolean; }; export type UnifiedSources = { live?: LiveSessionInput[]; persisted?: PersistedSessionInput[]; lifecycle?: LifecycleInput[]; history?: HistoryInput[]; mux?: MuxStatInput[]; }; /** Push a source tag onto an item exactly once. */ function addSource(item: UnifiedSessionItem, source: string): void { if (!item.sources.includes(source)) item.sources.push(source); } /** Get-or-create the accumulator item for a sessionId. */ function ensureItem(map: Map, sessionId: string): UnifiedSessionItem { let item = map.get(sessionId); if (!item) { item = { sessionId, sources: [] }; map.set(sessionId, item); } return item; } /** Overwrite a scalar field only when the incoming value is defined. */ function overwrite( item: UnifiedSessionItem, key: K, value: UnifiedSessionItem[K] | undefined ): void { if (value !== undefined) item[key] = value; } /** * Merge all source views into one list, applying precedence * (history → lifecycle → persisted → live) and the meaningfulness floor. */ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionItem[] { const map = new Map(); // Alias map: Claude conversation UUID → owning Codeman session id. Resumed // (claudeSessionId = resumeSessionId != id) and /clear-respawned sessions // would otherwise surface twice — once as a live/persisted row and once as a // separate history-only row keyed by the conversation UUID. Live wins over // persisted on conflicting entries (registered last). const aliasToOwner = new Map(); for (const p of sources.persisted ?? []) { if (p.claudeSessionId !== undefined && p.claudeSessionId !== p.id) aliasToOwner.set(p.claudeSessionId, p.id); } for (const v of sources.live ?? []) { if (v.claudeSessionId !== undefined && v.claudeSessionId !== v.id) aliasToOwner.set(v.claudeSessionId, v.id); } const resolveId = (sessionId: string): string => aliasToOwner.get(sessionId) ?? sessionId; // 1) history (lowest precedence; keys resolve through the alias map) for (const h of sources.history ?? []) { const item = ensureItem(map, resolveId(h.sessionId)); addSource(item, 'history'); overwrite(item, 'workingDir', h.workingDir); overwrite(item, 'sizeBytes', h.sizeBytes); overwrite(item, 'firstPrompt', h.firstPrompt); overwrite(item, 'lastPrompt', h.lastPrompt); overwrite(item, 'projectKey', h.projectKey); const ms = Date.parse(h.lastModified); if (!Number.isNaN(ms) && item.lastActivityAt === undefined) item.lastActivityAt = ms; } // 2) lifecycle — entries arrive NEWEST-first, so first-seen wins for // name/mode (mirrors the lastActivityAt guard); unconditional overwrites // would leave the OLDEST entry in the window (stale name/mode) standing. for (const l of sources.lifecycle ?? []) { const item = ensureItem(map, resolveId(l.sessionId)); addSource(item, 'lifecycle'); if (item.name === undefined) overwrite(item, 'name', l.name); if (item.mode === undefined) overwrite(item, 'mode', l.mode); if (item.lastActivityAt === undefined && typeof l.ts === 'number') item.lastActivityAt = l.ts; } // 3) persisted for (const p of sources.persisted ?? []) { const item = ensureItem(map, p.id); addSource(item, 'persisted'); overwrite(item, 'name', p.name); overwrite(item, 'mode', p.mode); overwrite(item, 'status', p.status); overwrite(item, 'workingDir', p.workingDir); overwrite(item, 'createdAt', p.createdAt); overwrite(item, 'lastActivityAt', p.lastActivityAt); overwrite(item, 'pinned', p.pinned); overwrite(item, 'pinnedAt', p.pinnedAt); } // 4) live (highest precedence) for (const v of sources.live ?? []) { const item = ensureItem(map, v.id); addSource(item, 'live'); overwrite(item, 'name', v.name); overwrite(item, 'mode', v.mode); overwrite(item, 'status', v.status); overwrite(item, 'isWorking', v.isWorking); overwrite(item, 'workingDir', v.workingDir); overwrite(item, 'createdAt', v.createdAt); overwrite(item, 'lastActivityAt', v.lastActivityAt); overwrite(item, 'claudeSessionId', v.claudeSessionId); overwrite(item, 'pinned', v.pinned); overwrite(item, 'pinnedAt', v.pinnedAt); } // 5) mux stats + remote flag (create item if mux-only) for (const m of sources.mux ?? []) { const item = ensureItem(map, m.sessionId); addSource(item, 'mux'); overwrite(item, 'mode', m.mode); if (m.stats) item.stats = m.stats; if (m.remote !== undefined) item.remote = m.remote; } // firstPrompt backfill (COD-140): the only source that sets firstPrompt is the // transcript-history view, keyed by the Claude transcript file's UUID. A live/persisted // row keyed by its Codeman id only inherits firstPrompt when that id happens to equal an // on-disk transcript UUID. When it doesn't (stale/wrong claudeSessionId, post-/clear new // uuid, resumed/attached/worktree session, transcript not yet flushed), the row shows // "(no prompt captured)" even though a real transcript for that working dir exists under a // different UUID. Backfill from the already-passed history: first try the claudeSessionId // join, then the newest transcript in the same workingDir. Never overwrite a non-empty // firstPrompt (so rows keyed to their own transcript are untouched). // // The workingDir guess is a last resort and MUST be skipped for any item that // already has its own 'history' entry (step 1 above already gave it a real, // direct scan of its own transcript). Without this guard, a history row whose // OWN extraction genuinely failed (oversized first message, etc.) silently // inherited the newest OTHER session's opening line from the same directory — // not a blank, but actively wrong: old sessions displayed today's conversation // as if it were their own. A row with no 'history' source at all (its // transcript hasn't been linked/scanned under its own id yet) has no such // direct attempt to prefer, so the guess remains a reasonable stand-in there. const firstPromptByUuid = new Map(); const firstPromptByWorkingDir = new Map(); // COD-145: lastPrompt rides the same backfill (build parallel indexes; never overwrite). const lastPromptByUuid = new Map(); const lastPromptByWorkingDir = new Map(); for (const h of sources.history ?? []) { const ms = Date.parse(h.lastModified); const ts = Number.isNaN(ms) ? -Infinity : ms; if (h.firstPrompt) { firstPromptByUuid.set(h.sessionId, h.firstPrompt); if (h.workingDir) { const existing = firstPromptByWorkingDir.get(h.workingDir); if (!existing || ts > existing.ms) { firstPromptByWorkingDir.set(h.workingDir, { prompt: h.firstPrompt, ms: ts }); } } } if (h.lastPrompt) { lastPromptByUuid.set(h.sessionId, h.lastPrompt); if (h.workingDir) { const existing = lastPromptByWorkingDir.get(h.workingDir); if (!existing || ts > existing.ms) { lastPromptByWorkingDir.set(h.workingDir, { prompt: h.lastPrompt, ms: ts }); } } } } for (const item of map.values()) { const hasOwnHistoryEntry = item.sources.includes('history'); if (!item.firstPrompt) { // never overwrite an existing non-empty prompt const byUuid = item.claudeSessionId ? firstPromptByUuid.get(item.claudeSessionId) : undefined; if (byUuid) { item.firstPrompt = byUuid; } else if (item.workingDir && !hasOwnHistoryEntry) { const byDir = firstPromptByWorkingDir.get(item.workingDir); if (byDir) item.firstPrompt = byDir.prompt; } } if (!item.lastPrompt) { const byUuid = item.claudeSessionId ? lastPromptByUuid.get(item.claudeSessionId) : undefined; if (byUuid) { item.lastPrompt = byUuid; } else if (item.workingDir && !hasOwnHistoryEntry) { const byDir = lastPromptByWorkingDir.get(item.workingDir); if (byDir) item.lastPrompt = byDir.prompt; } } } // Meaningfulness floor: keep real rows, drop bare lifecycle/mux-only noise. const kept: UnifiedSessionItem[] = []; for (const item of map.values()) { const isReal = item.sources.includes('live') || item.sources.includes('persisted') || item.sources.includes('history') || (item.firstPrompt !== undefined && item.firstPrompt !== ''); if (isReal) kept.push(item); } // Stable sort (COD-139): pinned group first (pinnedAt desc, most-recently-pinned // first), then unpinned by lastActivityAt desc (undefined last), createdAt desc, // sessionId asc. kept.sort((a, b) => { const pa = a.pinned === true; const pb = b.pinned === true; if (pa !== pb) return pa ? -1 : 1; // pinned floats above unpinned if (pa && pb) { // Both pinned: most-recently-pinned first (undefined pinnedAt sorts last). const ta = a.pinnedAt; const tb = b.pinnedAt; if (ta !== tb) { if (ta === undefined) return 1; if (tb === undefined) return -1; return tb - ta; } // tie-break falls through to the activity/createdAt/id rules below. } const la = a.lastActivityAt; const lb = b.lastActivityAt; if (la !== lb) { if (la === undefined) return 1; if (lb === undefined) return -1; return lb - la; } const ca = a.createdAt; const cb = b.createdAt; if (ca !== cb) { if (ca === undefined) return 1; if (cb === undefined) return -1; return cb - ca; } return a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : 0; }); return kept; } /** * Case-insensitive substring filter (name + firstPrompt + lastPrompt + workingDir + sessionId) * with offset/limit paging. `total` is the filtered count BEFORE paging. */ export function filterAndPaginate( items: UnifiedSessionItem[], opts: { q?: string; offset?: number; limit?: number } ): { sessions: UnifiedSessionItem[]; total: number } { const q = (opts.q ?? '').trim().toLowerCase(); const filtered = q ? items.filter((it) => { const hay = [it.name, it.firstPrompt, it.lastPrompt, it.workingDir, it.sessionId] .filter((v): v is string => typeof v === 'string') .join(' ') .toLowerCase(); return hay.includes(q); }) : items; const total = filtered.length; const offset = Math.max(0, Math.floor(opts.offset ?? 0)); const limit = Math.min(500, Math.max(1, Math.floor(opts.limit ?? 100))); const sessions = filtered.slice(offset, offset + limit); return { sessions, total }; }