pressing stop ended the turn with "Claude Code returned an error" — the agent sdk reports interrupt() as an ordinary failed result, indistinguishable from a real fault downstream. the sidecar now flags the session it interrupted and rewrites that event to the existing durable 'stopped', which opencode already emitted. escape stops the turn (bound to the chat subtree, not the document), and the prompt comes back to the composer verbatim unless you've started typing something else. history parity: claude files [Request interrupted by user] as a user message, so the transcript reader maps those exact strings to the same role instead of replaying them as something you typed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
815 lines
34 KiB
TypeScript
815 lines
34 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 '-'.
|
|
// Single-user platform: Claude runs with no isolation — HOME is the real home
|
|
// (HOME_DIR) — so its transcripts are the same store the terminal `claude` uses. We never keep our
|
|
// own copy; Claude's files are authoritative.
|
|
|
|
const claudeHome = (email: string): string => process.env.HOME_DIR ?? join(DATA_PATH, email, 'home');
|
|
|
|
/** Dedicated working directory for /chat sessions, so they form their own Claude "project" group. */
|
|
export const getGeneralChatSessionsCwd = (email: string): string => join(DATA_PATH, email, 'general_chat_sessions');
|
|
|
|
/** Same, but create the directory if it doesn't exist (call before spawning a /chat session). */
|
|
export const ensureGeneralChatSessionsCwd = (email: string): string => {
|
|
const dir = getGeneralChatSessionsCwd(email);
|
|
mkdirSync(dir, { recursive: true });
|
|
return dir;
|
|
};
|
|
|
|
const claudeProjectsDir = (email: string): string => join(claudeHome(email), '.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;
|
|
};
|
|
|
|
/**
|
|
* 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>');
|
|
}
|
|
if (text && !firstUserText && !text.startsWith('<command-name>')) 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' };
|
|
|
|
/**
|
|
* 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 '';
|
|
}
|
|
|
|
const userOrInterruption = (text: string): ClaudeChatMessage =>
|
|
INTERRUPTION_MARKERS.has(text.trim()) ? { role: 'interrupted' } : { 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 === 'user' && !entry.isMeta) {
|
|
if (typeof content === 'string') {
|
|
if (content.trim()) messages.push(userOrInterruption(content));
|
|
continue;
|
|
}
|
|
if (Array.isArray(content)) {
|
|
for (const block of content as ContentBlock[]) {
|
|
if (block.type === 'text' && block.text?.trim()) {
|
|
messages.push(userOrInterruption(block.text));
|
|
} 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(email: string, detail: ClaudeSessionDetail): ClaudeSessionDetail {
|
|
const parts = (() => {
|
|
const group = scanGroup(email, 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(email), 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(email: string, cwd: string, sessionId: string): ClaudeSessionDetail | null {
|
|
const detail = parseClaudeTranscript(
|
|
join(claudeProjectsDir(email), projectSlug(cwd), `${sessionId}.jsonl`),
|
|
sessionId,
|
|
cwd,
|
|
);
|
|
return detail && loadChainTranscript(email, 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(email: string, sessionId: string): ClaudeSessionDetail | null {
|
|
const projectsDir = claudeProjectsDir(email);
|
|
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(email, 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(email: string, cwd: string, sessionId: string): string | null {
|
|
const preferred = join(claudeProjectsDir(email), projectSlug(cwd), `${sessionId}.jsonl`);
|
|
if (existsSync(preferred)) return preferred;
|
|
|
|
const projectsDir = claudeProjectsDir(email);
|
|
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(email: string, cwd: string, sessionId: string): boolean {
|
|
const filePath = findTranscript(email, cwd, sessionId);
|
|
if (!filePath) return false;
|
|
|
|
const ownCwd = firstCwd(filePath);
|
|
const ids = ownCwd ? chainFileIds(email, 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(email: string, cwd: string, sessionId: string, title: string): boolean {
|
|
const filePath = findTranscript(email, 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(email: string, taskId: string): string | null {
|
|
const tmpRoot = process.env.TMPDIR ?? '/tmp';
|
|
const candidates: [string, string][] = [
|
|
[tmpRoot, `claude-*/*/*/tasks/${taskId}.output`],
|
|
[claudeProjectsDir(email), `*/*/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(email: string, taskId: string): BackgroundTaskDetail | null {
|
|
if (!TASK_ID_RE.test(taskId)) return null;
|
|
const found = findTaskOutput(email, 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 default /chat dir. Newest first. */
|
|
export function listClaudePwds(email: string): ClaudePwd[] {
|
|
const projectsDir = claudeProjectsDir(email);
|
|
const defaultCwd = getGeneralChatSessionsCwd(email);
|
|
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(email: string, cwd: string): TranscriptSummary[] {
|
|
const dir = join(claudeProjectsDir(email), 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(email: string, cwd: string): ClaudeSessionSummary[] {
|
|
return mergeChains(scanGroup(email, 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(
|
|
email: string,
|
|
cwd: string,
|
|
sessionId: string,
|
|
): { title: string; partCount: number } | null {
|
|
const merged = listClaudeSessions(email, cwd).find((entry) => entry.id === sessionId);
|
|
if (merged) return { title: merged.title, partCount: merged.partCount ?? 1 };
|
|
const part = scanGroup(email, cwd).find((entry) => entry.id === sessionId);
|
|
return part ? { title: part.title, partCount: 1 } : 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(email: string, cwd: string, sessionId: string): string[] {
|
|
const group = scanGroup(email, 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);
|
|
}
|