Files
platform/src/servers/api/chat/claude-sessions.ts
T
pastilhasandClaude Opus 5 177394fb76 Stop becomes Kill when the job ignores it
A background job that ignores SIGTERM could not be stopped from the UI at all.
Stop sends SIGTERM to every pid holding the job's output file, and the second
click sent the same signal again — task-restart.ts already said "that is what
the second click is for", and it was not. Reported after a job hung in another
chat.

The button now escalates instead of a second button appearing beside it. Press
Stop, and if the job is still running three seconds later the same control
becomes Kill in the destructive tone, and sends SIGKILL.

Escalating rather than always offering Kill, because a permanent Kill button is
a second destructive control competing with the first, and people reach for it
by default — losing the graceful shutdown that usually works. The delay is the
same argument: a clean exit takes well under a second, so offering Kill at once
teaches everyone to press it first.

`?force=1` on the existing DELETE rather than a second route: same verb on the
same thing, differing only in how hard it asks. The member path escalates too —
one `kill -KILL` run as them, since fuser and the kill must both be their own
identity.

Stated in the button's own title rather than hidden: SIGKILL gives the process
no chance to release a port or take its children with it. That is the right
trade for a hang and the reason it is not offered first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 14:02:18 +00:00

1072 lines
50 KiB
TypeScript

import { 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;
};
/** `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 };
/**
* Claude records an interrupted turn by writing one of these as the *user's* next message — it is how the
* model is told, on the next turn, that it was cut off. Replayed literally it reads as something you
* typed, so the transcript showed a message you never sent. Matched whole-string only: this text appears
* inside real messages too (this file's own conversation being one), and those are genuinely yours.
*/
const INTERRUPTION_MARKERS = new Set(['[Request interrupted by user]', '[Request interrupted by user for tool use]']);
type ContentBlock =
| { type: 'text'; text?: string }
| { type: 'thinking' }
| { type: 'tool_use'; id: string; name: string; input?: Record<string, unknown> }
| { type: 'tool_result'; tool_use_id: string; is_error?: boolean | null; content?: unknown };
function blockText(content: unknown): string {
if (typeof content === 'string') return content;
if (Array.isArray(content)) {
return content
.map((b) =>
b && typeof b === 'object' && (b as { type?: string }).type === 'text'
? ((b as { text?: string }).text ?? '')
: '',
)
.join('');
}
return '';
}
/**
* A background task reporting in. The harness delivers these to the agent by writing them as the *user's*
* next message — the same trick as `INTERRUPTION_MARKERS`, and the same consequence: replayed literally,
* the transcript shows a raw XML envelope in a bubble on your side of the conversation that you never
* typed. Live it never appears, because a finished task travels as its own `task:notification` event; it
* surfaces only when a refresh rebuilds the conversation from Claude's file.
*
* Anchored to the start of the message rather than matched anywhere in it, so quoting one inside a real
* message — this file's own conversation does exactly that — stays yours.
*/
const isTaskNotification = (text: string) => text.trimStart().startsWith('<task-notification>');
function userMessageFrom(text: string): ClaudeChatMessage | null {
if (isTaskNotification(text)) return null;
if (INTERRUPTION_MARKERS.has(text.trim())) return { role: 'interrupted' };
return { role: 'user', text };
}
export type ClaudeSessionDetail = { id: string; model: string; cwd: string; messages: ClaudeChatMessage[] };
/** Parse a session's JSONL transcript into a flat, display-ready message list. Claude is the source. */
function parseClaudeTranscript(
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): boolean {
const filePath = findTranscript(who, cwd, sessionId);
if (!filePath) return false;
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 true;
}
/**
* Rename a session by appending a `summary` entry to its transcript — Claude's own on-disk format, so
* the title lives in .claude (source of truth). Our reader takes the last summary as the title; no
* timestamp is written so the rename doesn't reorder the list.
*/
export function renameClaudeSession(who: ChatIdentity, cwd: string, sessionId: string, title: string): boolean {
const filePath = findTranscript(who, cwd, sessionId);
if (!filePath) return false;
// Attach the summary to the transcript's tip (the last entry carrying a uuid).
let leafUuid = sessionId;
const lines = 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. */
export function listClaudeSessions(who: ChatIdentity, cwd: string): ClaudeSessionSummary[] {
return mergeChains(scanGroup(who, cwd))
.map(publish)
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}
/**
* 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);
}