Files
platform/src/servers/api/chat/claude-sessions.ts
T
pastilhasandClaude Opus 5 0096392cde a task the agent woke up to starts a turn
Refreshing a chat lost the agent's answers: the turn folded to a seam and
printed "Turn finished · no closing message" over a turn that had plainly
answered. Reported against a commander, where it happened on most refreshes.

The durable log was innocent. A refresh renders from Claude's .jsonl, and the
harness delivers a finished background task as the agent's next user message —
a <task-notification> envelope. The parser dropped it so it would not replay as
a raw XML bubble you appear to have typed, and dropped a turn boundary with it,
because turn-rows derives turns from the message list. The woken turn merged
into the one before it, so that turn's closing prose became mid-turn work and
folded, and the merged segment ended on a tool call, which earned the marker.

Measured on the reported session: 30 messages, one user message, one fold
hiding seven answers, the marker reproduced at 21 of 30 refresh points.

The envelope now rebuilds into the same task row a live task:notification
produces, carrying startsTurn. buildRows makes that a boundary and buildTurn
exposes it as the turn's opener. Separately, the marker is withheld when a
segment ends on a tool call we never saw come back — still running, or cut off
holding it. A completed trailing tool call keeps its marker, so the test
pinning that live behaviour is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: f7a62308-0977-4cf4-aa29-c020853fd420
2026-08-25 06:37:21 +00:00

1204 lines
58 KiB
TypeScript

import { basename, dirname, join } from 'node:path';
import { DATA_PATH } from '../../data-path';
import {
appendTextAs,
findPathAs,
realpathAs,
listTranscriptsAs,
readHeadAs,
readTailAs,
readTextAs,
removeAs,
type AsUser,
} from '../../read-as-user';
import { runAsArgv } from '../../os-user';
import { isRealModel } from './model-aliases';
// ── Claude session store (source of truth) ──
// The `claude` CLI persists every session as a JSONL transcript at
// $HOME/.claude/projects/<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 discarded its argument
// whenever HOME_DIR was set — which was always, on a real install. Every read therefore resolved to the OWNER'S
// transcripts regardless of who was asking, and the comment above it said "single-user platform" as though
// that were a property rather than an assumption. A member reaching these functions would have been handed the
// owner's conversation list.
//
// So the home arrives resolved, from `resolveHomeDir(userId)`, and this file has no way to invent one.
//
// The email travels alongside it rather than being derived from it, because the two answer different
// questions: the email says WHO, the home says WHERE. They were briefly conflated in the other direction —
// `general_chat_sessions` was an email-derived path under DATA_PATH used as a chat's working directory, and
// because `confineUserTree` makes every sibling of a home the platform's at 0700, a member's turn started in
// a directory it could not enter. That default is now the caller's own home; the pairing survives because the
// distinction it encodes is real.
export type ChatIdentity = {
email: string;
/** From `resolveHomeDir`. Never `getOwnerHomeDir`, which ignores its argument. */
home: string;
/**
* Whose identity to read a member's transcripts as — `null` for the owner. From `resolveHomeDir`.
*
* This said locating them "never needed this: their directories are 775". There are no 775 directories on
* this path — `claude` creates `projects/` and every group at 700, which clamps the platform's ACL entry
* to nothing exactly as mode 600 does for the files. So `readdirSync`, `statSync` and unlink all fail too,
* and `existsSync` answers **false** rather than throwing, which is why it read as "no such session"
* everywhere instead of as an error. See `read-as-user.ts`; the listing goes through `listTranscriptsAs`.
*/
osUser: string | null;
/**
* From `resolveHomeDir`. Carried as its own fact rather than inferred from `osUser === null` — that
* equivalence holds today only because `resolveHomeDir` refuses a member without one, so reading it as
* "is the owner" would silently become wrong the moment that refusal is relaxed.
*
* Used to gate the OpenCode harness, which is owner-only until it carries an identity. See `chat.ts`.
*/
isOwner: boolean;
};
const claudeProjectsDir = (home: string): string => join(home, '.claude', 'projects');
/**
* Where a session's transcript actually is, or null.
*
* One listing as the transcripts' owner, then a lookup — never `existsSync` on a candidate path. For a
* member `existsSync` answers **false** on a file that is plainly there, because the group directory is
* mode 700 and the service user cannot traverse it, and every caller read that false as "no such session".
*
* `cwd` names the group to prefer, not the group to trust: a session's group and the caller's current one
* disagree routinely (a deep link has not resolved its group yet, or the list is showing another). Reads
* have always fallen back like this; writes did not, so delete and rename returned "not found" for a
* session that was on screen.
*/
function locateTranscript(who: ChatIdentity, sessionId: string, cwd?: string): string | null {
const projectsDir = claudeProjectsDir(who.home);
const files = listTranscriptsAs(who.osUser, projectsDir);
const preferred = cwd ? projectSlug(cwd) : null;
const hit =
(preferred && files.find((f) => f.id === sessionId && f.slug === preferred)) ||
files.find((f) => f.id === sessionId);
return hit ? join(projectsDir, hit.slug, `${sessionId}.jsonl`) : null;
}
/** Claude's folder name for a working directory. */
export const projectSlug = (cwd: string): string => cwd.replace(/[^a-zA-Z0-9]/g, '-');
/** What a session's continuation looks like to the app. See `applyLineage` for how it is derived. */
export type ClaudeSessionOrigin = { id: string; title: string };
export type ClaudeSessionSummary = {
id: string; // session uuid (= transcript filename)
title: string; // explicit title, else first human message, else a number in a /clear chain
cwd: string;
createdAt: string; // ISO, first entry
updatedAt: string; // ISO, last entry
messageCount: number; // human + assistant turns
harness?: 'claude' | 'opencode'; // which harness produced it (defaults to claude when absent)
/** This transcript opens with `/clear`, so it is the continuation of an earlier conversation. */
bornFromClear?: boolean;
/** The conversation this one continues, when it could be identified. Inferred — see `applyLineage`. */
continuedFrom?: ClaudeSessionOrigin | null;
/**
* How many transcripts this row stands for. Present only on a `/clear` chain, which the list shows as
* one conversation — see `mergeChains`. Absent means the ordinary case: one row, one transcript.
*/
partCount?: number;
/**
* This is a Command HQ commander, not an ordinary chat.
*
* ── Why it is a field and not a frontend detail ──
*
* A commander IS a Claude session, and this list reads transcripts from disk, so nothing distinguished
* the two and commanders appeared in the chat list looking like conversations somebody started and
* forgot. The web can now split them into tabs — but the reason this belongs in the PAYLOAD is the
* phone: the iPhone chat app swipes left and right between live chats, and with no tabs to hide behind
* it has to be able to filter commanders out of that gesture itself. A flag only the web knew about
* would fix one surface and leave the other wrong.
*
* Optional and never set by this file — every commander now has its OWN directory
* (`docs/commander-directory.md`), so there is no single path this list could compare a `cwd` against
* the way it once did when every commander shared one. The real answer is a `command_hq_sessions` row
* lookup, which needs a database call this module does not make; `chat.ts` computes it that way and
* spreads it onto the rows it returns — see its own comment on why `isCommander` is deliberately absent
* from THIS module's output. The field stays on the type because other callers read it optionally off
* whatever `ClaudeSessionSummary` they were handed, undefined being the honest answer when nobody set it.
*/
isCommander?: boolean;
};
/** `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 }>();
// `mtimeMs` is passed in rather than stat'ed here: `statSync` is EACCES on a member's transcript, and the
// listing that found the file already carries its mtime. Stat'ing again would be a second fork per file AND
// would fail for exactly the accounts this exists to serve.
function summarizeTranscript(osUser: AsUser, filePath: string, id: string, mtimeMs: number): TranscriptSummary | null {
const mtime = new Date(mtimeMs).toISOString();
const cached = summaryCache.get(filePath);
if (cached && cached.mtimeMs === mtimeMs) return cached.summary;
let raw: string;
try {
raw = readTextAs(osUser, filePath);
} catch {
return null;
}
let firstUserText = '';
let summaryTitle = ''; // a `summary` entry (our rename, appended to the transcript) wins over the first message
let cwd = '';
let firstTs = '';
let lastTs = '';
let messageCount = 0;
let sawFirstUserEntry = false;
let bornFromClear = false;
const activeMinutes = new Set<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 }
// A background task reporting in — see `taskNotificationFrom`. `startsTurn` is the load-bearing field:
// this is the prompt the agent woke up to, so it is where one turn ends and the next begins.
| {
role: 'task';
taskId: string;
description: string;
status?: 'completed' | 'failed' | 'stopped';
summary?: string;
startsTurn: 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 as prose, because a finished task travels as its own `task:notification`
* event and lands in the row the task already had; 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>');
const tagOf = (envelope: string, tag: string): string | undefined =>
envelope.match(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`))?.[1];
/** `&amp;` last, or `&amp;lt;` would decode twice and become `<`. */
const decodeEntities = (text: string) =>
text
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&amp;/g, '&');
const TASK_STATUSES = new Set(['completed', 'failed', 'stopped']);
/**
* The envelope, rebuilt into the same row a live `task:notification` produces when the task's announcing
* row is gone (`useChat`, `task:notification`). It used to be dropped outright, and dropping it lost more
* than a bubble: **turn boundaries are derived from the user's messages**, so deleting one merged the
* woken turn into the one before it. Everything the agent had said closing that earlier turn stopped being
* the turn's trailing prose and became mid-turn work, which folds — and the merged turn then ended on a
* tool call, which printed "Turn finished · no closing message" over a turn that had plainly finished
* talking. A commander wakes on these, so its whole life replayed as a single turn with every answer
* folded away.
*
* Malformed envelopes still return null and are still dropped: a row we cannot identify is worse than a
* missing boundary.
*/
function taskNotificationFrom(text: string): ClaudeChatMessage | null {
const taskId = tagOf(text, 'task-id')?.trim();
if (!taskId) return null;
const status = tagOf(text, 'status')?.trim();
const summary = decodeEntities((tagOf(text, 'summary') ?? '').trim());
return {
role: 'task',
taskId,
description: summary,
status: status && TASK_STATUSES.has(status) ? (status as 'completed' | 'failed' | 'stopped') : undefined,
summary,
startsTurn: true,
};
}
function userMessageFrom(text: string): ClaudeChatMessage | null {
if (isTaskNotification(text)) return taskNotificationFrom(text);
if (INTERRUPTION_MARKERS.has(text.trim())) return { role: 'interrupted' };
return { role: 'user', text };
}
export type ClaudeSessionDetail = { id: string; model: string; cwd: string; messages: ClaudeChatMessage[] };
/** Parse a session's JSONL transcript into a flat, display-ready message list. Claude is the source. */
function parseClaudeTranscript(
osUser: AsUser,
filePath: string,
sessionId: string,
fallbackCwd = '',
): ClaudeSessionDetail | null {
// No `existsSync` guard: it is redundant with the catch below (a missing file throws there just the same)
// and it is actively WRONG for a member, answering false on a transcript that exists — which is how a
// /chat/<id> deep link 404'd on a session the member was looking at.
const messages: ClaudeChatMessage[] = [];
const toolById = new Map<string, Extract<ClaudeChatMessage, { role: 'tool' }>>();
let model = '';
let sessionCwd = fallbackCwd;
// `existsSync` above passes for a member's transcript and the read still fails — the file is theirs at
// mode 600. That combination used to escape as a 500 from `GET /chat/sessions/:id`, because only the
// list path caught its read. "Not found" is the honest answer for a transcript we cannot open.
let raw: string;
try {
raw = readTextAs(osUser, filePath);
} catch {
return null;
}
for (const line of raw.split('\n')) {
if (!line.trim()) continue;
let entry: Entry & { message?: { role?: string; content?: unknown; model?: string } };
try {
entry = JSON.parse(line);
} catch {
continue;
}
// First cwd wins — a session's home is where it was launched, which is how Claude Code files the
// transcript on disk and how parseSummary groups it in the list. Later entries record wherever a tool
// happened to cd to, so taking the last one made the detail disagree with the list: opening a session
// moved the cwd picker to a subdirectory. It also let a stale entry clobber `fallbackCwd` even when the
// caller already knew the group it was loading from.
if (entry.cwd && !sessionCwd) sessionCwd = entry.cwd;
if (entry.message?.model && !model) model = entry.message.model;
const content = entry.message?.content;
if (entry.type === 'system' && entry.subtype === 'compact_boundary') {
const meta = entry.compactMetadata ?? {};
messages.push({
role: 'compact',
trigger: meta.trigger === 'manual' ? 'manual' : 'auto',
preTokens: meta.preTokens,
durationMs: meta.durationMs,
done: true,
});
continue;
}
if (entry.type === 'user' && !entry.isMeta) {
if (typeof content === 'string') {
const message = content.trim() ? userMessageFrom(content) : null;
if (message) messages.push(message);
continue;
}
if (Array.isArray(content)) {
for (const block of content as ContentBlock[]) {
if (block.type === 'text' && block.text?.trim()) {
const message = userMessageFrom(block.text);
if (message) messages.push(message);
} else if (block.type === 'tool_result') {
const tool = toolById.get(block.tool_use_id);
if (tool) {
tool.output = blockText(block.content);
tool.isError = block.is_error === true;
}
}
}
}
continue;
}
if (entry.type === 'assistant' && Array.isArray(content)) {
for (const block of content as ContentBlock[]) {
if (block.type === 'text' && block.text?.trim()) {
messages.push({ role: 'assistant', id: `${sessionId}-${messages.length}`, text: block.text });
} else if (block.type === 'tool_use') {
const tool = {
role: 'tool' as const,
toolName: block.name,
toolInput: block.input ?? {},
toolCallId: block.id,
};
messages.push(tool);
toolById.set(block.id, tool);
}
}
}
}
return { id: sessionId, model, cwd: sessionCwd, messages };
}
/**
* Splice in everything this conversation continues, so a `/clear` chain reads as one transcript.
*
* `/clear` is a fact about Claude's process, not about what you were doing — you were having one
* conversation, and it left you with three unrelated-looking rows. The parts are concatenated
* oldest-first with a `divider` between them, and the divider is the honest part: your history is
* continuous, the agent's context is not.
*
* Only ancestors, never descendants. Ancestry is what "continues" means, and it keeps the returned id
* resumable — a mid-chain deep link from an old bookmark shows its own story up to that point rather
* than a conversation that carries on past where it was addressed.
*
* Pagination needs nothing: the client asks for index windows into whatever the server calls the
* transcript, so a longer one simply pages further back.
*/
function loadChainTranscript(who: ChatIdentity, detail: ClaudeSessionDetail): ClaudeSessionDetail {
const parts = (() => {
const group = scanGroup(who, detail.cwd);
const head = group.find((session) => session.id === detail.id);
return head ? chainOf(head, new Map(group.map((session) => [session.id, session]))) : [];
})();
if (parts.length < 2) return detail;
const dir = join(claudeProjectsDir(who.home), projectSlug(detail.cwd));
const earlier: ClaudeChatMessage[] = [];
for (const part of parts.slice(0, -1)) {
const segment = parseClaudeTranscript(who.osUser, join(dir, `${part.id}.jsonl`), part.id, detail.cwd);
if (!segment) continue;
earlier.push(...segment.messages, { role: 'divider', sessionId: part.id });
}
return { ...detail, messages: [...earlier, ...detail.messages] };
}
/** Load a session when its cwd (project group) is known. */
export function loadClaudeSession(who: ChatIdentity, cwd: string, sessionId: string): ClaudeSessionDetail | null {
const detail = parseClaudeTranscript(
who.osUser,
join(claudeProjectsDir(who.home), projectSlug(cwd), `${sessionId}.jsonl`),
sessionId,
cwd,
);
return detail && loadChainTranscript(who, detail);
}
/** Resolve a session by id ALONE — scan every project group for its transcript. Used on a deep-link /
* refresh to /chat/<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 filePath = locateTranscript(who, sessionId);
if (!filePath) return null;
const detail = parseClaudeTranscript(who.osUser, filePath, sessionId);
return detail && loadChainTranscript(who, detail);
}
/**
* The transcript file for a session: in the named group if it is there, otherwise wherever it actually
* is. The caller's cwd is a hint, not an authority — a session's group is a property of the session,
* and the two disagree routinely (the list is showing one group while you act on a row from another,
* or a deep link hasn't resolved its group yet). Reads have always fallen back like this; writes did
* not, so delete and rename returned "not found" for a session that was plainly on screen.
*/
const findTranscript = (who: ChatIdentity, cwd: string, sessionId: string): string | null =>
locateTranscript(who, sessionId, cwd);
/**
* Delete a conversation by removing its transcript file — and, when it is a `/clear` chain, the files
* of every part it continues. Returns false if it didn't exist.
*
* The chain is resolved from the transcript's OWN directory, not the caller's `cwd`: the two disagree
* routinely (see `findTranscript`), and the wrong group would find no chain and silently delete one
* part of several.
*/
export function deleteClaudeSession(who: ChatIdentity, cwd: string, sessionId: string): string[] {
const filePath = findTranscript(who, cwd, sessionId);
// The IDS it removed, not a boolean — empty means nothing was there. The caller needs them to forget the
// rows keyed on each one (`forgetChatSessions`), and a `/clear` chain is several transcripts presenting as
// one conversation: returning true would have told a caller to forget the head and orphan its parts.
if (!filePath) return [];
const ownCwd = firstCwd(who.osUser, filePath);
const ids = ownCwd ? chainFileIds(who, ownCwd, sessionId) : [sessionId];
// `removeAs`, not `rmSync`: unlinking needs `w`+`x` on the DIRECTORY, which the service user does not have
// on a member's group. `rmSync` guarded by `existsSync` therefore deleted nothing and still reported
// success — the conversation reappeared on the next refresh.
const dir = dirname(filePath);
for (const id of ids) removeAs(who.osUser, join(dir, `${id}.jsonl`));
return ids;
}
/**
* Rename a session by appending a `summary` entry to its transcript — Claude's own on-disk format, so
* the title lives in .claude (source of truth). Our reader takes the last summary as the title; no
* timestamp is written so the rename doesn't reorder the list.
*/
export function renameClaudeSession(who: ChatIdentity, cwd: string, sessionId: string, title: string): boolean {
const filePath = findTranscript(who, cwd, sessionId);
if (!filePath) return false;
// Attach the summary to the transcript's tip (the last entry carrying a uuid).
let leafUuid = sessionId;
const lines = readTextAs(who.osUser, filePath).split('\n');
for (let i = lines.length - 1; i >= 0; i--) {
if (!lines[i]!.trim()) continue;
try {
const entry = JSON.parse(lines[i]!) as Entry;
if (entry.uuid) {
leafUuid = entry.uuid;
break;
}
} catch {
/* skip */
}
}
appendTextAs(who.osUser, filePath, `${JSON.stringify({ type: 'summary', summary: title, leafUuid })}\n`);
return true;
}
// ── Background tasks ──
// A background task (run_in_background, Monitor, a backgrounded subagent) reports nothing over the wire
// between `task:started` and its notification — the only thing that knows what it is doing is the file
// Claude Code streams it into:
//
// $TMPDIR/claude-<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 };
/**
* Where a task's file is, looked for AS the account that owns it.
*
* This used to be a `Bun.Glob` scan running as the platform's own uid, and it worked for exactly one
* account: the owner. `/tmp/claude-<uid>` is `drwx------`, so for a member the scan matched nothing — and
* a glob that cannot traverse does not throw, it returns an empty iterator. Both callers read that as "no
* such task", which is why a member saw a job that ran fine, streamed no logs at all, and answered
* `{"stopped":false,"reason":"no such task"}` when they pressed Stop. One unreachable directory, two
* symptoms that look unrelated.
*
* The depths are the old glob's shape, kept as explicit bounds so neither `find` wanders: a task output is
* `<tmp>/claude-<uid>/<project-slug>/<session>/tasks/<id>.output` (5), a subagent transcript is
* `<projects>/<slug>/<session>/subagents/agent-<id>.jsonl` (4). `taskId` is `TASK_ID_RE`-checked above, so
* it carries no glob or shell metacharacter into either.
*/
export function findTaskOutput(who: ChatIdentity, taskId: string): string | null {
// The member's `claude` runs under `--reset-env`, so its TMPDIR is the passwd default, not ours. Both
// resolve to /tmp on Linux; a server started with a TMPDIR of its own would look in the wrong place.
const tmpRoot = process.env.TMPDIR ?? '/tmp';
const candidates: [string, string[]][] = [
[tmpRoot, ['-mindepth', '5', '-maxdepth', '5', '-name', `${taskId}.output`]],
[claudeProjectsDir(who.home), ['-mindepth', '4', '-maxdepth', '4', '-name', `agent-${taskId}.jsonl`]],
];
for (const [root, predicate] of candidates) {
const hit = findPathAs(who.osUser, root, predicate);
if (hit) return hit;
}
return null;
}
/** The tail of a file, as text, without reading the whole thing. */
function tailFile(osUser: AsUser, filePath: string, bytes: number): { text: string; truncated: boolean } {
const { text, truncated } = readTailAs(osUser, filePath, bytes);
// A byte offset lands mid-line (and possibly mid-codepoint); drop the partial head.
return { text: truncated ? text.slice(text.indexOf('\n') + 1) : text, truncated };
}
/**
* The pids with this file open — which is how a job with no recorded pid is found.
*
* `fuser` reads `/proc/<pid>/fd`, which only the process's own user and root may open, so it runs AS the
* job's owner. Run as the platform it would find nothing for a member's job and report it already stopped.
*/
export async function pidsHoldingFile(who: ChatIdentity, path: string): Promise<number[]> {
const argv = who.osUser ? runAsArgv(who.osUser, ['fuser', path]) : ['fuser', path];
const proc = Bun.spawn(argv, { stdout: 'pipe', stderr: 'ignore' });
const out = await new Response(proc.stdout).text();
await proc.exited;
// The platform's OWN pids are removed, always.
//
// Not defensive programming for its own sake: a leaked descriptor made the server a holder of a job's
// output file, and the next Pause `SIGSTOP`ped the whole platform through this list. The leak is fixed,
// and this is the guard that means the next one cannot do the same thing. Nothing that manages a job
// should ever be able to name the process doing the managing.
const self = new Set([process.pid, process.ppid]);
return out
.split(/\s+/)
.map((token) => Number(token.replace(/\D/g, '')))
.filter((n) => Number.isInteger(n) && n > 1 && !self.has(n));
}
/**
* Stop a running background job.
*
* The platform does not own these processes — Claude Code starts them, and nothing records a pid. What it
* does have is the file the job streams into, and the job is still holding that file open. So the pid
* comes from `fuser`, and the file is resolved through the SAME scoped lookup the read path uses: a task
* id is validated, then matched only under this caller's own directories. An id that resolves to nothing
* kills nothing.
*
* SIGTERM by default, and SIGKILL only when asked. A dev server with children should get the chance to
* take them down with it; SIGKILL orphans them and leaves the port bound, which is worse than a job that
* took a second to stop.
*
* `force` exists because that reasoning stops applying the moment a process ignores SIGTERM: there is
* then no graceful shutdown to protect, only a job that cannot be stopped from the UI at all. The comment
* in task-restart.ts already said "that is what the second click is for" — this is the second click
* actually doing something different from the first.
*
* Only agent-run SHELL jobs can be stopped this way. A subagent writes a `.jsonl` transcript rather than
* holding a log open, so there is no fd to trace and this reports that rather than pretending.
*/
export async function stopBackgroundTask(
who: ChatIdentity,
taskId: string,
force = false,
): Promise<{ stopped: boolean; reason?: string; signal?: 'TERM' | 'KILL' }> {
if (!TASK_ID_RE.test(taskId)) return { stopped: false, reason: 'bad task id' };
const found = findTaskOutput(who, taskId);
if (!found) return { stopped: false, reason: 'no such task' };
if (found.endsWith('.jsonl')) return { stopped: false, reason: 'subagents cannot be stopped this way' };
// As the member, for both halves. `fuser` reads `/proc/<pid>/fd`, which only the process's own user and
// root may open, so run as the platform it would find no pids for a member's job and report it already
// stopped. And the kill would then be cross-user: EPERM, silently swallowed by the catch below.
const asUser = (command: string[]) => (who.osUser ? runAsArgv(who.osUser, command) : command);
const pids = await pidsHoldingFile(who, found);
if (pids.length === 0) return { stopped: false, reason: 'already stopped' };
if (who.osUser) {
// One fork for the whole set rather than one per pid, and the kernel checks the member's own identity
// against processes that are already theirs.
const kill = Bun.spawn(asUser(['kill', '-TERM', ...pids.map(String)]), { stdout: 'ignore', stderr: 'ignore' });
await kill.exited;
return { stopped: true };
}
for (const pid of pids) {
try {
process.kill(pid, 'SIGTERM');
} catch {
// Gone between listing and killing, or not ours. Either way there is nothing to do about it.
}
}
return { stopped: true };
}
/**
* What a background task is doing right now. Returns null when nothing has been written yet — which is
* the normal state for the first second or two of a task's life, not an error.
*/
export function loadBackgroundTask(who: ChatIdentity, taskId: string): BackgroundTaskDetail | null {
if (!TASK_ID_RE.test(taskId)) return null;
const found = findTaskOutput(who, taskId);
if (!found) return null;
// As the member, for the same reason the lookup is: resolving a link needs `x` on every directory above
// it, and `/tmp/claude-<uid>` is `drwx------`. Resolving it as the platform threw EACCES on an INTACT
// link, and the catch reported that as dangling — so a member's job was found and then declared to have
// written nothing, which is exactly what it looked like on screen.
const target = realpathAs(who.osUser, found);
if (!target) return null; // dangling, or gone between the lookup and here
if (target.endsWith('.jsonl')) {
const detail = parseClaudeTranscript(who.osUser, target, taskId);
if (!detail) return null;
const messages = detail.messages.map((m) =>
m.role === 'tool' && m.output && m.output.length > OUTPUT_CAP
? { ...m, output: `${m.output.slice(0, OUTPUT_CAP)}\n… (truncated)` }
: m,
);
return { kind: 'agent', messages };
}
try {
const { text, truncated } = tailFile(who.osUser, target, LOG_TAIL_BYTES);
return { kind: 'log', text, truncated };
} catch {
return null;
}
}
// ── Working directories (pwds) ──
// Every dir you've run Claude in shows up as a project group under ~/.claude/projects. We read the
// real `cwd` back from each group's transcripts so the UI can offer "jump to any project's sessions".
/** Read the `cwd` recorded in a transcript, from a bounded head read (cwd appears in early entries). */
function firstCwd(osUser: AsUser, filePath: string): string {
try {
return readHeadAs(osUser, filePath, 32768).match(/"cwd":"([^"]*)"/)?.[1] ?? '';
} catch {
return '';
}
}
export type ClaudePwd = { cwd: string; sessionCount: number; updatedAt: string; isDefault: boolean };
/** All working directories that have Claude sessions, plus the caller's home. Newest first. */
/**
* Every transcript this identity owns, with the first real model it recorded.
*
* For the `chat_session_models` backfill (`scripts/backfill-session-models.ts`) — the one caller that wants
* a model per session across the WHOLE tree rather than for one directory.
*
* ── Why the FIRST model is the right one to read ──
*
* Sounds wrong: the table stores the model a session is *currently* using, so the last should win. But
* changing model mid-conversation only became possible on 2026-08-17, so no transcript written before that
* can contain more than one. First and last are the same value for every session this backfill will ever
* see, and reading the first costs a bounded head read instead of a full one — which matters, because the
* transcripts on this machine run to 59MB.
*
* Any session that takes a real turn afterwards overwrites the guess with the exact alias, `[1m]` and all.
* A future re-run of the backfill cannot regress that: `seedMissingChatSessionModels` only inserts.
*
* `<synthetic>` is skipped — it is Claude Code's marker for its own generated entries, not a model.
*/
export function listTranscriptModels(who: ChatIdentity): { sessionId: string; apiModel: string }[] {
const projectsDir = claudeProjectsDir(who.home);
const out: { sessionId: string; apiModel: string }[] = [];
for (const file of listTranscriptsAs(who.osUser, projectsDir)) {
const path = join(projectsDir, file.slug, `${file.id}.jsonl`);
let head: string;
try {
// 64KB rather than the 32KB `firstCwd` uses: `cwd` is on the very first entry, while the first
// ASSISTANT entry (the only kind that carries a model) sits after the user's opening message, which
// can be long. A miss here costs one absent row, not a wrong one.
head = readHeadAs(who.osUser, path, 65536);
} catch {
continue; // Unreadable transcript. One missing row, and the next turn fills it in.
}
// Scan for the first `"model":"…"` that is not the synthetic marker. Regex rather than JSON.parse per
// line because the head read almost certainly ends mid-line.
const apiModel = [...head.matchAll(/"model":"([^"]+)"/g)].map((m) => m[1]!).find(isRealModel);
if (apiModel) out.push({ sessionId: file.id, apiModel });
}
return out;
}
export function listClaudePwds(who: ChatIdentity): ClaudePwd[] {
const projectsDir = claudeProjectsDir(who.home);
const defaultCwd = who.home;
const byCwd = new Map<string, { count: number; updatedAt: string }>();
// One listing for the whole tree, grouped here. This used to be `readdirSync` + a `statSync` per file,
// and BOTH are EACCES for a member — the readdir threw uncaught, so this endpoint answered 500 rather
// than answering wrongly. That 500 was the visible half of the empty conversation list.
const byGroup = new Map<string, { count: number; newest: number; first: string }>();
for (const file of listTranscriptsAs(who.osUser, projectsDir)) {
const prev = byGroup.get(file.slug);
byGroup.set(file.slug, {
count: (prev?.count ?? 0) + 1,
newest: Math.max(prev?.newest ?? 0, file.mtimeMs),
first: prev?.first ?? file.id,
});
}
for (const [slug, group] of byGroup) {
// The cwd is a property of the transcript's entries, not of the slug, which is lossy and cannot be
// reversed. Any file in the group answers it.
const cwd = firstCwd(who.osUser, join(projectsDir, slug, `${group.first}.jsonl`));
if (!cwd) continue;
const updatedAt = new Date(group.newest).toISOString();
const prev = byCwd.get(cwd);
byCwd.set(cwd, {
count: (prev?.count ?? 0) + group.count,
updatedAt: prev && prev.updatedAt > updatedAt ? prev.updatedAt : updatedAt,
});
}
if (!byCwd.has(defaultCwd)) byCwd.set(defaultCwd, { count: 0, updatedAt: '' });
return [...byCwd.entries()]
.map(([cwd, v]) => ({ cwd, sessionCount: v.count, updatedAt: v.updatedAt, isDefault: cwd === defaultCwd }))
.sort((a, b) => (a.isDefault ? -1 : b.isDefault ? 1 : b.updatedAt.localeCompare(a.updatedAt)));
}
/**
* Every transcript in a working directory, one entry each, with lineage resolved.
*
* Lineage is worked out across the whole group at once — a session's parent is only knowable relative
* to its neighbours, so there is no per-file answer to cache. The per-file summaries underneath it are
* mtime-cached, which is what makes calling this on every request cheap.
*/
function scanGroup(who: ChatIdentity, cwd: string): TranscriptSummary[] {
const slug = projectSlug(cwd);
const dir = join(claudeProjectsDir(who.home), slug);
const sessions: TranscriptSummary[] = [];
for (const file of listTranscriptsAs(who.osUser, claudeProjectsDir(who.home), slug)) {
const summary = summarizeTranscript(who.osUser, join(dir, `${file.id}.jsonl`), file.id, file.mtimeMs);
if (summary) sessions.push(summary);
}
return applyLineage(sessions);
}
/**
* Conversations Claude has stored for a working directory, newest first, one row per `/clear` chain.
*
* **Says nothing about commanders**, deliberately. It used to: every commander shared one directory, so the
* directory was the answer and this computed it once per group. That stopped being true the moment a
* commander could choose its own working directory — a commander in the platform checkout would have been
* reported as an ordinary chat, and an ordinary chat in the old commanders directory as a commander.
*
* Identity is now the `command_hq_sessions` row, which is the only thing that survives the cwd being free.
* It is applied by the CALLER, because that requires a database read and this function is synchronous over a
* directory scan.
*/
/**
* A transcript with no human or assistant turn in it is not a conversation.
*
* The harness writes metadata records — `last-prompt`, `ai-title`, `atis-latch` — into a transcript file
* independently of the conversation, and it will happily create the file to hold them. So a session that was
* deleted, or one that never sent anything, leaves a file containing only those three lines, which listed as
* "(untitled) · 0 msgs" and accumulated: four of them from one evening of testing.
*
* Filtered at the list rather than deleted on disk. The files are the harness's, it is entitled to write
* them, and a cleanup that raced it would be the same class of bug as the one that produced them.
*/
const hasConversation = (s: ClaudeSessionSummary): boolean => s.messageCount > 0;
export function listClaudeSessions(who: ChatIdentity, cwd: string): ClaudeSessionSummary[] {
return mergeChains(scanGroup(who, cwd))
.map(publish)
.filter(hasConversation)
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}
/**
* One session's list-shaped summary, found by id wherever it lives.
*
* For callers that hold a set of ids rather than a directory — Command HQ, which reads its commanders from
* `command_hq_sessions` and cannot scan for them any more now that each chooses its own working directory.
*
* Returns null when no transcript can be found, which is the same signal a directory scan gave by omission:
* a filed row whose conversation has been deleted. `purgeCommandHqSessions` is what tidies those.
*
* Deliberately NOT chain-aware in the way `listClaudeSessions` is. It reports the transcript addressed by
* this id; a `/clear` chain's head is what gets filed and what every surface addresses, so the tail is not
* something a caller here holds an id for.
*/
export function loadClaudeSessionSummaryById(who: ChatIdentity, sessionId: string): ClaudeSessionSummary | null {
const filePath = locateTranscript(who, sessionId);
if (!filePath) return null;
// `mtimeMs` becomes `updatedAt`, which is what every caller sorts on — so it has to be real, not zero.
// Taken from `listTranscriptsAs` over the transcript's OWN directory rather than a bare `statSync`: the
// file may belong to a member, and reading a member's tree needs the as-user path. One directory listing
// per commander, scoped to one group — the same call the old whole-directory scan made, once per row
// instead of once for all of them, which is the cost of letting each choose where it lives.
const group = basename(dirname(filePath));
const entry = listTranscriptsAs(who.osUser, claudeProjectsDir(who.home), group).find((f) => f.id === sessionId);
if (!entry) return null;
const summary = summarizeTranscript(who.osUser, filePath, sessionId, entry.mtimeMs);
return summary ? publish(summary) : null;
}
/**
* Every conversation in every directory, newest first.
*
* For the sessions list's **Latest** tab, which asks a question the per-directory listing cannot: what have
* I been working on lately, wherever it was. The alternative was one request per directory from the client,
* which turns one screen into twenty round trips and still cannot sort across them.
*
* ── Why it loops directories rather than scanning flat ──
*
* `/clear` chains are merged into one row, and a chain lives inside a single project group. Scanning every
* transcript flat and merging afterwards would let two unrelated chains in different directories collapse
* into each other, because the lineage heuristics match on time and title rather than on path. Looping
* `listClaudeSessions` per directory keeps merging scoped exactly the way the per-directory list does, so
* the same conversation is one row in both views.
*
* The cost is one group scan per directory — the same total work as expanding every node of the tree, and
* bounded by how many directories you have ever run `claude` in.
*/
export function listAllClaudeSessions(who: ChatIdentity): ClaudeSessionSummary[] {
return listClaudePwds(who)
.flatMap((pwd) => listClaudeSessions(who, pwd.cwd))
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}
/**
* One session's list-level facts: the title it is shown under, and how many parts it spans.
*
* A transcript cannot answer either on its own — both are properties of the `/clear` chain it belongs
* to — so this goes through the group listing. It exists so the open conversation is labelled the same
* way its row is, instead of the two drifting.
*
* The fallback matters: the merged list only holds chain heads, so a link to a part in the middle of a
* chain (an old bookmark, or the parent link from before merging) finds nothing there. Such a link
* still opens a real transcript, so answer for the part itself rather than 404 the title.
*/
export function claudeSessionContext(
who: ChatIdentity,
cwd: string,
sessionId: string,
): { title: string; partCount: number } | null {
const merged = listClaudeSessions(who, cwd).find((entry) => entry.id === sessionId);
if (merged) return { title: merged.title, partCount: merged.partCount ?? 1 };
const part = scanGroup(who, cwd).find((entry) => entry.id === sessionId);
return part ? { title: part.title, partCount: 1 } : null;
}
/**
* The display title for a session known only by id — what `/chat/live` needs.
*
* `claudeSessionContext` is the real answer but has to be told the group, and a live session can be in
* any of them; the agent reports session keys and nothing else. So find the transcript by scanning the
* slugs, take the cwd off its own first entry, and hand that to the normal path — which means the Live
* panel shows exactly the title the list shows, `/clear` chains merged and all, rather than a second
* opinion about naming.
*
* Reads the file to answer. That is a few milliseconds against a poll every ten seconds over a handful
* of live sessions, so it is not worth a cache yet — but it is worth knowing before this is called from
* anywhere hotter.
*/
export function liveSessionTitle(who: ChatIdentity, sessionId: string): { title: string; cwd: string } | null {
const filePath = locateTranscript(who, sessionId);
if (!filePath) return null;
// The cwd is a property of the transcript's entries, so the first one carrying it settles which group
// this session belongs to — no need to reverse the slug, which is lossy.
let cwd: string | null = null;
try {
for (const line of readTextAs(who.osUser, filePath).split('\n')) {
if (!line.trim()) continue;
const entry = JSON.parse(line) as { cwd?: string };
if (entry.cwd) {
cwd = entry.cwd;
break;
}
}
} catch {
return null;
}
if (!cwd) return null;
const context = claudeSessionContext(who, cwd, sessionId);
return context ? { title: context.title, cwd } : null;
}
/**
* Every transcript that has to go when this conversation is deleted: itself and everything it
* continues. The row stands for the whole chain, so deleting it has to mean the whole chain — leaving
* the ancestors behind would resurrect them as separate rows the moment their child was gone, which
* reads as the delete having half worked.
*/
function chainFileIds(who: ChatIdentity, cwd: string, sessionId: string): string[] {
const group = scanGroup(who, cwd);
const head = group.find((session) => session.id === sessionId);
if (!head) return [sessionId];
return chainOf(head, new Map(group.map((session) => [session.id, session]))).map((part) => part.id);
}