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
1204 lines
58 KiB
TypeScript
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];
|
|
|
|
/** `&` last, or `&lt;` would decode twice and become `<`. */
|
|
const decodeEntities = (text: string) =>
|
|
text
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, "'")
|
|
.replace(/&/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);
|
|
}
|