Files
claude-agent-acp/src/session-index/transcript-scan.ts
nikita-ashihmin 03d84f6663 feat: add the AIR sessionIndex extension for fast session lists (#1268)
## Summary

Adds the opt-in AIR `sessionIndex` extension. With it, an AIR client can
show the latest sessions of a project quickly, with their state, and can
rename, archive and unarchive them over ACP. The full contract is in
`docs/air-extensions.md` ("Session index").

The extension follows ACP RFDs. The transport differs: it uses
`_meta.jetbrains.air` request options and row fields, `_session/*`
methods, and AIR capabilities that are not advertised under ACP v2. The
`archived` list filter also differs (see below).

- **Session list extensions**
(agentclientprotocol/agent-client-protocol#2323):
- `limit`, ordered by last user activity (`lastPromptAt ?? updatedAt`),
and `includeWorktrees`.
  - Row fields `lastPromptAt`, `model` and `forkedFrom`.
- `state`: `running`, `idle`, `requires_action`, and `error`, which the
RFD lacks.
  - `lastTurnEndedAt`.
- `cost`: the SDK's `total_cost_usd` for a session that runs on this
connection only. No cost is read from transcripts.
  - Live updates come from an AIR list subscription (below).
- **Session archive** (agentclientprotocol/agent-client-protocol#2161):
- `archived` is on every row, `session_info_update` is sent on a change,
and an unknown session is `-32002`.
- The `archived` list filter has three values: `"unarchived"` (the
default), `"archived"` (archived sessions only) and `"all"` (both, in
one order). #2161 has a boolean here. Its `false` is `"unarchived"`, its
`true` is `"all"`, and `"archived"` has no counterpart.
- **Capabilities**: an AIR client that declares `sessionIndex` gets
these in `_meta.jetbrains.air.capabilities`. The client declares only
`sessionIndex`.
  - `sessionIndex`.
- `sessionArchive`: `_session/archive` and `_session/unarchive`. The RFD
capability is `archive` (#2161).
- `sessionRename`: `_session/rename`. The RFD has capability `setTitle`
and method `session/set_title`
(agentclientprotocol/agent-client-protocol#1987).
- `sessionListSubscribe`: `_session/list/subscribe`,
`_session/list/unsubscribe` and `_session/list/changes`. No RFD
counterpart.

### Clients without `sessionIndex`

Clients that don't declare `sessionIndex`, which means non-AIR clients
and released AIR builds, keep the current behaviour. They get the same
SDK `listSessions` path, page size, cursor and initialize capabilities,
with no watchers and no notifications. There are two deliberate
deviations, both strictly safer:

1. **`session/delete` from an AIR client without `sessionIndex`.**
Released AIR builds use delete as "Done". Instead of deleting the
transcript (data loss), the adapter archives the session in AIR's own
Claude format (below). Archived sessions are hidden from that client's
list, so "Done" looks the same and nothing is lost. An unknown session
keeps the SDK's error. Other clients get the SDK delete, as before.
2. **`session/close`** of a session that is not loaded here returns `{}`
instead of throwing.

### Sessions open in another process

`session/load`, `session/resume` and `session/prompt` work on a session
that another live Claude Code process has open, for every client, as the
Claude CLI does. Rename, archive, unarchive and delete also go ahead,
writing records or deleting transcripts as the CLI's `/rename` and
delete do. The other process keeps running.

### Archive format and rule

The archive state uses AIR's native Claude archive format
(`ClaudeStoreThreadBackend.appendTitleRecords`, ADR 0140), so AIR's
native path and the adapter read and write the same state.

- **Archive** appends `{"type":"custom-title","customTitle":"[archived]
<title>",…}` and `{"type":"agent-name","agentName":"[archived]
<title>",…}` to every transcript copy. **Unarchive** appends the same
pair without the prefix. Titles are cut to the CLI's 200 characters.
- **Archived is decided by the agent name first**, as in AIR's native
Claude provider. A session's name is its last top-level `agentName`,
else the custom title the SDK reports. The session is archived exactly
when that name starts with `[archived] `. The listed title comes from
the same source, shown without the prefix.
- Title records are metadata: `updatedAt` and the list order do not
move.

### Rename, archive and delete

- **Rename** writes the same title pair. An archived session keeps its
prefix. A session running here is renamed through its CLI's
`rename_session` control request, serialised with auto-titling. Other
sessions get the records plus the `custom-title.json` sidecar the CLI
writes. No generated title replaces a client title, but a title stored
later by someone else (a `/rename`) is still adopted.
- **Archiving stops the running session.** `_session/archive` of a
session loaded on this connection does four things:
  1. It interrupts the running turn.
2. It retitles the session through its CLI. The adapter also appends the
matching `agent-name` record to the CLI's transcript, because the CLI
writes that record only later.
  3. It closes the session as `session/close` does.
4. It archives the transcripts once more if the closed CLI left them
unarchived.

The client then gets `session_info_update` with `archived: true`.
Unarchive does not reopen the session. A session that is not loaded here
is archived by writing the title records.
- **Delete** for a `sessionIndex` client deletes every copy of the
session and its `<sessionId>/` directories. A session running here is
closed first.

### Implementation (`src/session-index/`)

- **Index**:
- Reads the project dirs with `readdir` + `stat`, and keeps a 2000-entry
metadata LRU keyed by (path, mtime, size).
- Cache misses are read in parallel through `getSessionInfo`, plus a
growing tail read (64 KB → 4 MB) for the last prompt, last message, turn
end and agent name.
- SDK semantics are kept: sidecar titles, `continued-in`, long-path
hashes, darwin case, and first-prompt extraction (ported, and
fuzz-compared with the SDK).
- The `cwd` scope follows the SDK `listSessions`. Worktrees expand to
the same relative subdirectory, and a session copied to several project
directories is listed from its newest copy.
- The keyset cursor is bound to `cwd`, `archived` and
`includeWorktrees`. Session ids match exactly.
- **State**:
  - For sessions of this process, the SDK state.
- For other sessions, the Claude Code live registry
(`~/.claude/sessions/<pid>.json`, with pid + procStart liveness and
Claude Desktop's rules) or the transcript tail. The registry is read
only for the row state.
- `error` means the session is not running and its last turn ended with
an error, not a cancel.

Documented in `docs/air-extensions.md`, with an extension → RFD mapping
table and the remaining differences.

### Measured on a real `~/.claude` (233 transcripts, read-only)

| Case | New path | Old SDK path |
|---|---|---|
| cwd, limit 50, cold process | ~80 ms | 78–156 ms (all rows) |
| cwd, limit 50, warm | 1.3–4 ms | — |

## Session list subscription

Replaces polling the first page. It is only for a client that declared
`sessionIndex`; the agent advertises `sessionListSubscribe` with it,
never under ACP v2.

- **Subscribe**: `_session/list/subscribe { cwd }` returns `{
subscriptionId }`. `cwd` must be absolute, else `-32602`; other params
are ignored.
- The scope is `cwd` and the same subdirectory in every linked worktree,
in any archive state, without sidechains.
- **Changes**: `_session/list/changes { subscriptionId, sessions,
removed }`.
- `sessions` holds the full `session/list` row of each session that
appeared, or whose title, `lastPromptAt`, `state`, `lastTurnEndedAt`,
`cost`, `model`, `forkedFrom` or `archived` changed since the last row
sent.
- The first change of a session after subscribe is sent in full. After
that, a change of `updatedAt` alone sends nothing.
  - `removed` lists sessions that were deleted or moved out of scope.
  - Archive and unarchive are row changes.
- A session is sent at most once a second, with one notification per
subscription per pass.
- **Unsubscribe**: `_session/list/unsubscribe { subscriptionId }`
returns `{}` and is idempotent.
- A subscription lives until unsubscribe or until the connection closes.
- A connection has at most 128 subscriptions. The 129th is `-32602` with
`data.reason: "too_many_subscriptions"`.
- Subscriptions of the same `cwd` share one watch and are notified
independently.
- **Delivery** is best effort, with no resync and no sequence numbers. A
rescan every 10 s (and 1 s after a project directory watch opens) sends
whatever differs.
- **Implementation** (`list-subscriptions.ts`, `session-info.ts`):
- A non-recursive `fs.watch` on the scope's project dirs, the registry
and the projects root.
- A transcript event re-reads only that transcript, and a registry event
reads only that `<pid>.json`. Sessions running here push their SDK state
at once.
- Subscribe reads no session row. The rescan reads only transcripts
whose stat changed or whose read failed.

### Measured: one subscription on the 5,200-session project (Docker
`node:22`, 2 CPU, 2 GB, shared host)

300 s of churn: 20 sessions appending 10/s each (updatedAt-only),
registry status flips every 200 ms, and a session created every 2 s and
deleted 10 s later.

| | Old `list_changed` watcher | Subscription |
|---|---|---|
| Agent CPU p50 / max | 19% / — | 12.9% / 49% |
| Event loop p99 (p50 / max of 1 s windows) | — | 16 / 31 ms; worst
single delay 87 ms |
| Change latency p50 / p95 | 950 ms (hint) | create 291 / 570 ms, delete
279 / 506 ms; registry state 902 / 990 ms* |
| Notifications / min | — | 90 (896 rows, 0 rows for updatedAt-only
appends) |
| RSS | flat ~160 MB | 159 → 198 MB (heap flat ~50 MB) |

\* The registry flips the same 5 sessions every 200 ms, so each is held
to the once-a-second limit. Latency is measured from the first unsent
change.

These numbers were measured with an earlier subscribe that read every
row up front. Subscribe now reads no rows, so that one-off cost no
longer occurs. The lazy subscription has not been re-measured yet.

## Load test: 10,000 sessions (Docker, 2 CPU, 2 GB)

Calibrated data: 10k transcripts, 15 GB, 36k subagent files, and one
5,200-session project. Sizes match a real profile (p50 311K / p90 2.67M
/ p99 20.5M).

| Scenario (limit 50) | Before | After |
|---|---|---|
| Big project, first page cold / warm p50 | out of memory | 225 / 19 ms
|
| No cwd, cold / warm p50 | 1,722 / 178 ms | 209 / 45 ms |
| Page through 4,637 rows | 457 s | 11.4 s cold, 2.9 s warm |
| Delete p50 / 100 concurrent | 23.8 ms / 4.4 s | 7.8 ms / 0.8 s |
| 300 s of churn (60k changes) | out of memory after 3 s | list p50 54
ms, 0 stalls >50 ms, RSS flat ~160 MB |

## Streaming: keep the event loop responsive under a backlog

Under a backlog of buffered SDK messages, the adapter could freeze its
event loop for seconds. Every `query.next()` on a non-empty SDK queue
resolves in a microtask, so `runConsumer` forwarded the whole backlog
without reaching a macrotask boundary. Incoming JSON-RPC, including
`session/cancel`, waited until the backlog drained.

1. **Consumer yields to the event loop.** A shared `EventLoopYielder`
(`src/utils.ts`) gives the event loop a turn via `setImmediate` once the
current iteration has run for 8 ms. Order, content and cancel semantics
are unchanged.
2. **No per-message copy in `nodeToWebWritable`** for byte streams.
Object-mode streams keep the copy.
3. **Diff memo.** The approval preview and the PostToolUse patch no
longer diff the same texts twice. Timed-out diffs are not remembered.
4. **Prefix guards** in `parseMcpCommand` and `sanitizeTitle`. Both give
identical output.
5. **Replay yields between history messages**, with the same budget.

With 10 sessions × 1 MB, the max event-loop delay went from 4205 ms to
1626 ms, and ping p95 from 2793 ms to 262 ms (interleaved A/B, Docker
`node:22`, 2 CPU). Single-stream throughput is unchanged. Remaining
stalls are inside the Claude SDK and GC.

## Test plan

- [x] Rebased on `origin/main`. `npm run check`, `npm run build` and
`npm run test:run` pass.
- [x] Regression tests for the unchanged non-`sessionIndex` paths.
- [x] Regression tests for every review finding.
- [x] Reviews: Codex (gpt-6-astra) and a Claude review subagent, run as
independent passes against `origin/main` until a round found nothing of
normal or higher severity.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:59:47 +04:00

615 lines
24 KiB
TypeScript

/**
* What the session list needs from a transcript beyond the SDK metadata.
*
* One open reads the first and the last {@link CHUNK_SIZE} bytes, the same
* window that the SDK reads. The tail gives the time of the last message, the
* end of the last turn. The head and the tail give the `cwd` candidates.
*
* A last message longer than the tail window leaves no message in it: then
* the tail window grows, up to {@link MAX_TAIL_SIZE}, until it holds one.
*/
import * as fs from "node:fs/promises";
import { firstPrompt, isUserPrompt, mediaPrompt, promptOf } from "./first-prompt.js";
const CHUNK_SIZE = 64 * 1024;
/** The largest tail window read to find the last message. */
const MAX_TAIL_SIZE = 4 * 1024 * 1024;
const TAIL_GROWTH = 4;
const FULL_SEARCH_CACHE_SIZE = 512;
/** By `path\0size`, the facts of the transcripts whose last
* {@link MAX_TAIL_SIZE} bytes lack the last message or prompt: a file of
* the same size is not searched again. */
const fullySearched = new Map<string, TranscriptFacts>();
export type TurnState = "finished" | "unfinished";
export type TranscriptFacts = {
/** Time of the last user or assistant message in the tail, epoch ms. */
lastMessageAt?: number;
/** Whether the last turn in the tail ended. Undefined when the tail shows
* no turn at all. */
turnState?: TurnState;
/** Time the last ended turn ended, epoch ms. */
lastTurnEndedAt?: number;
/** The last turn ended with an API error (an `isApiErrorMessage`
* assistant record), not a user interrupt, and nothing came after it. */
lastTurnError?: boolean;
/** The first `cwd` of the head. */
headCwd?: string;
/** The last `cwd` of the tail. */
tailCwd?: string;
/** Time of the last real user prompt in the tail (not a tool result, a
* meta record or a slash command), epoch ms. */
lastPromptAt?: number;
/** Whether `lastPromptAt` is final: the prompt was found, or the tail
* window searched up to {@link MAX_TAIL_SIZE} holds none. */
promptSearched?: boolean;
/** The model of the last assistant message in the tail. */
model?: string;
/** The session this one was forked from: the `forkedFrom.sessionId` that
* the SDK and CLI fork write on every copied record. */
forkedFrom?: string;
/** Whether the head or the tail holds a user or an assistant message. A
* transcript without one is a metadata-only stub. */
hasMessages: boolean;
};
export type HeadTail = { head: string; tail: string };
/** Facts that an earlier scan may have found before the tail window. */
const INHERITED_FACTS = ["model", "tailCwd", "lastTurnEndedAt"] as const;
/** What an earlier scan of the same file found, at `size`. */
export type PreviousScan = { size: number } & Pick<
TranscriptFacts,
"lastPromptAt" | "promptSearched" | (typeof INHERITED_FACTS)[number]
>;
/** The last `window` bytes of a file of `size` bytes, without the first,
* cut line. The whole file when it fits. */
async function readTail(handle: fs.FileHandle, size: number, window: number): Promise<string> {
const length = Math.min(window, size);
const buffer = Buffer.allocUnsafe(length);
const { bytesRead } = await handle.read(buffer, 0, length, size - length);
const raw = buffer.toString("utf8", 0, bytesRead);
if (length === size) return raw;
const newline = raw.indexOf("\n");
return newline >= 0 ? raw.slice(newline + 1) : "";
}
/** The first and the last 64 KB of the first `size` bytes of `filePath`:
* what was appended after the file was stat'ed is not read, so the result
* matches the size it is cached by. The tail is the head for a file that
* fits in one chunk. */
export async function readHeadTail(filePath: string, size: number): Promise<HeadTail> {
const handle = await fs.open(filePath, "r");
try {
return await readHeadTailOf(handle, size);
} finally {
await handle.close();
}
}
/** {@link readHeadTail} of a file that is open already. */
export async function readHeadTailOf(handle: fs.FileHandle, size: number): Promise<HeadTail> {
const buffer = Buffer.allocUnsafe(CHUNK_SIZE);
const first = await handle.read(buffer, 0, Math.min(CHUNK_SIZE, size), 0);
const head = buffer.toString("utf8", 0, first.bytesRead);
if (size <= CHUNK_SIZE) return { head, tail: head };
return { head, tail: await readTail(handle, size, CHUNK_SIZE) };
}
/**
* The facts of `filePath`, from its head and tail. When the transcript has
* messages but the tail window lacks the last message or the last user
* prompt (a long answer or tool output follows it), the window grows,
* reading only the new range each step, until it holds both or reaches
* {@link MAX_TAIL_SIZE}. The result of a full search is kept per
* `(path, size)`.
*
* `previous` is what an earlier scan of the same file found: when the file
* only grew by less than the tail window, and the tail holds no prompt, the
* appended bytes hold none either, so the earlier `lastPromptAt` stands.
*/
export async function scanTranscriptFile(
filePath: string,
size: number,
headTail?: HeadTail,
previous?: PreviousScan,
/** Tells a file replaced by another of the same size apart (its inode). */
identity?: number,
): Promise<TranscriptFacts> {
const read = headTail ?? (await readHeadTail(filePath, size));
const facts = scanTranscript(read);
// The file only grew by bytes that the tail covers, and the tail holds no
// prompt: the appended bytes hold none either, so the earlier search
// stands (its prompt, or that the last 4 MB held none).
const grownWithinTail =
previous !== undefined &&
size >= previous.size &&
size - Buffer.byteLength(read.tail) <= previous.size;
if (grownWithinTail) {
// What the earlier, possibly wider, scan found before the tail stands
// when the tail has nothing newer.
for (const key of INHERITED_FACTS) {
if (facts[key] === undefined && previous[key] !== undefined) {
(facts as Record<string, unknown>)[key] = previous[key];
}
}
}
if (facts.lastPromptAt !== undefined || size <= CHUNK_SIZE) {
facts.promptSearched = true;
} else if (grownWithinTail && (previous.lastPromptAt !== undefined || previous.promptSearched)) {
facts.lastPromptAt = previous.lastPromptAt;
facts.promptSearched = true;
}
const complete = (found: TranscriptFacts) =>
found.lastMessageAt !== undefined && found.promptSearched === true;
if (!facts.hasMessages || complete(facts) || size <= CHUNK_SIZE) return facts;
const key = `${filePath}\0${size}\0${identity ?? ""}`;
const searched = fullySearched.get(key);
if (searched) return searched;
let result = facts;
const handle = await fs.open(filePath, "r");
try {
// The bytes from `start` to the end, grown by reading the new range only.
let start = Math.max(0, size - CHUNK_SIZE);
const first = Buffer.allocUnsafe(size - start);
const { bytesRead: firstRead } = await handle.read(first, 0, first.length, start);
let bytes = first.subarray(0, firstRead);
for (let window = CHUNK_SIZE * TAIL_GROWTH; start > 0; window *= TAIL_GROWTH) {
const nextStart = Math.max(0, size - Math.min(window, MAX_TAIL_SIZE));
if (nextStart >= start) break;
const range = Buffer.allocUnsafe(start - nextStart);
const { bytesRead } = await handle.read(range, 0, range.length, nextStart);
bytes = Buffer.concat([range.subarray(0, bytesRead), bytes]);
start = nextStart;
let tail = bytes.toString("utf8");
if (start > 0) {
// The first line of the window is cut; drop it.
const newline = tail.indexOf("\n");
tail = newline >= 0 ? tail.slice(newline + 1) : "";
}
// A wider tail ends with the same records: what the narrow one found
// stays, and the wider one adds what lay before it.
const wider = scanTranscript(
{ head: read.head, tail },
{
wide: true,
known: { model: facts.model, tailCwd: facts.tailCwd },
},
);
const lastPromptAt = wider.lastPromptAt ?? facts.lastPromptAt;
result = {
...wider,
...(facts.model !== undefined && { model: facts.model }),
...(facts.tailCwd !== undefined && { tailCwd: facts.tailCwd }),
// A turn end the wider tail lacks may lie before it: the
// narrow scan inherited it from an earlier scan of this file.
lastTurnEndedAt: wider.lastTurnEndedAt ?? facts.lastTurnEndedAt,
hasMessages: facts.hasMessages,
lastPromptAt,
// Searched to the start of the file, or found.
promptSearched: facts.promptSearched || lastPromptAt !== undefined || start === 0,
};
if (complete(result)) return result;
}
} finally {
await handle.close();
}
// The whole window is searched: no prompt in it either.
result = { ...result, promptSearched: true };
fullySearched.set(key, result);
if (fullySearched.size > FULL_SEARCH_CACHE_SIZE) {
fullySearched.delete(fullySearched.keys().next().value as string);
}
return result;
}
const RELOCATED_MARKER = '"relocated"';
/** The cwd of the last `relocated` record of the tail: where the session
* was moved, whatever cwd its earlier messages carry. */
export function relocatedCwd(tail: string): string | undefined {
const lines = tail.split("\n");
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i]!;
if (!line.includes(RELOCATED_MARKER)) continue;
const entry = parseLine(line);
if (entry?.type === "relocated" && typeof entry.relocatedCwd === "string") {
return entry.relocatedCwd;
}
}
return undefined;
}
/** The cwd a transcript belongs to, as the SDK reads it: the last
* `relocated` record of the tail, else the first cwd of the head. */
export function transcriptProjectCwd({ head, tail }: HeadTail): string | undefined {
const relocated = relocatedCwd(tail);
if (relocated !== undefined) return relocated;
const headCwd = CWD_PATTERN.exec(head)?.[1];
return headCwd === undefined ? undefined : decodeJsonString(headCwd);
}
const MESSAGE_MARKERS = [
'"type":"user"',
'"type":"assistant"',
'"type": "user"',
'"type": "assistant"',
];
/** System records that the CLI writes when a turn ends. */
const TURN_END_SYSTEM_SUBTYPES = new Set(["stop_hook_summary", "turn_duration"]);
const INTERRUPT_PREFIX = "[Request interrupted by user";
type Entry = Record<string, unknown>;
function parseLine(line: string): Entry | undefined {
try {
const value: unknown = JSON.parse(line);
return value && typeof value === "object" && !Array.isArray(value)
? (value as Entry)
: undefined;
} catch {
return undefined;
}
}
function timestampOf(entry: Entry): number | undefined {
if (typeof entry.timestamp !== "string") return undefined;
const value = Date.parse(entry.timestamp);
return Number.isNaN(value) ? undefined : value;
}
function firstText(entry: Entry): string | undefined {
const message = entry.message as { content?: unknown } | undefined;
const content = message?.content;
if (typeof content === "string") return content;
if (Array.isArray(content)) {
for (const block of content) {
if (block && typeof block === "object" && (block as { type?: unknown }).type === "text") {
const text = (block as { text?: unknown }).text;
if (typeof text === "string") return text;
}
}
}
return undefined;
}
/**
* How a main-chain record bears on the end of the turn: `finished` for a
* record that ends a turn (an assistant `end_turn`, an API error, a user
* interrupt, the CLI's turn-end system records), `unfinished` for a record of
* a turn in progress, and undefined for a record that says nothing.
*/
export function turnEffect(entry: Entry): TurnState | undefined {
if (entry.isSidechain === true) return undefined;
switch (entry.type) {
case "assistant": {
if (entry.isApiErrorMessage === true) return "finished";
const stopReason = (entry.message as { stop_reason?: unknown } | undefined)?.stop_reason;
return stopReason === "end_turn" ? "finished" : "unfinished";
}
case "user": {
if (entry.isMeta === true) return undefined;
return firstText(entry)?.startsWith(INTERRUPT_PREFIX) ? "finished" : "unfinished";
}
case "system":
return TURN_END_SYSTEM_SUBTYPES.has(entry.subtype as string) ? "finished" : undefined;
default:
return undefined;
}
}
const FORKED_FROM_PATTERN =
/"forkedFrom":\s?\{"sessionId":\s?"([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})"/;
const CWD_PATTERN = /"cwd":\s?"((?:[^"\\]|\\.)*)"/;
function decodeJsonString(raw: string): string | undefined {
try {
const value: unknown = JSON.parse(`"${raw}"`);
return typeof value === "string" ? value : undefined;
} catch {
return undefined;
}
}
/** The facts of a transcript, from its head and its tail. */
export function scanTranscript(
{ head, tail }: HeadTail,
options: { wide?: boolean; known?: { model?: string; tailCwd?: string } } = {},
): TranscriptFacts {
const facts: TranscriptFacts = {
hasMessages: MESSAGE_MARKERS.some((marker) => head.includes(marker) || tail.includes(marker)),
};
const headCwd = CWD_PATTERN.exec(head)?.[1];
if (headCwd !== undefined) facts.headCwd = decodeJsonString(headCwd);
const forkedFrom = FORKED_FROM_PATTERN.exec(head)?.[1];
if (forkedFrom !== undefined) facts.forkedFrom = forkedFrom;
const lines = tail.split("\n");
let turnEndFound = false;
let messageFound = false;
let promptFound = false;
/** The last turn ended: is its end an API error? */
let checkingTurnError = false;
// A wide tail (see scanTranscriptFile) is searched for what the narrow
// one lacked: the last message, prompt and turn end, and the model and
// the cwd only when the narrow tail had none (its records can all be cut,
// e.g. one large tool result), so a long answer is not parsed line by line.
const wide = options.wide === true;
let modelFound = wide && options.known?.model !== undefined;
const searchCwd = !wide || options.known?.tailCwd === undefined;
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i]!;
if (!line) continue;
const isTurnRecord =
line.includes('"user"') || line.includes('"assistant"') || line.includes('"system"');
const hasCwd = searchCwd && facts.tailCwd === undefined && line.includes('"cwd"');
if (!isTurnRecord && !hasCwd) continue;
// Everything has been found: stop parsing.
if (
turnEndFound &&
messageFound &&
promptFound &&
modelFound &&
!checkingTurnError &&
!hasCwd
) {
break;
}
// Once the last message and the turn state are known, only a line that
// can still tell something is parsed: lines are often large (tool
// results, long answers), and a long tail has thousands of them.
if (
messageFound &&
facts.turnState !== undefined &&
!hasCwd &&
!(!promptFound && mayBePrompt(line)) &&
!(!modelFound && hasType(line, "assistant")) &&
!(!turnEndFound && mayEndTurn(line)) &&
!(checkingTurnError && isTurnRecord)
) {
continue;
}
const entry = parseLine(line);
if (!entry) continue;
if (hasCwd && typeof entry.cwd === "string" && entry.cwd) facts.tailCwd = entry.cwd;
if (entry.isSidechain === true) continue;
if (!messageFound && (entry.type === "user" || entry.type === "assistant")) {
messageFound = true;
facts.lastMessageAt = timestampOf(entry);
}
if (!promptFound && entry.type === "user" && isUserPrompt(entry)) {
promptFound = true;
facts.lastPromptAt = timestampOf(entry);
}
if (!modelFound && entry.type === "assistant") {
const model = (entry.message as { model?: unknown } | undefined)?.model;
// The CLI marks the messages it makes up (API errors) `<synthetic>`.
if (typeof model === "string" && model && model !== "<synthetic>") {
modelFound = true;
facts.model = model;
}
}
const effect = turnEffect(entry);
if (effect === undefined) continue;
if (facts.turnState === undefined) {
facts.turnState = effect;
checkingTurnError = effect === "finished";
}
if (checkingTurnError) {
// The record that ended the last turn, behind the CLI's turn-end
// system records: an API error is an error.
if (entry.type === "assistant" && entry.isApiErrorMessage === true) {
facts.lastTurnError = true;
checkingTurnError = false;
} else if (entry.type !== "system") {
checkingTurnError = false;
}
}
if (effect === "finished" && !turnEndFound) {
turnEndFound = true;
facts.lastTurnEndedAt = timestampOf(entry);
}
}
return facts;
}
function hasType(line: string, type: string): boolean {
return line.includes(`"type":"${type}"`) || line.includes(`"type": "${type}"`);
}
/** Whether a line may be a user prompt: a user record that is no tool
* result, or one that carries an image or a document. */
function mayBePrompt(line: string): boolean {
if (!hasType(line, "user")) return false;
return !hasType(line, "tool_result") || hasType(line, "image") || hasType(line, "document");
}
/** Whether a line may end a turn (see {@link turnEffect}). */
function mayEndTurn(line: string): boolean {
return (
line.includes('"end_turn"') ||
line.includes('"isApiErrorMessage"') ||
line.includes(INTERRUPT_PREFIX) ||
[...TURN_END_SYSTEM_SUBTYPES].some((subtype) => line.includes(`"${subtype}"`))
);
}
/** Every `"key":"value"` string of `text`, in order, decoded. */
function stringFields(text: string, key: string): string[] {
const pattern = new RegExp(`"${key}":\\s?"((?:[^"\\\\]|\\\\.)*)"`, "g");
const values: string[] = [];
for (const match of text.matchAll(pattern)) {
const value = decodeJsonString(match[1]!);
if (value !== undefined) values.push(value);
}
return values;
}
const lastField = (text: string, key: string) => stringFields(text, key).at(-1) || undefined;
/** Whether the first record of the transcript is a sidechain one, which the
* SDK never lists. */
export function isSidechainTranscript(head: string): boolean {
const newline = head.indexOf("\n");
const first = newline >= 0 ? head.slice(0, newline) : head;
return first.includes('"isSidechain":true') || first.includes('"isSidechain": true');
}
/** The last `customTitle` of the tail, as the SDK reads it. */
export function tailCustomTitle(tail: string): string | undefined {
return lastField(tail, "customTitle");
}
/** The last non-blank top-level `agentName` of the records of `text`, the
* name `/rename`, a `rename_session` and AIR write with the custom title. */
function lastAgentName(text: string): string | undefined {
if (!text.includes('"agentName"')) return undefined;
const lines = text.split("\n");
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i]!;
if (!line.includes('"agentName"')) continue;
const value = parseLine(line)?.agentName;
if (typeof value === "string" && value.trim()) return value;
}
return undefined;
}
/** The last agent name of a transcript, as AIR's native Claude provider
* reads it: the top-level `agentName` of any record, from the tail, else
* from the head. */
export function transcriptAgentName({ head, tail }: HeadTail): string | undefined {
return lastAgentName(tail) ?? lastAgentName(head);
}
/** What the SDK reports as the titles of a session. */
export type SdkTitles = {
/** `SDKSessionInfo.customTitle`: the title the CLI or a client set. */
customTitle?: string;
/** `SDKSessionInfo.summary`: the custom title, else a generated one. */
summary?: string;
};
/**
* The titles the SDK gives a transcript, from its own head and tail, in the
* SDK's order: custom title (tail, the sidecar, head), AI title, then
* {@link generatedTitle}. For a transcript that the SDK `getSessionInfo`
* does not read (another copy of the session comes first in its search).
* The sidecar title counts only when the tail has no custom title.
*/
export function sdkTitles({ head, tail }: HeadTail, sidecarTitle?: string): SdkTitles {
const customTitle =
lastField(tail, "customTitle") ??
sidecarTitle ??
lastField(head, "customTitle") ??
lastField(tail, "aiTitle") ??
lastField(head, "aiTitle");
return { customTitle, summary: customTitle ?? generatedTitle({ head, tail }) };
}
/** The title of a transcript without a custom title, in the SDK's order: AI
* title, last prompt, summary, first prompt. */
export function generatedTitle({ head, tail }: HeadTail): string | undefined {
return (
lastField(tail, "aiTitle") ??
lastField(head, "aiTitle") ??
lastField(tail, "lastPrompt") ??
lastField(tail, "summary") ??
(firstPrompt(head) || mediaPrompt(head) || undefined)
);
}
/** The time of the last `"timestamp"` in the last 64 KB of a file, cut
* line included, epoch ms: a record longer than that still ends with it. */
export async function lastTimestamp(filePath: string, size: number): Promise<number | undefined> {
const handle = await fs.open(filePath, "r");
let text: string;
try {
const length = Math.min(size, CHUNK_SIZE);
const buffer = Buffer.allocUnsafe(length);
const { bytesRead } = await handle.read(buffer, 0, length, size - length);
text = buffer.toString("utf8", 0, bytesRead);
} finally {
await handle.close();
}
for (const raw of stringFields(text, "timestamp").reverse()) {
const value = Date.parse(raw);
if (!Number.isNaN(value)) return value;
}
return undefined;
}
const CONTINUED_IN_MARKER = '"type":"continued-in"';
/**
* The session this transcript was continued in, as the SDK list reads it: the
* last `continued-in` record of the tail, unless a completed turn follows it
* (the session was resumed here again).
*/
export function continuedInSessionId(tail: string): string | undefined {
if (!tail.includes(CONTINUED_IN_MARKER)) return undefined;
const lines = tail.split("\n");
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i]!;
const isContinuation = line.includes(CONTINUED_IN_MARKER);
const isMessage = line.includes('"type":"user"') || line.includes('"type":"assistant"');
if (!isContinuation && !isMessage) continue;
const entry = parseLine(line);
if (!entry) continue;
if (isContinuation && entry.type === "continued-in") {
const id = entry.continuedInSessionId;
return typeof id === "string" && SESSION_ID.test(id) ? id : undefined;
}
if (entry.type === "assistant") {
const stopReason = (entry.message as { stop_reason?: unknown } | undefined)?.stop_reason;
if (entry.isApiErrorMessage !== true && typeof stopReason === "string") return undefined;
} else if (promptOf(entry, { commandFallback: "" }) !== undefined) {
return undefined;
}
}
return undefined;
}
const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const HISTORY_MARKER = '"parentUuid":';
const HISTORY_SCAN_LIMIT = 16 * 1024 * 1024;
const HISTORY_SCAN_CHUNK = 1024 * 1024;
/** Whether the transcript at `filePath` holds conversation history (a record
* with a `parentUuid`), as the SDK checks a continuation's successor. */
export async function hasHistory(filePath: string): Promise<boolean> {
let handle: fs.FileHandle;
try {
handle = await fs.open(filePath, "r");
} catch {
return false;
}
try {
const { size } = await handle.stat();
const buffer = Buffer.allocUnsafe(HISTORY_SCAN_CHUNK + HISTORY_MARKER.length);
let carry = 0;
for (let offset = 0; offset < Math.min(size, HISTORY_SCAN_LIMIT);) {
const { bytesRead } = await handle.read(buffer, carry, HISTORY_SCAN_CHUNK, offset);
if (bytesRead === 0) return false;
const end = carry + bytesRead;
if (buffer.subarray(0, end).includes(HISTORY_MARKER)) return true;
carry = Math.min(HISTORY_MARKER.length, end);
buffer.copyWithin(0, end - carry, end);
offset += bytesRead;
}
// Past the scan limit the SDK assumes history.
return size > HISTORY_SCAN_LIMIT;
} catch {
return false;
} finally {
await handle.close();
}
}