import { readdirSync, readFileSync, existsSync, statSync, mkdirSync, rmSync, appendFileSync, openSync, readSync, closeSync, realpathSync, } from 'node:fs'; import { dirname, join } from 'node:path'; import { DATA_PATH } from '../../data-path'; // ── Claude session store (source of truth) ── // The `claude` CLI persists every session as a JSONL transcript at // $HOME/.claude/projects//.jsonl // where is the working directory with every non-alphanumeric char replaced by '-'. // Claude runs with no isolation for the OWNER — HOME is their real home — so its transcripts are the same // store their terminal `claude` uses. We never keep our own copy; Claude's files are authoritative. // // ── Why this takes a home instead of an email ── // // It used to be `process.env.HOME_DIR ?? join(DATA_PATH, email, 'home')`, which discards its argument whenever // HOME_DIR is set — which is always, on a real install. Every read therefore resolved to the OWNER'S // transcripts regardless of who was asking, and the comment above it said "single-user platform" as though // that were a property rather than an assumption. A member reaching these functions would have been handed the // owner's conversation list. // // So the home arrives resolved, from `resolveHomeDir(userId)`, and this file has no way to invent one. // // The email travels alongside it rather than being derived from it, because the two answer different // questions: the email says WHO, the home says WHERE. They were briefly conflated in the other direction — // `general_chat_sessions` was an email-derived path under DATA_PATH used as a chat's working directory, and // because `confineUserTree` makes every sibling of a home the platform's at 0700, a member's turn started in // a directory it could not enter. That default is now the caller's own home; the pairing survives because the // distinction it encodes is real. export type ChatIdentity = { email: string; /** From `resolveHomeDir`. Never `getOwnerHomeDir`, which ignores its argument. */ home: string; }; const claudeProjectsDir = (home: string): string => join(home, '.claude', 'projects'); /** Claude's folder name for a working directory. */ export const projectSlug = (cwd: string): string => cwd.replace(/[^a-zA-Z0-9]/g, '-'); /** What a session's continuation looks like to the app. See `applyLineage` for how it is derived. */ export type ClaudeSessionOrigin = { id: string; title: string }; export type ClaudeSessionSummary = { id: string; // session uuid (= transcript filename) title: string; // explicit title, else first human message, else a number in a /clear chain cwd: string; createdAt: string; // ISO, first entry updatedAt: string; // ISO, last entry messageCount: number; // human + assistant turns harness?: 'claude' | 'opencode'; // which harness produced it (defaults to claude when absent) /** This transcript opens with `/clear`, so it is the continuation of an earlier conversation. */ bornFromClear?: boolean; /** The conversation this one continues, when it could be identified. Inferred — see `applyLineage`. */ continuedFrom?: ClaudeSessionOrigin | null; /** * How many transcripts this row stands for. Present only on a `/clear` chain, which the list shows as * one conversation — see `mergeChains`. Absent means the ordinary case: one row, one transcript. */ partCount?: number; }; /** `ClaudeSessionSummary` plus the bits only lineage resolution needs. Never leaves this module. */ type TranscriptSummary = ClaudeSessionSummary & { /** The title came from a `summary` record — Claude's own, or an Officer rename. Never overwrite it. */ hasExplicitTitle: boolean; /** * Every minute in which this transcript recorded anything (epoch minutes). * * Lineage needs "was this conversation alive at the instant that one started", and `updatedAt` cannot * answer it: a session that is cleared and then **resumed later** has an `updatedAt` long after its * child was born. Two of the three cleared sessions on this machine were in exactly that state, so * matching on `updatedAt` found no parent for either. Minute buckets survive resumption and cost one * integer per minute of activity rather than one per entry. */ activeMinutes: Set; }; /** Pull the plain text out of a transcript entry's `message.content` (string or content-block array). */ function entryText(message: unknown): string { if (!message || typeof message !== 'object') return ''; const content = (message as { content?: unknown }).content; if (typeof content === 'string') return content; if (Array.isArray(content)) { return content .map((block) => block && typeof block === 'object' && (block as { type?: string }).type === 'text' ? ((block as { text?: string }).text ?? '') : '', ) .join('') .trim(); } return ''; } type Entry = { type?: string; timestamp?: string; sessionId?: string; cwd?: string; uuid?: string; summary?: string; message?: { role?: string; content?: unknown }; isMeta?: boolean; /** `system` entries carry their kind here — `compact_boundary` is the one this reader cares about. */ subtype?: string; /** Present on a `compact_boundary`. camelCase on disk; the live SDK stream uses snake_case. */ compactMetadata?: { trigger?: string; preTokens?: number; durationMs?: number }; }; /** * Summaries keyed by path, invalidated on mtime. A group's transcripts are re-read on every list AND * on every session open, and they run to tens of megabytes here — the same file was being parsed * several times a second. An mtime key is exact for this store: Claude only ever appends. */ const summaryCache = new Map(); function summarizeTranscript(filePath: string, id: string): TranscriptSummary | null { let mtimeMs: number; let mtime: string; try { const stat = statSync(filePath); mtimeMs = stat.mtimeMs; mtime = stat.mtime.toISOString(); } catch { return null; } const cached = summaryCache.get(filePath); if (cached && cached.mtimeMs === mtimeMs) return cached.summary; let raw: string; try { raw = readFileSync(filePath, 'utf-8'); } catch { return null; } let firstUserText = ''; let summaryTitle = ''; // a `summary` entry (our rename, appended to the transcript) wins over the first message let cwd = ''; let firstTs = ''; let lastTs = ''; let messageCount = 0; let sawFirstUserEntry = false; let bornFromClear = false; const activeMinutes = new Set(); for (const line of raw.split('\n')) { if (!line.trim()) continue; let entry: Entry; try { entry = JSON.parse(line) as Entry; } catch { continue; } if (entry.cwd && !cwd) cwd = entry.cwd; if (entry.timestamp) { if (!firstTs) firstTs = entry.timestamp; lastTs = entry.timestamp; const at = Date.parse(entry.timestamp); if (Number.isFinite(at)) activeMinutes.add(Math.floor(at / 60_000)); } if (entry.type === 'summary' && typeof entry.summary === 'string') { summaryTitle = entry.summary; // last one wins } else if (entry.type === 'user' || entry.type === 'assistant') { messageCount += 1; if (entry.type === 'user' && !entry.isMeta) { const text = entryText(entry.message).split('\n')[0]!.trim(); // Claude records `/clear`, `/compact` and friends as ordinary user entries wrapping // `…`, and does NOT set `isMeta` on them. Two consequences: // - a slash command is not a title, so skip past them for `firstUserText` (every cleared // session used to be listed as "/clear"); // - a transcript whose very first user entry is `/clear` was opened by clearing another // conversation, which is the only trace of that handoff Claude leaves anywhere. if (text && !sawFirstUserEntry) { sawFirstUserEntry = true; bornFromClear = text.startsWith('/clear'); } // A resumed session can open with a background task reporting in, which is no more a title than a // slash command is — see `isTaskNotification`. if (text && !firstUserText && !text.startsWith('') && !isTaskNotification(text)) { firstUserText = text; } } } } const summary: TranscriptSummary = { id, title: summaryTitle || firstUserText || '(untitled)', hasExplicitTitle: Boolean(summaryTitle), bornFromClear, cwd, createdAt: firstTs || mtime, updatedAt: lastTs || mtime, messageCount, activeMinutes, }; summaryCache.set(filePath, { mtimeMs, summary }); return summary; } /** * Was this conversation writing to disk around the given instant? * * `/clear` happens inside one process, so the old transcript's last write and the new transcript's * first are the same moment — 4ms apart in the case measured here (…28.525Z → …28.529Z). * * The window is **symmetric**, which is not obvious and was found the hard way. Clearing makes Claude * summarise the conversation it is ending, and that costs a model call: the parent's final `summary` * record can therefore land *after* the child's first entry. One session on this machine sits exactly * there — its parent's nearest write is a minute the wrong side of the birth — and a * before-only window silently lost it. * * It asks about *activity*, never about `updatedAt`. A conversation that is cleared and then **resumed * later** has an `updatedAt` days after its child was born, so an end-time test discards that parent * outright. */ const wasActiveAt = (session: TranscriptSummary, at: number): boolean => { const minute = Math.floor(at / 60_000); return ( session.activeMinutes.has(minute - 1) || session.activeMinutes.has(minute) || session.activeMinutes.has(minute + 1) ); }; /** "Platform Arch" → "Platform Arch 2" → "Platform Arch 3". */ function nextInChain(parentTitle: string): string { // A title already ending in a number continues its count. This does mean a conversation genuinely // called "Sidecar stage 3" would be continued as "Sidecar stage 4" — accepted, because it is a // display-only guess and renaming overrides it. const numbered = parentTitle.match(/^(.*\S)\s+(\d+)$/); if (numbered) return `${numbered[1]} ${Number(numbered[2]) + 1}`; return `${parentTitle} 2`; } /** * Link each `/clear`-born session to the conversation it continues, and give it a numbered title. * * **This is an inference, and the only one in this file.** Claude records no parent: not in * `compactMetadata`/`logicalParentUuid` (those are in-file compaction), not in `summary.leafUuid` * (every one resolves inside its own transcript), not in `slug` (random per session), not in * `~/.claude/sessions/*.json` (a live process registry). What it does record is that the new transcript * opens with `/clear` and that the old one was still writing that same second — so the parent is the * conversation in the same group that was alive at the instant this one began. * * **Ambiguity fails closed.** If two transcripts in the group were active in that minute — two claude * processes in one directory, which does happen here — neither is named. No parent is a much smaller * mistake than the wrong parent, because the wrong parent also renames the conversation. * * Read-only: nothing is written back to Claude's store, so a wrong guess is cosmetic and disappears on * the next read. An explicit title always wins — this never overwrites a rename. * * (`/compact` needs none of this. It appends to the same transcript and keeps the session id.) */ function applyLineage(summaries: TranscriptSummary[]): TranscriptSummary[] { // Oldest first, so a parent's own computed title is settled before its child asks for it and a chain // of clears numbers straight through. const ordered = [...summaries].sort((a, b) => a.createdAt.localeCompare(b.createdAt)); const resolved = new Map(); for (const session of ordered) { const out: TranscriptSummary = { ...session }; const startedAt = Date.parse(session.createdAt); if (session.bornFromClear && Number.isFinite(startedAt)) { const candidates = ordered.filter((other) => other.id !== session.id && wasActiveAt(other, startedAt)); const only = candidates.length === 1 ? candidates[0]! : null; // A parent resolved earlier in this loop may itself have been renumbered, so prefer that copy — // that is what makes "Arch" → "Arch 2" → "Arch 3" work instead of two conversations called "Arch 2". const parent = only ? (resolved.get(only.id) ?? only) : null; if (parent) { out.continuedFrom = { id: parent.id, title: parent.title }; if (!session.hasExplicitTitle) out.title = nextInChain(parent.title); } } resolved.set(session.id, out); } return summaries.map((session) => resolved.get(session.id) ?? session); } /** Drop the fields the app has no business seeing — `activeMinutes` would go over the wire as `{}`. */ function publish(session: TranscriptSummary): ClaudeSessionSummary { const { hasExplicitTitle: _hasExplicitTitle, activeMinutes: _activeMinutes, ...rest } = session; return rest; } /** A session and every conversation it continues, oldest first. Cycles cannot happen, but guard anyway. */ function chainOf(head: TranscriptSummary, byId: Map): TranscriptSummary[] { const parts: TranscriptSummary[] = []; const seen = new Set(); let cursor: TranscriptSummary | undefined = head; while (cursor && !seen.has(cursor.id)) { seen.add(cursor.id); parts.unshift(cursor); cursor = cursor.continuedFrom ? byId.get(cursor.continuedFrom.id) : undefined; } return parts; } /** * Show a `/clear` chain as the one conversation it is: a single row, standing for every part. * * The row is the **newest** link, not the oldest, because that is the only one you can carry on — a * cleared session is finished, and `--resume` on it would start a second branch. Everything else on the * row is the whole chain's: the root's start time, the summed message count, and the root's title, so a * conversation does not rename itself every time you clear it. A rename still wins, and it is the head * that carries it, which is the same id the row links to and acts on. * * `continuedFrom` is cleared here on purpose. It described a hop the reader now never has to make — * `loadChainTranscript` has already spliced the parts into one transcript — and leaving it would put a * "continues Platform Arch" line above a conversation that visibly contains Platform Arch. */ function mergeChains(sessions: TranscriptSummary[]): TranscriptSummary[] { const byId = new Map(sessions.map((session) => [session.id, session])); const continued = new Set(sessions.flatMap((session) => (session.continuedFrom ? [session.continuedFrom.id] : []))); return sessions .filter((session) => !continued.has(session.id)) .map((head) => { const parts = chainOf(head, byId); if (parts.length < 2) return head; const root = parts[0]!; return { ...head, title: head.hasExplicitTitle ? head.title : root.title, createdAt: root.createdAt, messageCount: parts.reduce((total, part) => total + part.messageCount, 0), partCount: parts.length, continuedFrom: null, }; }); } // ── Loading a full transcript for display ── // App-facing message shape (matches the frontend ChatMessage union), rebuilt from Claude's blocks. export type ClaudeChatMessage = | { role: 'user'; text: string } | { role: 'assistant'; id: string; text: string } | { role: 'tool'; toolName: string; toolInput: Record; toolCallId: string; output?: string; isError?: boolean; } // Where one part of a `/clear` chain ends and the next begins. Only ever produced by // `loadChainTranscript`; `sessionId` is the part that ENDS here, which gives the row an identity the // virtualised list can key on. It has to be visible, not smoothed over: the parts read as one // conversation to you, but the agent's context was emptied at this line and nothing above it is in // its memory. | { role: 'divider'; sessionId: string } /** A turn you stopped. See `INTERRUPTION_MARKERS`. */ | { role: 'interrupted' } // Where the agent rewrote its own context. Kept for the same reason as `divider`: the conversation // above it is still yours to read, and the agent's memory of it is a summary. It is also the answer to // "why did it go quiet for two minutes there", which is only useful if it survives a reload. | { role: 'compact'; trigger: 'manual' | 'auto'; preTokens?: number; durationMs?: number; done: true }; /** * Claude records an interrupted turn by writing one of these as the *user's* next message — it is how the * model is told, on the next turn, that it was cut off. Replayed literally it reads as something you * typed, so the transcript showed a message you never sent. Matched whole-string only: this text appears * inside real messages too (this file's own conversation being one), and those are genuinely yours. */ const INTERRUPTION_MARKERS = new Set(['[Request interrupted by user]', '[Request interrupted by user for tool use]']); type ContentBlock = | { type: 'text'; text?: string } | { type: 'thinking' } | { type: 'tool_use'; id: string; name: string; input?: Record } | { type: 'tool_result'; tool_use_id: string; is_error?: boolean | null; content?: unknown }; function blockText(content: unknown): string { if (typeof content === 'string') return content; if (Array.isArray(content)) { return content .map((b) => b && typeof b === 'object' && (b as { type?: string }).type === 'text' ? ((b as { text?: string }).text ?? '') : '', ) .join(''); } return ''; } /** * A background task reporting in. The harness delivers these to the agent by writing them as the *user's* * next message — the same trick as `INTERRUPTION_MARKERS`, and the same consequence: replayed literally, * the transcript shows a raw XML envelope in a bubble on your side of the conversation that you never * typed. Live it never appears, because a finished task travels as its own `task:notification` event; it * surfaces only when a refresh rebuilds the conversation from Claude's file. * * Anchored to the start of the message rather than matched anywhere in it, so quoting one inside a real * message — this file's own conversation does exactly that — stays yours. */ const isTaskNotification = (text: string) => text.trimStart().startsWith(''); function userMessageFrom(text: string): ClaudeChatMessage | null { if (isTaskNotification(text)) return null; if (INTERRUPTION_MARKERS.has(text.trim())) return { role: 'interrupted' }; return { role: 'user', text }; } export type ClaudeSessionDetail = { id: string; model: string; cwd: string; messages: ClaudeChatMessage[] }; /** Parse a session's JSONL transcript into a flat, display-ready message list. Claude is the source. */ function parseClaudeTranscript(filePath: string, sessionId: string, fallbackCwd = ''): ClaudeSessionDetail | null { if (!existsSync(filePath)) return null; const messages: ClaudeChatMessage[] = []; const toolById = new Map>(); let model = ''; let sessionCwd = fallbackCwd; for (const line of readFileSync(filePath, 'utf-8').split('\n')) { if (!line.trim()) continue; let entry: Entry & { message?: { role?: string; content?: unknown; model?: string } }; try { entry = JSON.parse(line); } catch { continue; } // First cwd wins — a session's home is where it was launched, which is how Claude Code files the // transcript on disk and how parseSummary groups it in the list. Later entries record wherever a tool // happened to cd to, so taking the last one made the detail disagree with the list: opening a session // moved the cwd picker to a subdirectory. It also let a stale entry clobber `fallbackCwd` even when the // caller already knew the group it was loading from. if (entry.cwd && !sessionCwd) sessionCwd = entry.cwd; if (entry.message?.model && !model) model = entry.message.model; const content = entry.message?.content; if (entry.type === 'system' && entry.subtype === 'compact_boundary') { const meta = entry.compactMetadata ?? {}; messages.push({ role: 'compact', trigger: meta.trigger === 'manual' ? 'manual' : 'auto', preTokens: meta.preTokens, durationMs: meta.durationMs, done: true, }); continue; } if (entry.type === 'user' && !entry.isMeta) { if (typeof content === 'string') { const message = content.trim() ? userMessageFrom(content) : null; if (message) messages.push(message); continue; } if (Array.isArray(content)) { for (const block of content as ContentBlock[]) { if (block.type === 'text' && block.text?.trim()) { const message = userMessageFrom(block.text); if (message) messages.push(message); } else if (block.type === 'tool_result') { const tool = toolById.get(block.tool_use_id); if (tool) { tool.output = blockText(block.content); tool.isError = block.is_error === true; } } } } continue; } if (entry.type === 'assistant' && Array.isArray(content)) { for (const block of content as ContentBlock[]) { if (block.type === 'text' && block.text?.trim()) { messages.push({ role: 'assistant', id: `${sessionId}-${messages.length}`, text: block.text }); } else if (block.type === 'tool_use') { const tool = { role: 'tool' as const, toolName: block.name, toolInput: block.input ?? {}, toolCallId: block.id, }; messages.push(tool); toolById.set(block.id, tool); } } } } return { id: sessionId, model, cwd: sessionCwd, messages }; } /** * Splice in everything this conversation continues, so a `/clear` chain reads as one transcript. * * `/clear` is a fact about Claude's process, not about what you were doing — you were having one * conversation, and it left you with three unrelated-looking rows. The parts are concatenated * oldest-first with a `divider` between them, and the divider is the honest part: your history is * continuous, the agent's context is not. * * Only ancestors, never descendants. Ancestry is what "continues" means, and it keeps the returned id * resumable — a mid-chain deep link from an old bookmark shows its own story up to that point rather * than a conversation that carries on past where it was addressed. * * Pagination needs nothing: the client asks for index windows into whatever the server calls the * transcript, so a longer one simply pages further back. */ function loadChainTranscript(who: ChatIdentity, detail: ClaudeSessionDetail): ClaudeSessionDetail { const parts = (() => { const group = scanGroup(who, detail.cwd); const head = group.find((session) => session.id === detail.id); return head ? chainOf(head, new Map(group.map((session) => [session.id, session]))) : []; })(); if (parts.length < 2) return detail; const dir = join(claudeProjectsDir(who.home), projectSlug(detail.cwd)); const earlier: ClaudeChatMessage[] = []; for (const part of parts.slice(0, -1)) { const segment = parseClaudeTranscript(join(dir, `${part.id}.jsonl`), part.id, detail.cwd); if (!segment) continue; earlier.push(...segment.messages, { role: 'divider', sessionId: part.id }); } return { ...detail, messages: [...earlier, ...detail.messages] }; } /** Load a session when its cwd (project group) is known. */ export function loadClaudeSession(who: ChatIdentity, cwd: string, sessionId: string): ClaudeSessionDetail | null { const detail = parseClaudeTranscript( join(claudeProjectsDir(who.home), projectSlug(cwd), `${sessionId}.jsonl`), sessionId, cwd, ); return detail && loadChainTranscript(who, detail); } /** Resolve a session by id ALONE — scan every project group for its transcript. Used on a deep-link / * refresh to /chat/, when the cwd isn't known yet; the transcript records the real cwd, which the * caller uses to scope the list + cwd picker. */ export function loadClaudeSessionById(who: ChatIdentity, sessionId: string): ClaudeSessionDetail | null { const projectsDir = claudeProjectsDir(who.home); let slugs: string[]; try { slugs = readdirSync(projectsDir); } catch { return null; } for (const slug of slugs) { const filePath = join(projectsDir, slug, `${sessionId}.jsonl`); if (!existsSync(filePath)) continue; const detail = parseClaudeTranscript(filePath, sessionId); return detail && loadChainTranscript(who, detail); } return null; } /** * The transcript file for a session: in the named group if it is there, otherwise wherever it actually * is. The caller's cwd is a hint, not an authority — a session's group is a property of the session, * and the two disagree routinely (the list is showing one group while you act on a row from another, * or a deep link hasn't resolved its group yet). Reads have always fallen back like this; writes did * not, so delete and rename returned "not found" for a session that was plainly on screen. */ function findTranscript(who: ChatIdentity, cwd: string, sessionId: string): string | null { const preferred = join(claudeProjectsDir(who.home), projectSlug(cwd), `${sessionId}.jsonl`); if (existsSync(preferred)) return preferred; const projectsDir = claudeProjectsDir(who.home); let slugs: string[]; try { slugs = readdirSync(projectsDir); } catch { return null; } for (const slug of slugs) { const filePath = join(projectsDir, slug, `${sessionId}.jsonl`); if (existsSync(filePath)) return filePath; } return null; } /** * Delete a conversation by removing its transcript file — and, when it is a `/clear` chain, the files * of every part it continues. Returns false if it didn't exist. * * The chain is resolved from the transcript's OWN directory, not the caller's `cwd`: the two disagree * routinely (see `findTranscript`), and the wrong group would find no chain and silently delete one * part of several. */ export function deleteClaudeSession(who: ChatIdentity, cwd: string, sessionId: string): boolean { const filePath = findTranscript(who, cwd, sessionId); if (!filePath) return false; const ownCwd = firstCwd(filePath); const ids = ownCwd ? chainFileIds(who, ownCwd, sessionId) : [sessionId]; const dir = dirname(filePath); for (const id of ids) { const partPath = join(dir, `${id}.jsonl`); if (existsSync(partPath)) rmSync(partPath); } return true; } /** * Rename a session by appending a `summary` entry to its transcript — Claude's own on-disk format, so * the title lives in .claude (source of truth). Our reader takes the last summary as the title; no * timestamp is written so the rename doesn't reorder the list. */ export function renameClaudeSession(who: ChatIdentity, cwd: string, sessionId: string, title: string): boolean { const filePath = findTranscript(who, cwd, sessionId); if (!filePath) return false; // Attach the summary to the transcript's tip (the last entry carrying a uuid). let leafUuid = sessionId; const lines = readFileSync(filePath, 'utf-8').split('\n'); for (let i = lines.length - 1; i >= 0; i--) { if (!lines[i]!.trim()) continue; try { const entry = JSON.parse(lines[i]!) as Entry; if (entry.uuid) { leafUuid = entry.uuid; break; } } catch { /* skip */ } } appendFileSync(filePath, `${JSON.stringify({ type: 'summary', summary: title, leafUuid })}\n`); return true; } // ── Background tasks ── // A background task (run_in_background, Monitor, a backgrounded subagent) reports nothing over the wire // between `task:started` and its notification — the only thing that knows what it is doing is the file // Claude Code streams it into: // // $TMPDIR/claude-///tasks/.output // // For a shell task that file IS the output. For an agent task it is a SYMLINK to the subagent's own // transcript under ~/.claude/projects///subagents/agent-.jsonl — which is the // same JSONL format as any other session, so the parser above reads it unchanged. Both kinds are // reachable from the one directory, which is why we look there first; the projects tree is the fallback // for when /tmp has been cleared out from under a still-referenced agent transcript. // // Resolution is by task id ALONE, deliberately. The client learns a task id from `task:started` and // nothing else — officer's per-connection session key is not Claude's session uuid, and the uuid only // arrives with the turn `result`, long after the tray needs to show the task. /** Task ids come from Claude, but they reach us through a URL — never let one address a parent. */ const TASK_ID_RE = /^[A-Za-z0-9_-]{1,64}$/; /** How much of a shell task's log to return. It is a tail: the end is the part that is still moving. */ const LOG_TAIL_BYTES = 64 * 1024; /** Per tool result. A background agent's greps run to megabytes and the tray shows a preview, not a file. */ const OUTPUT_CAP = 4000; export type BackgroundTaskDetail = | { kind: 'agent'; messages: ClaudeChatMessage[] } | { kind: 'log'; text: string; truncated: boolean }; function findTaskOutput(who: ChatIdentity, taskId: string): string | null { const tmpRoot = process.env.TMPDIR ?? '/tmp'; const candidates: [string, string][] = [ [tmpRoot, `claude-*/*/*/tasks/${taskId}.output`], [claudeProjectsDir(who.home), `*/*/subagents/agent-${taskId}.jsonl`], ]; for (const [root, pattern] of candidates) { try { for (const rel of new Bun.Glob(pattern).scanSync({ cwd: root, followSymlinks: false, onlyFiles: false })) { return join(root, rel); } } catch { /* root doesn't exist — try the next one */ } } return null; } /** The tail of a file, as text, without reading the whole thing. */ function tailFile(filePath: string, bytes: number): { text: string; truncated: boolean } { const size = statSync(filePath).size; const start = Math.max(0, size - bytes); let fd: number | undefined; try { fd = openSync(filePath, 'r'); const buf = Buffer.alloc(size - start); const n = readSync(fd, buf, 0, buf.length, start); let text = buf.toString('utf-8', 0, n); // A byte offset lands mid-line (and possibly mid-codepoint); drop the partial head. if (start > 0) text = text.slice(text.indexOf('\n') + 1); return { text, truncated: start > 0 }; } finally { if (fd !== undefined) closeSync(fd); } } /** * What a background task is doing right now. Returns null when nothing has been written yet — which is * the normal state for the first second or two of a task's life, not an error. */ export function loadBackgroundTask(who: ChatIdentity, taskId: string): BackgroundTaskDetail | null { if (!TASK_ID_RE.test(taskId)) return null; const found = findTaskOutput(who, taskId); if (!found) return null; let target = found; try { target = realpathSync(found); } catch { return null; // a dangling symlink — the task registered before its transcript existed } if (target.endsWith('.jsonl')) { const detail = parseClaudeTranscript(target, taskId); if (!detail) return null; const messages = detail.messages.map((m) => m.role === 'tool' && m.output && m.output.length > OUTPUT_CAP ? { ...m, output: `${m.output.slice(0, OUTPUT_CAP)}\n… (truncated)` } : m, ); return { kind: 'agent', messages }; } try { const { text, truncated } = tailFile(target, LOG_TAIL_BYTES); return { kind: 'log', text, truncated }; } catch { return null; } } // ── Working directories (pwds) ── // Every dir you've run Claude in shows up as a project group under ~/.claude/projects. We read the // real `cwd` back from each group's transcripts so the UI can offer "jump to any project's sessions". /** Read the `cwd` recorded in a transcript, from a bounded head read (cwd appears in early entries). */ function firstCwd(filePath: string): string { let fd: number | undefined; try { fd = openSync(filePath, 'r'); const buf = Buffer.alloc(32768); const n = readSync(fd, buf, 0, buf.length, 0); return buf.toString('utf-8', 0, n).match(/"cwd":"([^"]*)"/)?.[1] ?? ''; } catch { return ''; } finally { if (fd !== undefined) closeSync(fd); } } export type ClaudePwd = { cwd: string; sessionCount: number; updatedAt: string; isDefault: boolean }; /** All working directories that have Claude sessions, plus the caller's home. Newest first. */ export function listClaudePwds(who: ChatIdentity): ClaudePwd[] { const projectsDir = claudeProjectsDir(who.home); const defaultCwd = who.home; const byCwd = new Map(); if (existsSync(projectsDir)) { for (const group of readdirSync(projectsDir)) { const groupDir = join(projectsDir, group); let files: string[]; try { files = readdirSync(groupDir).filter((f) => f.endsWith('.jsonl')); } catch { continue; // not a directory } if (files.length === 0) continue; const cwd = firstCwd(join(groupDir, files[0]!)); if (!cwd) continue; let updatedAt = ''; for (const f of files) { const m = statSync(join(groupDir, f)).mtime.toISOString(); if (m > updatedAt) updatedAt = m; } const prev = byCwd.get(cwd); byCwd.set(cwd, { count: (prev?.count ?? 0) + files.length, updatedAt: prev && prev.updatedAt > updatedAt ? prev.updatedAt : updatedAt, }); } } if (!byCwd.has(defaultCwd)) byCwd.set(defaultCwd, { count: 0, updatedAt: '' }); return [...byCwd.entries()] .map(([cwd, v]) => ({ cwd, sessionCount: v.count, updatedAt: v.updatedAt, isDefault: cwd === defaultCwd })) .sort((a, b) => (a.isDefault ? -1 : b.isDefault ? 1 : b.updatedAt.localeCompare(a.updatedAt))); } /** * Every transcript in a working directory, one entry each, with lineage resolved. * * Lineage is worked out across the whole group at once — a session's parent is only knowable relative * to its neighbours, so there is no per-file answer to cache. The per-file summaries underneath it are * mtime-cached, which is what makes calling this on every request cheap. */ function scanGroup(who: ChatIdentity, cwd: string): TranscriptSummary[] { const dir = join(claudeProjectsDir(who.home), projectSlug(cwd)); if (!existsSync(dir)) return []; const sessions: TranscriptSummary[] = []; for (const file of readdirSync(dir)) { if (!file.endsWith('.jsonl')) continue; const summary = summarizeTranscript(join(dir, file), file.replace(/\.jsonl$/, '')); if (summary) sessions.push(summary); } return applyLineage(sessions); } /** Conversations Claude has stored for a working directory, newest first, one row per `/clear` chain. */ export function listClaudeSessions(who: ChatIdentity, cwd: string): ClaudeSessionSummary[] { return mergeChains(scanGroup(who, cwd)) .map(publish) .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); } /** * One session's list-level facts: the title it is shown under, and how many parts it spans. * * A transcript cannot answer either on its own — both are properties of the `/clear` chain it belongs * to — so this goes through the group listing. It exists so the open conversation is labelled the same * way its row is, instead of the two drifting. * * The fallback matters: the merged list only holds chain heads, so a link to a part in the middle of a * chain (an old bookmark, or the parent link from before merging) finds nothing there. Such a link * still opens a real transcript, so answer for the part itself rather than 404 the title. */ export function claudeSessionContext( who: ChatIdentity, cwd: string, sessionId: string, ): { title: string; partCount: number } | null { const merged = listClaudeSessions(who, cwd).find((entry) => entry.id === sessionId); if (merged) return { title: merged.title, partCount: merged.partCount ?? 1 }; const part = scanGroup(who, cwd).find((entry) => entry.id === sessionId); return part ? { title: part.title, partCount: 1 } : null; } /** * The display title for a session known only by id — what `/chat/live` needs. * * `claudeSessionContext` is the real answer but has to be told the group, and a live session can be in * any of them; the agent reports session keys and nothing else. So find the transcript by scanning the * slugs, take the cwd off its own first entry, and hand that to the normal path — which means the Live * panel shows exactly the title the list shows, `/clear` chains merged and all, rather than a second * opinion about naming. * * Reads the file to answer. That is a few milliseconds against a poll every ten seconds over a handful * of live sessions, so it is not worth a cache yet — but it is worth knowing before this is called from * anywhere hotter. */ export function liveSessionTitle(who: ChatIdentity, sessionId: string): { title: string; cwd: string } | null { const projectsDir = claudeProjectsDir(who.home); let slugs: string[]; try { slugs = readdirSync(projectsDir); } catch { return null; } for (const slug of slugs) { const filePath = join(projectsDir, slug, `${sessionId}.jsonl`); if (!existsSync(filePath)) continue; // The cwd is a property of the transcript's entries, so the first one carrying it settles which // group this session belongs to — no need to reverse the slug, which is lossy. let cwd: string | null = null; try { for (const line of readFileSync(filePath, 'utf-8').split('\n')) { if (!line.trim()) continue; const entry = JSON.parse(line) as { cwd?: string }; if (entry.cwd) { cwd = entry.cwd; break; } } } catch { return null; } if (!cwd) return null; const context = claudeSessionContext(who, cwd, sessionId); return context ? { title: context.title, cwd } : null; } return null; } /** * Every transcript that has to go when this conversation is deleted: itself and everything it * continues. The row stands for the whole chain, so deleting it has to mean the whole chain — leaving * the ancestors behind would resurrect them as separate rows the moment their child was gone, which * reads as the delete having half worked. */ function chainFileIds(who: ChatIdentity, cwd: string, sessionId: string): string[] { const group = scanGroup(who, cwd); const head = group.find((session) => session.id === sessionId); if (!head) return [sessionId]; return chainOf(head, new Map(group.map((session) => [session.id, session]))).map((part) => part.id); }