mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-14 21:31:52 +00:00
## Summary
Updates `@anthropic-ai/claude-agent-sdk` from 0.3.322 to 0.3.323, which
bundles Claude Code 2.1.323. The new version needs two adapter fixes,
both included here:
- the session index reads titles by the SDK's new rule;
- the heartbeat of a long-running hook no longer lands on a tool call.
Also bumps these dev dependencies: `@anthropic-ai/sdk` 1.7.0 → 1.8.0,
`@tsconfig/node22` 22.0.6 → 22.0.7 and `prettier` 3.9.10 → 3.10.1.
`npm update` brings the in-range lockfile bumps: `rolldown` 1.2.18 →
1.2.19, `vite` 8.3.7 → 8.3.8, `hono` 4.13.17 → 4.13.18,
`@hono/node-server` 2.1.6 → 2.1.8, and patch releases of
`@babel/runtime`, `brace-expansion`, `encodeurl`,
`@jridgewell/resolve-uri` and `@rolldown/pluginutils`. `rolldown` 1.2.19
lists its wasm32 binding as an optional dependency, so the lockfile
gains `@rolldown/binding-wasm32-wasi`, `@napi-rs/wasm-runtime`,
`@emnapi/core` and `@emnapi/runtime` (2.0.0-beta.2),
`@emnapi/wasi-threads`, `@tybys/wasm-util` and `tslib`. All are dev-only
and optional.
TypeScript stays at 6.0.3: 7.0.2 is out, but `@typescript-eslint/parser`
8.72.2 still requires `typescript <6.1.0`.
## Session titles
`getSessionInfo` and `listSessions` of SDK 0.3.323 read titles
differently, and neither changelog says so. The session index mirrors
the SDK's rule (`sdkTitles` in `src/session-index/transcript-scan.ts`),
so it has to follow.
- Before: the SDK took the last `"customTitle":"…"` text of the tail or
the head, wherever it stood.
- Now: it parses each line that names the field, from the last one back,
and takes the top-level string of the record. For `customTitle` and
`aiTitle` the record must be of type `custom-title` or `ai-title`.
`lastPrompt` and `summary` may be on a record of any type.
What changes for a client:
- A `customTitle` or `lastPrompt` inside a message is no title. A tool
result that shows a transcript no longer renames the session.
- A `custom-title` record that the start of the 64 KB tail window cuts
is not read. The old rule still found its title text.
- A `custom-title` record appended to a line whose write was cut off is
read.
Checked against the real SDK: a differential run of `sdkTitles` against
`getSessionInfo` over 1,800 generated transcripts gave no mismatch. The
old rule mismatched on 371 of 600. The run is not part of this PR; the
tests in `src/tests/v1/session-index.test.ts` compare each case above
with `getSessionInfo`.
## Hook heartbeats
Since Claude Code 2.1.323, a hook that runs long sends a `tool_progress`
beat every 30 seconds. Recorded from the real CLI:
```json
{
"type": "tool_progress",
"tool_use_id": "hook-PreToolUse-toolu_01X…-heartbeat-0",
"tool_name": "PreToolUse",
"parent_tool_use_id": "toolu_01X…",
"elapsed_time_seconds": 30,
"heartbeat": true
}
```
For a PreToolUse or PostToolUse hook, `parent_tool_use_id` is the tool
call. For a UserPromptSubmit or Stop hook it is an id that names no tool
call. The PostToolUse beats come before the tool result.
Both cores send a beat under an unknown id to the call that
`parent_tool_use_id` names. So without a change, a slow PreToolUse or
PostToolUse hook:
- marked the tool call `in_progress` before it ran, on v1 and v2;
- restarted its elapsed time with the hook's (30 → 1 → 2 → 3 → 30 in the
scenario);
- named the call `PreToolUse` in `_meta.claudeCode.toolName` for a plain
or Zed client.
The adapter now drops these beats. This matches how it already ignores
`hook_started`, `hook_progress` and `hook_response`: hooks have no ACP
surface. The rule that picks the tool call of a beat was written twice,
in `src/v1/acp-agent.ts` and `src/v2/stream.ts`. It is now one function,
`progressToolCallId` in `src/tool-calls/renderer.ts`, which holds the
new rule too.
The `tool-progress` scenario carries a PreToolUse and a PostToolUse
beat. Its recordings for the plain, Zed, AIR and v2 clients are
unchanged, and all four fail without the fix.
## Changelog review
Read the SDK changelog for 0.3.323 and the Claude Code changelog for
2.1.323. No other entry needs a code change:
- **Types.** Doc text and new optional members only, plus one new
`SDKStartupFailureReason` (`managed_settings_not_delivered`).
`startupFailureError` treats a reason it does not list as an internal
error with the CLI's message.
- **Context window of an unknown model id**, 200K → 1M. The adapter
reads the window from Claude Code (`getContextUsage`) at session
creation and at every model switch, so it reports the new value. Its own
200K is only the placeholder until that read lands.
- **`canUseTool` suggestions** no longer hold a Bash allow rule for a
write to `.git` or `.claude`. The adapter then offers no "always allow"
option.
- **`init.plugin_errors`** also lists load errors of plugins that
managed settings enable. The adapter only logs them.
- **`forkSession` with `upToMessageId`** and **`getSessionMessages`**
compaction fixes. Picked up as they are: the adapter has no workaround
for the old behavior.
- **A subagent's WebSearch result can carry `tool_use_result`.** The
code reads only fields that this result does not have. One comment in
`src/async-tasks.ts` said that no subagent result carries one; it now
says Bash results.
- **A permission check that fails after a PreToolUse hook allowed the
call** goes to `canUseTool`, which the adapter always provides.
## Not in this PR
Left for follow-ups, because each is its own change:
- The 200K placeholder for the context window. 1M is now the more likely
value, and with it the "1m" text heuristic has nothing left to do.
- `matchDeniedModels()` (alpha). The adapter's own `isDeniedModel`
disagrees with it on 23 of 875 model and entry pairs.
- `supersedes` on the retry of a response whose tool call could not be
parsed. The adapter does not act on `supersedes`.
- `machinePolicy.didNotLoad` (alpha) of `resolveSettings()`.
## Testing
- `npm run build`, `npm run check`, `npm run test:run` (4,121 tests
pass)
- Real CLI: one short session with slow hooks and
`CLAUDE_CODE_HOOK_HEARTBEAT_INTERVAL_MS=1000`, to record the heartbeat
frames and their order
- Real CLI: `getContextUsage` for unknown and known model ids, without a
prompt
766 lines
31 KiB
TypeScript
766 lines
31 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.
|
|
*
|
|
* The open background tasks come from the tail, on top of the tasks of an
|
|
* earlier scan (see `background-tasks.ts`).
|
|
*/
|
|
|
|
import * as fs from "node:fs/promises";
|
|
import { backgroundTasksOf, type BackgroundTasks } from "./background-tasks.js";
|
|
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;
|
|
/** What the last `worktree-state` record says: true when its
|
|
* `worktreeSession` is set and not `enteredExisting` (Claude Code made the
|
|
* worktree and the session is in it), false when it says otherwise.
|
|
* Undefined when no such record was read. See {@link agentWorktreeOf}. */
|
|
agentWorktree?: boolean;
|
|
/** The background tasks that did not end. From the tail, on top of what
|
|
* an earlier scan found when the file only grew within the tail. */
|
|
backgroundTasks?: BackgroundTasks;
|
|
/** 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;
|
|
/** The last 64 KB from their first whole line on; the whole file when it
|
|
* fits in the head. */
|
|
tail: string;
|
|
/** The last 64 KB as the SDK searches them, with the cut line before
|
|
* `tail`; absent when that is `tail`. */
|
|
sdkTail?: string;
|
|
};
|
|
|
|
/** Facts that an earlier scan may have found before the tail window. */
|
|
const INHERITED_FACTS = ["model", "tailCwd", "lastTurnEndedAt", "agentWorktree"] as const;
|
|
|
|
/** What an earlier scan of the same file found, at `size`. */
|
|
export type PreviousScan = { size: number } & Pick<
|
|
TranscriptFacts,
|
|
"lastPromptAt" | "promptSearched" | "backgroundTasks" | (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<{ raw: string; tail: 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, tail: raw };
|
|
const newline = raw.indexOf("\n");
|
|
return { raw, tail: 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 };
|
|
const { raw, tail } = await readTail(handle, size, CHUNK_SIZE);
|
|
return raw === tail ? { head, tail } : { head, tail, sdkTail: raw };
|
|
}
|
|
|
|
/**
|
|
* 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];
|
|
}
|
|
}
|
|
}
|
|
const previousTasks = grownWithinTail ? previous.backgroundTasks : undefined;
|
|
facts.backgroundTasks = tailBackgroundTasks(read.tail, size, previousTasks);
|
|
// No worktree record in the tail (or before it, by an earlier scan): the
|
|
// one `claude --worktree` writes at the start may still be in the head.
|
|
if (facts.agentWorktree === undefined) {
|
|
const agentWorktree = agentWorktreeOf(read.head);
|
|
if (agentWorktree !== undefined) facts.agentWorktree = agentWorktree;
|
|
}
|
|
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);
|
|
// The tasks can differ by the earlier scan.
|
|
if (searched) return { ...searched, backgroundTasks: facts.backgroundTasks };
|
|
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 }),
|
|
// The wider tail holds the narrow one, so a record it has is the
|
|
// latest; else the narrow scan's (inherited or from the head) stands.
|
|
...((wider.agentWorktree ?? facts.agentWorktree) !== undefined && {
|
|
agentWorktree: wider.agentWorktree ?? facts.agentWorktree,
|
|
}),
|
|
// 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,
|
|
backgroundTasks: facts.backgroundTasks,
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* The background tasks of the tail of a file of `size` bytes. `previous`:
|
|
* the tasks of an earlier scan, when the file only grew within the tail
|
|
* since. The set is complete when the tail is the whole file, or when it
|
|
* holds all records after a complete earlier set.
|
|
*/
|
|
function tailBackgroundTasks(
|
|
tail: string,
|
|
size: number,
|
|
previous: BackgroundTasks | undefined,
|
|
): BackgroundTasks {
|
|
if (size <= CHUNK_SIZE) return backgroundTasksOf(tail, { complete: true });
|
|
if (previous?.complete) return backgroundTasksOf(tail, { complete: true, after: previous });
|
|
return backgroundTasksOf(tail, { complete: false });
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
const WORKTREE_STATE_MARKER = '"worktree-state"';
|
|
|
|
/**
|
|
* Whether the last `worktree-state` record of `text` says the session is in
|
|
* a worktree that Claude Code made for it, as AIR's native Claude provider
|
|
* reads it: a `worktreeSession` that is an object without
|
|
* `enteredExisting: true`. The last line that parses as a `worktree-state`
|
|
* record decides; any other `worktreeSession` (`null`, which the CLI writes
|
|
* when the session leaves the worktree, missing, or no object) means false.
|
|
* Only lines that are not JSON (such as the cut first line of a window) are
|
|
* skipped. Undefined when `text` holds no such record.
|
|
*
|
|
* The CLI writes the record when it enters or leaves a worktree (for
|
|
* `claude --worktree`, at the start of the session, so in the head), and
|
|
* writes it again with the other session metadata at the end of the
|
|
* transcript (on exit and as the transcript grows), so the tail usually
|
|
* holds the latest one. Only the head and the tail are read: a session that
|
|
* entered a worktree midway through a long transcript, with no metadata
|
|
* written after it yet, is not seen until the CLI writes the metadata again.
|
|
*/
|
|
export function agentWorktreeOf(text: string): boolean | undefined {
|
|
if (!text.includes(WORKTREE_STATE_MARKER)) return undefined;
|
|
const lines = text.split("\n");
|
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
const line = lines[i]!;
|
|
if (!line.includes(WORKTREE_STATE_MARKER)) continue;
|
|
const entry = parseLine(line);
|
|
// Only a line that is no JSON record is skipped: the latest record that
|
|
// parses decides, even when its `worktreeSession` is missing or no object.
|
|
if (entry?.type !== "worktree-state") continue;
|
|
const session = entry.worktreeSession;
|
|
if (!session || typeof session !== "object" || Array.isArray(session)) return false;
|
|
return (session as { enteredExisting?: unknown }).enteredExisting !== true;
|
|
}
|
|
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;
|
|
// The tail only: scanTranscriptFile falls back to the head after it
|
|
// inherits what an earlier scan found before the tail.
|
|
const agentWorktree = agentWorktreeOf(tail);
|
|
if (agentWorktree !== undefined) facts.agentWorktree = agentWorktree;
|
|
|
|
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;
|
|
}
|
|
|
|
/** The string `field` of the JSON record `line`, which is of `type` when one
|
|
* is given. `null` when the line is no JSON. */
|
|
function recordField(line: string, field: string, type?: string): string | null | undefined {
|
|
let record: unknown;
|
|
try {
|
|
record = JSON.parse(line);
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (typeof record !== "object" || record === null) return undefined;
|
|
if (type !== undefined && (record as Entry).type !== type) return undefined;
|
|
const value = (record as Entry)[field];
|
|
return typeof value === "string" ? value : undefined;
|
|
}
|
|
|
|
/**
|
|
* The top-level string `field` of the last record of `text` that has one,
|
|
* as the SDK reads a session's metadata since 0.3.323: it parses each line
|
|
* that names the field, from the last one back. A `"customTitle":"…"` inside
|
|
* a message, such as a tool result that shows a transcript, is no title.
|
|
*
|
|
* With `type`, only a record of that `type` counts, and a line that names
|
|
* the type but is no JSON is read from its last `{"type":"<type>"` that does
|
|
* not follow `:`, `,` or `[`: a record appended to a line whose write was cut
|
|
* off. The record that the start of the tail window cuts has no such start
|
|
* in the window, and is not read.
|
|
*
|
|
* Undefined when the field of that record is empty: older records are not
|
|
* searched.
|
|
*/
|
|
function lastRecordField(text: string, field: string, type?: string): string | undefined {
|
|
const fieldMarker = `"${field}":`;
|
|
const typeMarkers = type === undefined ? undefined : [`"type":"${type}"`, `"type": "${type}"`];
|
|
const lines = text.split("\n");
|
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
const line = lines[i]!;
|
|
if (!line.includes(fieldMarker)) continue;
|
|
if (typeMarkers && !typeMarkers.some((marker) => line.includes(marker))) continue;
|
|
let value = recordField(line, field, type);
|
|
if (value === null && typeMarkers) {
|
|
const start = line.lastIndexOf(`{${typeMarkers[0]}`);
|
|
if (start > 0 && !":,[".includes(line[start - 1]!)) {
|
|
value = recordField(line.slice(start), field, type);
|
|
}
|
|
}
|
|
if (typeof value === "string") return value || undefined;
|
|
}
|
|
return 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 title of the last `custom-title` record of the tail, as the SDK reads
|
|
* it (see {@link lastRecordField}). */
|
|
export function tailCustomTitle({ tail, sdkTail }: HeadTail): string | undefined {
|
|
return lastRecordField(sdkTail ?? tail, "customTitle", "custom-title");
|
|
}
|
|
|
|
/** 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}: what the SDK `getSessionInfo` reports for this
|
|
* file, without its lookup of the file. The sidecar title counts only when
|
|
* the tail has no custom title.
|
|
*/
|
|
export function sdkTitles(
|
|
{ head, tail: wholeLines, sdkTail }: HeadTail,
|
|
sidecarTitle?: string,
|
|
): SdkTitles {
|
|
const tail = sdkTail ?? wholeLines;
|
|
const customTitle =
|
|
lastRecordField(tail, "customTitle", "custom-title") ??
|
|
sidecarTitle ??
|
|
lastRecordField(head, "customTitle", "custom-title") ??
|
|
lastRecordField(tail, "aiTitle", "ai-title") ??
|
|
lastRecordField(head, "aiTitle", "ai-title");
|
|
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. The last prompt and the summary
|
|
* are the field of a record of any type. */
|
|
export function generatedTitle({ head, tail }: HeadTail): string | undefined {
|
|
return (
|
|
lastRecordField(tail, "aiTitle", "ai-title") ??
|
|
lastRecordField(head, "aiTitle", "ai-title") ??
|
|
lastRecordField(tail, "lastPrompt") ??
|
|
lastRecordField(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();
|
|
}
|
|
}
|