Files
platform/src/servers/api/chat/claude-sessions.ts
T
pastilhasandClaude Opus 5 ec1997fd0e a chat with no chosen directory runs in the caller's own home
The default was DATA_PATH/<email>/general_chat_sessions, a dedicated directory so /chat
sessions formed their own Claude project group instead of cluttering the home. It is a sibling
of the home, and confineUserTree makes every sibling the platform's at 0700 because the others
are attachments and email_accounts. So it was unreachable for a member: the first live member
turn started there and every Bash call failed on its own working directory before doing
anything.

A per-member copy inside each home fixed the symptom and left two rules to remember. The owner
chose one rule instead — the account's own home, whoever they are — and accepted the trade
knowingly: /chat sessions now share a project group with anything else run from that home,
which was the reason the dedicated directory existed.

Removed rather than left dangling: getGeneralChatSessionsCwd, ensureGeneralChatSessionsCwd,
ensureMemberChatCwd, and general_chat_sessions from USER_DIRS so new accounts stop getting it.
Existing directories are untouched and their transcripts stay where they are — Claude groups by
cwd, so the owner's old /chat history remains under its own project slug rather than moving.

The UI labels move with it: the default group now reads "home" rather than naming a directory
that no longer has a role.

ChatIdentity keeps carrying both email and home. The pairing was justified in the comment by
general_chat_sessions being email-derived, which is now gone — but the distinction it encodes
is real (the email says who, the home says where), so the comment explains that instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 01:54:19 +00:00

915 lines
39 KiB
TypeScript

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/<slug>/<session-uuid>.jsonl
// where <slug> 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<number>;
};
/** 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<string, { mtimeMs: number; summary: TranscriptSummary }>();
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<number>();
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
// `<command-name>…`, 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 "<command-name>/clear</command-name>");
// - 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('<command-name>/clear</command-name>');
}
// 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('<command-name>') && !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<string, TranscriptSummary>();
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<string, TranscriptSummary>): TranscriptSummary[] {
const parts: TranscriptSummary[] = [];
const seen = new Set<string>();
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<string, unknown>;
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<string, unknown> }
| { 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('<task-notification>');
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<string, Extract<ClaudeChatMessage, { role: 'tool' }>>();
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/<id>, 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-<uid>/<project-slug>/<session-uuid>/tasks/<task-id>.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/<slug>/<session>/subagents/agent-<task-id>.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<string, { count: number; updatedAt: string }>();
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);
}