import { dirname, join } from 'node:path'; import { DATA_PATH } from '../../data-path'; import { appendTextAs, findPathAs, realpathAs, listTranscriptsAs, readHeadAs, readTailAs, readTextAs, removeAs, type AsUser, } from '../../read-as-user'; import { runAsArgv } from '../../os-user'; import { isRealModel } from './model-aliases'; // ── 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 discarded its argument // whenever HOME_DIR was set — which was 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; /** * Whose identity to read a member's transcripts as — `null` for the owner. From `resolveHomeDir`. * * This said locating them "never needed this: their directories are 775". There are no 775 directories on * this path — `claude` creates `projects/` and every group at 700, which clamps the platform's ACL entry * to nothing exactly as mode 600 does for the files. So `readdirSync`, `statSync` and unlink all fail too, * and `existsSync` answers **false** rather than throwing, which is why it read as "no such session" * everywhere instead of as an error. See `read-as-user.ts`; the listing goes through `listTranscriptsAs`. */ osUser: string | null; /** * From `resolveHomeDir`. Carried as its own fact rather than inferred from `osUser === null` — that * equivalence holds today only because `resolveHomeDir` refuses a member without one, so reading it as * "is the owner" would silently become wrong the moment that refusal is relaxed. * * Used to gate the OpenCode harness, which is owner-only until it carries an identity. See `chat.ts`. */ isOwner: boolean; }; const claudeProjectsDir = (home: string): string => join(home, '.claude', 'projects'); /** * Where a session's transcript actually is, or null. * * One listing as the transcripts' owner, then a lookup — never `existsSync` on a candidate path. For a * member `existsSync` answers **false** on a file that is plainly there, because the group directory is * mode 700 and the service user cannot traverse it, and every caller read that false as "no such session". * * `cwd` names the group to prefer, not the group to trust: a session's group and the caller's current one * disagree routinely (a deep link has not resolved its group yet, or the list is showing another). Reads * have always fallen back like this; writes did not, so delete and rename returned "not found" for a * session that was on screen. */ function locateTranscript(who: ChatIdentity, sessionId: string, cwd?: string): string | null { const projectsDir = claudeProjectsDir(who.home); const files = listTranscriptsAs(who.osUser, projectsDir); const preferred = cwd ? projectSlug(cwd) : null; const hit = (preferred && files.find((f) => f.id === sessionId && f.slug === preferred)) || files.find((f) => f.id === sessionId); return hit ? join(projectsDir, hit.slug, `${sessionId}.jsonl`) : null; } /** 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(); // `mtimeMs` is passed in rather than stat'ed here: `statSync` is EACCES on a member's transcript, and the // listing that found the file already carries its mtime. Stat'ing again would be a second fork per file AND // would fail for exactly the accounts this exists to serve. function summarizeTranscript(osUser: AsUser, filePath: string, id: string, mtimeMs: number): TranscriptSummary | null { const mtime = new Date(mtimeMs).toISOString(); const cached = summaryCache.get(filePath); if (cached && cached.mtimeMs === mtimeMs) return cached.summary; let raw: string; try { raw = readTextAs(osUser, filePath); } 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( osUser: AsUser, filePath: string, sessionId: string, fallbackCwd = '', ): ClaudeSessionDetail | null { // No `existsSync` guard: it is redundant with the catch below (a missing file throws there just the same) // and it is actively WRONG for a member, answering false on a transcript that exists — which is how a // /chat/ deep link 404'd on a session the member was looking at. const messages: ClaudeChatMessage[] = []; const toolById = new Map>(); let model = ''; let sessionCwd = fallbackCwd; // `existsSync` above passes for a member's transcript and the read still fails — the file is theirs at // mode 600. That combination used to escape as a 500 from `GET /chat/sessions/:id`, because only the // list path caught its read. "Not found" is the honest answer for a transcript we cannot open. let raw: string; try { raw = readTextAs(osUser, filePath); } catch { return null; } for (const line of raw.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(who.osUser, 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( who.osUser, 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 filePath = locateTranscript(who, sessionId); if (!filePath) return null; const detail = parseClaudeTranscript(who.osUser, filePath, sessionId); return detail && loadChainTranscript(who, detail); } /** * 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. */ const findTranscript = (who: ChatIdentity, cwd: string, sessionId: string): string | null => locateTranscript(who, sessionId, cwd); /** * 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(who.osUser, filePath); const ids = ownCwd ? chainFileIds(who, ownCwd, sessionId) : [sessionId]; // `removeAs`, not `rmSync`: unlinking needs `w`+`x` on the DIRECTORY, which the service user does not have // on a member's group. `rmSync` guarded by `existsSync` therefore deleted nothing and still reported // success — the conversation reappeared on the next refresh. const dir = dirname(filePath); for (const id of ids) removeAs(who.osUser, join(dir, `${id}.jsonl`)); 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 = readTextAs(who.osUser, filePath).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 */ } } appendTextAs(who.osUser, 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 }; /** * Where a task's file is, looked for AS the account that owns it. * * This used to be a `Bun.Glob` scan running as the platform's own uid, and it worked for exactly one * account: the owner. `/tmp/claude-` is `drwx------`, so for a member the scan matched nothing — and * a glob that cannot traverse does not throw, it returns an empty iterator. Both callers read that as "no * such task", which is why a member saw a job that ran fine, streamed no logs at all, and answered * `{"stopped":false,"reason":"no such task"}` when they pressed Stop. One unreachable directory, two * symptoms that look unrelated. * * The depths are the old glob's shape, kept as explicit bounds so neither `find` wanders: a task output is * `/claude-///tasks/.output` (5), a subagent transcript is * `///subagents/agent-.jsonl` (4). `taskId` is `TASK_ID_RE`-checked above, so * it carries no glob or shell metacharacter into either. */ export function findTaskOutput(who: ChatIdentity, taskId: string): string | null { // The member's `claude` runs under `--reset-env`, so its TMPDIR is the passwd default, not ours. Both // resolve to /tmp on Linux; a server started with a TMPDIR of its own would look in the wrong place. const tmpRoot = process.env.TMPDIR ?? '/tmp'; const candidates: [string, string[]][] = [ [tmpRoot, ['-mindepth', '5', '-maxdepth', '5', '-name', `${taskId}.output`]], [claudeProjectsDir(who.home), ['-mindepth', '4', '-maxdepth', '4', '-name', `agent-${taskId}.jsonl`]], ]; for (const [root, predicate] of candidates) { const hit = findPathAs(who.osUser, root, predicate); if (hit) return hit; } return null; } /** The tail of a file, as text, without reading the whole thing. */ function tailFile(osUser: AsUser, filePath: string, bytes: number): { text: string; truncated: boolean } { const { text, truncated } = readTailAs(osUser, filePath, bytes); // A byte offset lands mid-line (and possibly mid-codepoint); drop the partial head. return { text: truncated ? text.slice(text.indexOf('\n') + 1) : text, truncated }; } /** * The pids with this file open — which is how a job with no recorded pid is found. * * `fuser` reads `/proc//fd`, which only the process's own user and root may open, so it runs AS the * job's owner. Run as the platform it would find nothing for a member's job and report it already stopped. */ export async function pidsHoldingFile(who: ChatIdentity, path: string): Promise { const argv = who.osUser ? runAsArgv(who.osUser, ['fuser', path]) : ['fuser', path]; const proc = Bun.spawn(argv, { stdout: 'pipe', stderr: 'ignore' }); const out = await new Response(proc.stdout).text(); await proc.exited; // The platform's OWN pids are removed, always. // // Not defensive programming for its own sake: a leaked descriptor made the server a holder of a job's // output file, and the next Pause `SIGSTOP`ped the whole platform through this list. The leak is fixed, // and this is the guard that means the next one cannot do the same thing. Nothing that manages a job // should ever be able to name the process doing the managing. const self = new Set([process.pid, process.ppid]); return out .split(/\s+/) .map((token) => Number(token.replace(/\D/g, ''))) .filter((n) => Number.isInteger(n) && n > 1 && !self.has(n)); } /** * Stop a running background job. * * The platform does not own these processes — Claude Code starts them, and nothing records a pid. What it * does have is the file the job streams into, and the job is still holding that file open. So the pid * comes from `fuser`, and the file is resolved through the SAME scoped lookup the read path uses: a task * id is validated, then matched only under this caller's own directories. An id that resolves to nothing * kills nothing. * * SIGTERM by default, and SIGKILL only when asked. A dev server with children should get the chance to * take them down with it; SIGKILL orphans them and leaves the port bound, which is worse than a job that * took a second to stop. * * `force` exists because that reasoning stops applying the moment a process ignores SIGTERM: there is * then no graceful shutdown to protect, only a job that cannot be stopped from the UI at all. The comment * in task-restart.ts already said "that is what the second click is for" — this is the second click * actually doing something different from the first. * * Only agent-run SHELL jobs can be stopped this way. A subagent writes a `.jsonl` transcript rather than * holding a log open, so there is no fd to trace and this reports that rather than pretending. */ export async function stopBackgroundTask( who: ChatIdentity, taskId: string, force = false, ): Promise<{ stopped: boolean; reason?: string; signal?: 'TERM' | 'KILL' }> { if (!TASK_ID_RE.test(taskId)) return { stopped: false, reason: 'bad task id' }; const found = findTaskOutput(who, taskId); if (!found) return { stopped: false, reason: 'no such task' }; if (found.endsWith('.jsonl')) return { stopped: false, reason: 'subagents cannot be stopped this way' }; // As the member, for both halves. `fuser` reads `/proc//fd`, which only the process's own user and // root may open, so run as the platform it would find no pids for a member's job and report it already // stopped. And the kill would then be cross-user: EPERM, silently swallowed by the catch below. const asUser = (command: string[]) => (who.osUser ? runAsArgv(who.osUser, command) : command); const pids = await pidsHoldingFile(who, found); if (pids.length === 0) return { stopped: false, reason: 'already stopped' }; if (who.osUser) { // One fork for the whole set rather than one per pid, and the kernel checks the member's own identity // against processes that are already theirs. const kill = Bun.spawn(asUser(['kill', '-TERM', ...pids.map(String)]), { stdout: 'ignore', stderr: 'ignore' }); await kill.exited; return { stopped: true }; } for (const pid of pids) { try { process.kill(pid, 'SIGTERM'); } catch { // Gone between listing and killing, or not ours. Either way there is nothing to do about it. } } return { stopped: true }; } /** * 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; // As the member, for the same reason the lookup is: resolving a link needs `x` on every directory above // it, and `/tmp/claude-` is `drwx------`. Resolving it as the platform threw EACCES on an INTACT // link, and the catch reported that as dangling — so a member's job was found and then declared to have // written nothing, which is exactly what it looked like on screen. const target = realpathAs(who.osUser, found); if (!target) return null; // dangling, or gone between the lookup and here if (target.endsWith('.jsonl')) { const detail = parseClaudeTranscript(who.osUser, 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(who.osUser, 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(osUser: AsUser, filePath: string): string { try { return readHeadAs(osUser, filePath, 32768).match(/"cwd":"([^"]*)"/)?.[1] ?? ''; } catch { return ''; } } 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. */ /** * Every transcript this identity owns, with the first real model it recorded. * * For the `chat_session_models` backfill (`scripts/backfill-session-models.ts`) — the one caller that wants * a model per session across the WHOLE tree rather than for one directory. * * ── Why the FIRST model is the right one to read ── * * Sounds wrong: the table stores the model a session is *currently* using, so the last should win. But * changing model mid-conversation only became possible on 2026-08-17, so no transcript written before that * can contain more than one. First and last are the same value for every session this backfill will ever * see, and reading the first costs a bounded head read instead of a full one — which matters, because the * transcripts on this machine run to 59MB. * * Any session that takes a real turn afterwards overwrites the guess with the exact alias, `[1m]` and all. * A future re-run of the backfill cannot regress that: `seedMissingChatSessionModels` only inserts. * * `` is skipped — it is Claude Code's marker for its own generated entries, not a model. */ export function listTranscriptModels(who: ChatIdentity): { sessionId: string; apiModel: string }[] { const projectsDir = claudeProjectsDir(who.home); const out: { sessionId: string; apiModel: string }[] = []; for (const file of listTranscriptsAs(who.osUser, projectsDir)) { const path = join(projectsDir, file.slug, `${file.id}.jsonl`); let head: string; try { // 64KB rather than the 32KB `firstCwd` uses: `cwd` is on the very first entry, while the first // ASSISTANT entry (the only kind that carries a model) sits after the user's opening message, which // can be long. A miss here costs one absent row, not a wrong one. head = readHeadAs(who.osUser, path, 65536); } catch { continue; // Unreadable transcript. One missing row, and the next turn fills it in. } // Scan for the first `"model":"…"` that is not the synthetic marker. Regex rather than JSON.parse per // line because the head read almost certainly ends mid-line. const apiModel = [...head.matchAll(/"model":"([^"]+)"/g)].map((m) => m[1]!).find(isRealModel); if (apiModel) out.push({ sessionId: file.id, apiModel }); } return out; } export function listClaudePwds(who: ChatIdentity): ClaudePwd[] { const projectsDir = claudeProjectsDir(who.home); const defaultCwd = who.home; const byCwd = new Map(); // One listing for the whole tree, grouped here. This used to be `readdirSync` + a `statSync` per file, // and BOTH are EACCES for a member — the readdir threw uncaught, so this endpoint answered 500 rather // than answering wrongly. That 500 was the visible half of the empty conversation list. const byGroup = new Map(); for (const file of listTranscriptsAs(who.osUser, projectsDir)) { const prev = byGroup.get(file.slug); byGroup.set(file.slug, { count: (prev?.count ?? 0) + 1, newest: Math.max(prev?.newest ?? 0, file.mtimeMs), first: prev?.first ?? file.id, }); } for (const [slug, group] of byGroup) { // The cwd is a property of the transcript's entries, not of the slug, which is lossy and cannot be // reversed. Any file in the group answers it. const cwd = firstCwd(who.osUser, join(projectsDir, slug, `${group.first}.jsonl`)); if (!cwd) continue; const updatedAt = new Date(group.newest).toISOString(); const prev = byCwd.get(cwd); byCwd.set(cwd, { count: (prev?.count ?? 0) + group.count, 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 slug = projectSlug(cwd); const dir = join(claudeProjectsDir(who.home), slug); const sessions: TranscriptSummary[] = []; for (const file of listTranscriptsAs(who.osUser, claudeProjectsDir(who.home), slug)) { const summary = summarizeTranscript(who.osUser, join(dir, `${file.id}.jsonl`), file.id, file.mtimeMs); 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)); } /** * Every conversation in every directory, newest first. * * For the sessions list's **Latest** tab, which asks a question the per-directory listing cannot: what have * I been working on lately, wherever it was. The alternative was one request per directory from the client, * which turns one screen into twenty round trips and still cannot sort across them. * * ── Why it loops directories rather than scanning flat ── * * `/clear` chains are merged into one row, and a chain lives inside a single project group. Scanning every * transcript flat and merging afterwards would let two unrelated chains in different directories collapse * into each other, because the lineage heuristics match on time and title rather than on path. Looping * `listClaudeSessions` per directory keeps merging scoped exactly the way the per-directory list does, so * the same conversation is one row in both views. * * The cost is one group scan per directory — the same total work as expanding every node of the tree, and * bounded by how many directories you have ever run `claude` in. */ export function listAllClaudeSessions(who: ChatIdentity): ClaudeSessionSummary[] { return listClaudePwds(who) .flatMap((pwd) => listClaudeSessions(who, pwd.cwd)) .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 filePath = locateTranscript(who, sessionId); if (!filePath) return null; // 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 readTextAs(who.osUser, filePath).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; } /** * 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); }