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>
915 lines
39 KiB
TypeScript
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);
|
|
}
|