Files
claude-agent-acp/src/diff.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

749 lines
27 KiB
TypeScript

import { ToolCallContent, ToolCallLocation } from "@agentclientprotocol/sdk";
import type * as v2 from "@agentclientprotocol/sdk/experimental/v2";
import { structuredPatch } from "diff";
import { constants } from "node:fs";
import { type FileHandle, open } from "node:fs/promises";
import path from "node:path";
import { AIR_DIFF_PATCH_CAPABILITY, withAirMeta } from "./air-extension.js";
import { normalizeWriteInput } from "./tool-calls/reporters/file-edit.js";
/**
* The largest file, in bytes, that the adapter turns into a git patch.
*
* A larger file, a larger new text, or an Edit whose result can be larger
* gets no patch. The tool call then keeps its standard ACP content. The limit
* bounds the file read, the replacement, and the line diff that run while
* Claude waits for an approval.
*/
export const MAX_PATCH_FILE_BYTES = 1024 * 1024;
/**
* The wall-clock budget, in milliseconds, of one line diff for a patch.
*
* The diff runs synchronously, so the budget is also the longest block of the
* event loop.
*/
const PATCH_DIFF_TIMEOUT_MS = 100;
/** The number of context lines around a change in a hunk. */
const PATCH_CONTEXT_LINES = 3;
/** Git reads this many leading bytes to decide that a file is binary. */
const BINARY_SNIFF_BYTES = 8000;
const NO_NEWLINE_MARKER = "\\ No newline at end of file";
/**
* One unified-diff hunk in the `diff` package convention.
*
* A side with zero lines stores the number of the line after the change as its
* start. {@link hunkHeader} converts that start to the git convention.
*/
interface PatchHunk {
oldStart: number;
oldLines: number;
newStart: number;
newLines: number;
lines: string[];
}
interface DiffToolResponse {
filePath?: string;
structuredPatch?: PatchHunk[];
/** FileWriteOutput only (FileEditOutput carries no `type`): whether the
* write created the file or overwrote an existing one. */
type?: "create" | "update";
/** FileWriteOutput only: the content that was written. */
content?: string;
/** The pre-change content. It is null on a Write create, or on a Write
* update whose previous content was too large to include. An Edit that
* creates a file reports an empty string, as for an existing empty file. */
originalFile?: string | null;
/** FileEditOutput only: the text that the Edit replaced. It is empty when
* the Edit created the file or filled an existing empty file. */
oldString?: string;
}
interface EditPreviewInput {
file_path?: unknown;
old_string?: unknown;
new_string?: unknown;
replace_all?: unknown;
}
/** The kind of file change that a git patch describes. */
type FileChange = "create" | "update";
/** A file change and the hunks of its git patch. */
interface FilePatch {
change: FileChange;
hunks: PatchHunk[];
}
/**
* The diff that carries an exact patch: AIR's `diffPatch` extension of a v1
* diff, or an ACP v2 diff.
*/
export type PatchForm = "air" | "v2";
/**
* How a git patch names its file. `git` is git's own form: the leading slash
* dropped and the `a/` and `b/` prefixes added. `absolute` is the form of an
* ACP v2 `git_patch`, whose paths must be absolute: the path itself.
*/
type GitPatchPaths = "git" | "absolute";
/** An ACP v2 diff, as tool call content. */
type V2DiffContent = v2.Diff & { type: "diff" };
/**
* Builds the exact patch shown before Claude runs an Edit or Write tool.
*
* Returns undefined when the adapter cannot predict the file change exactly.
* The caller then keeps the standard tool-call content. The adapter declines
* a file that is missing for an Edit, too large, binary, or that has CR line
* endings. Claude converts the line endings of an edited file, so an in-memory
* replacement would not match the bytes that Claude writes. It also declines
* an `old_string` that does not match exactly once, because Claude then
* normalizes quotes or fails.
*
* A Write always gets content that shows the change: the patch, the standard
* diff (in v2, the change alone) of a text that cannot have an exact patch, or
* a notice that the Write overwrites a file that the adapter cannot show.
*
* The preview uses the tool input as it is. Claude can still remove the
* trailing whitespace of a line before it writes. The PostToolUse hook then
* sends the patch of the written file.
*/
export async function previewPatchContent(
toolName: string,
input: Record<string, unknown>,
cwd?: string,
form: PatchForm = "air",
): Promise<ToolCallContent[] | undefined> {
if (toolName === "Edit") {
const edit = input as EditPreviewInput;
if (
typeof edit.file_path !== "string" ||
typeof edit.old_string !== "string" ||
typeof edit.new_string !== "string"
) {
return undefined;
}
const oldString = edit.old_string;
const filePath = resolveToolPath(edit.file_path, cwd);
const oldText = await readPatchSource(filePath);
if (oldText === undefined) return undefined;
const newString = edit.new_string;
if (oldString === newString || !isPatchableText(newString)) return undefined;
if (oldString.length === 0) {
// An empty old_string creates the file, or fills an existing file that
// holds only whitespace. `trim` also removes a byte order mark, whose
// handling the adapter does not predict, so such a file is declined.
if (oldText !== null && (oldText.trim() !== "" || oldText.includes("\uFEFF"))) {
return undefined;
}
return exactPatch(filePath, oldText, newString, form);
}
if (oldText === null) return undefined;
const occurrences = oldText.split(oldString).length - 1;
if (occurrences === 0 || (edit.replace_all !== true && occurrences !== 1)) return undefined;
if (replacedTextBytesBound(oldText, oldString, newString, occurrences) > MAX_PATCH_FILE_BYTES) {
return undefined;
}
const newText = replacedText(oldText, oldString, newString, edit.replace_all === true);
return exactPatch(filePath, oldText, newText, form);
}
if (toolName === "Write") {
const write = normalizeWriteInput(input);
if (typeof write?.file_path !== "string" || typeof write.content !== "string") {
return undefined;
}
// The Write tool call shows no diff, so the approval is the only place that
// shows what the Write changes.
const content = write.content;
const filePath = resolveToolPath(write.file_path, cwd);
const oldText = await readPatchSource(filePath);
if (oldText === undefined) {
return [
{
type: "content",
content: {
type: "text",
text: `Overwrites the existing file \`${write.file_path}\`. The adapter cannot show its current content.`,
},
},
];
}
if (oldText === content) return undefined;
const standard: ToolCallContent[] = [
form === "v2"
? v2DiffContent(filePath, oldText === null ? "create" : "update")
: { type: "diff", path: write.file_path, oldText, newText: content },
];
if (!isPatchableText(content)) return standard;
// A dense change can exceed the diff budget. The approval then shows the standard diff.
return exactPatch(filePath, oldText, content, form) ?? standard;
}
return undefined;
}
/**
* An upper bound of the UTF-8 size, in bytes, of {@link replacedText}.
*
* The caller checks the bound before it builds the text. A `replace_all` of
* many short matches with a long `newString` can otherwise build a text of
* gigabytes before the timed line diff starts.
*/
function replacedTextBytesBound(
fileText: string,
oldString: string,
newString: string,
occurrences: number,
): number {
const growth = Buffer.byteLength(newString, "utf8") - Buffer.byteLength(oldString, "utf8");
return Buffer.byteLength(fileText, "utf8") + occurrences * Math.max(0, growth);
}
/**
* The file text after Claude replaces `oldString` with `newString`.
*
* An empty `newString` deletes a line: when `oldString` does not end with a
* line break and the file holds `oldString` and a line break, Claude also
* removes that line break. With `replaceAll`, it then replaces only the
* occurrences that a line break follows.
*/
function replacedText(
fileText: string,
oldString: string,
newString: string,
replaceAll: boolean,
): string {
const target =
newString === "" && !oldString.endsWith("\n") && fileText.includes(`${oldString}\n`)
? `${oldString}\n`
: oldString;
return replaceAll
? fileText.split(target).join(newString)
: fileText.replace(target, () => newString);
}
/**
* Builds the git patch for a finished Edit or Write from the file on disk.
*
* The Claude SDK `structuredPatch` is display data: Claude converts leading
* tabs to spaces and CRLF line endings to LF before it computes the hunks. A
* patch built from those hunks does not match the file. This function diffs
* `originalFile` against the written file instead. It returns undefined when
* it cannot build an exact patch, and the caller then sends the standard diff.
* A written file that contains a CR or a byte order mark is declined, because
* Claude removed those bytes from `originalFile`.
*/
export async function patchUpdateFromDiffToolResponse(
toolResponse: unknown,
form: PatchForm = "air",
): Promise<{ content: ToolCallContent[]; locations: ToolCallLocation[] } | undefined> {
if (!toolResponse || typeof toolResponse !== "object") return undefined;
const response = toolResponse as DiffToolResponse;
if (typeof response.filePath !== "string") return undefined;
// An Edit with an empty old_string reports "" as the original of a created
// file and of an existing empty file. The response does not tell the two
// apart, so the caller keeps the standard diff.
if (response.type === undefined && response.oldString === "" && response.originalFile === "") {
return undefined;
}
// A Write whose content is the previous text changed nothing. The file on
// disk can hold a later change, and that change is not the change of this Write.
if (
response.type === "update" &&
typeof response.content === "string" &&
response.originalFile === response.content
) {
return undefined;
}
const oldText =
response.type === "create"
? null
: typeof response.originalFile === "string"
? response.originalFile
: undefined;
if (oldText === undefined) return undefined;
// The same size limit as the new side: a larger text gets the standard diff.
if (oldText !== null && Buffer.byteLength(oldText, "utf8") > MAX_PATCH_FILE_BYTES)
return undefined;
const newText = await readPatchSource(response.filePath);
if (typeof newText !== "string" || newText.startsWith("\uFEFF")) return undefined;
if (response.type === "create" && newText !== response.content) return undefined;
const patch = filePatch(oldText, newText);
if (!patch) return undefined;
return {
content: [patchContent(response.filePath, patch, form)],
// A created file keeps the location of its Write tool call.
locations:
oldText === null
? [{ path: response.filePath }]
: patch.hunks.map(({ newStart }) => ({ path: response.filePath!, line: newStart })),
};
}
/**
* Builds standard ACP diff content from the structured toolResponse provided
* by the PostToolUse hook for diff-producing tools (Edit, Write). Unlike
* parsing the plain unified diff string, this uses the pre-parsed
* structuredPatch which supports multiple replacement sites (replaceAll) and
* always includes context lines for better readability.
*/
export function toolUpdateFromDiffToolResponse(toolResponse: unknown): {
content?: ToolCallContent[];
locations?: ToolCallLocation[];
} {
if (!toolResponse || typeof toolResponse !== "object") return {};
const response = toolResponse as DiffToolResponse;
if (!response.filePath || !Array.isArray(response.structuredPatch)) return {};
const content: ToolCallContent[] = [];
const locations: ToolCallLocation[] = [];
for (const { lines, newStart } of response.structuredPatch) {
const oldText: string[] = [];
const newText: string[] = [];
for (const line of lines) {
if (line.startsWith("-")) {
oldText.push(line.slice(1));
} else if (line.startsWith("+")) {
newText.push(line.slice(1));
} else if (line === NO_NEWLINE_MARKER) {
continue;
} else {
oldText.push(line.slice(1));
newText.push(line.slice(1));
}
}
if (oldText.length > 0 || newText.length > 0) {
locations.push({ path: response.filePath, line: newStart });
content.push({
type: "diff",
path: response.filePath,
oldText: oldText.join("\n") || null,
newText: newText.join("\n"),
});
}
}
// A Write `update` can arrive with an empty structuredPatch — nothing
// changed, the diff timed out, or the previous content was too large to
// diff (originalFile null; SDK 0.3.252 documents the lane). Returning `{}`
// would leave Write's optimistic tool_use-time content standing, and that
// was built with `oldText: null` — "creation" semantics — so an overwrite
// of a large existing file would render as creating it. Emit a truthful
// replacement instead. Gated on `type` so Edit (whose output carries no
// `type` and whose optimistic old/new diff is already truthful) keeps the
// empty-return behavior. A `create` needs nothing here: the
// tool_use-time diff shows the created file, and a client with diffPatch
// gets it from the Write reporter.
if (content.length === 0 && response.type === "update" && typeof response.content === "string") {
locations.push({ path: response.filePath });
content.push(
typeof response.originalFile === "string"
? {
type: "diff",
path: response.filePath,
oldText: response.originalFile,
newText: response.content,
}
: {
type: "content",
content: {
type: "text",
text: `Updated \`${response.filePath}\` (previous content too large to diff)`,
},
},
);
}
const result: { content?: ToolCallContent[]; locations?: ToolCallLocation[] } = {};
if (content.length > 0) result.content = content;
if (locations.length > 0) result.locations = locations;
return result;
}
/**
* The ACP v2 diff of a finished Edit or Write: the exact patch of
* {@link patchUpdateFromDiffToolResponse} when it can be built, else the
* change with a patch from what the toolResponse holds.
*
* The fallback patch of an update comes from the `structuredPatch`, which is
* display data (see {@link patchUpdateFromDiffToolResponse}). That is enough
* for a v2 patch, which is renderable text, not a patch to apply. An update
* without hunks, such as one whose previous content was too large to diff,
* gets the change alone. An Edit with an empty `old_string` reports the same
* response for a created file and a filled empty one, so it gets no diff.
*/
export async function v2UpdateFromDiffToolResponse(toolResponse: unknown): Promise<{
content?: ToolCallContent[];
locations?: ToolCallLocation[];
}> {
const exact = await patchUpdateFromDiffToolResponse(toolResponse, "v2");
if (exact) return exact;
if (!toolResponse || typeof toolResponse !== "object") return {};
const response = toolResponse as DiffToolResponse;
const filePath = response.filePath;
if (typeof filePath !== "string") return {};
if (response.type === "create") {
const hunk =
typeof response.content === "string" && isPatchableText(response.content)
? wholeFileHunk(response.content)
: undefined;
return {
content: [v2DiffContent(filePath, "create", hunk && [hunk])],
locations: [{ path: filePath }],
};
}
if (response.type === undefined && response.oldString === "" && response.originalFile === "") {
return {};
}
// A Write whose content is the previous text changed nothing.
if (response.type === "update" && response.content === response.originalFile) return {};
const hunks =
Array.isArray(response.structuredPatch) && response.structuredPatch.length > 0
? response.structuredPatch
: undefined;
return {
content: [v2DiffContent(filePath, "update", hunks)],
locations: hunks
? hunks.map(({ newStart }) => ({ path: filePath, line: newStart }))
: [{ path: filePath }],
};
}
/**
* The text of one git patch for `filePath`.
*
* The headers follow `git diff`: the path is quoted when git would quote it,
* and a created file gets its mode line and a `/dev/null` side. With `git`
* paths, the path loses its leading slash and gets the `a/` and `b/` prefixes.
*/
export function gitPatchText(
filePath: string,
change: FileChange,
hunks: PatchHunk[],
paths: GitPatchPaths = "git",
): string {
return [
...gitPatchHeader(filePath, change, paths),
...hunks.flatMap((hunk) => [hunkHeader(hunk), ...hunk.lines]),
"",
].join("\n");
}
function gitPatchHeader(filePath: string, change: FileChange, paths: GitPatchPaths): string[] {
// A Windows path gets forward slashes so the header names the same file on
// every platform.
const slashed = filePath.replaceAll("\\", "/");
// git drops one leading slash of an absolute path.
const name = paths === "git" ? slashed.replace(/^\/+/u, "") : slashed;
const oldName = quoteGitPath(paths === "git" ? "a/" : "", name);
const newName = quoteGitPath(paths === "git" ? "b/" : "", name);
// git ends a ---/+++ name that contains a space with a tab, for GNU patch.
const tab = name.includes(" ") ? "\t" : "";
return [
`diff --git ${oldName} ${newName}`,
...(change === "create" ? ["new file mode 100644"] : []),
`--- ${change === "create" ? "/dev/null" : `${oldName}${tab}`}`,
`+++ ${newName}${tab}`,
];
}
const GIT_PATH_ESCAPES: Record<number, string> = {
0x07: "a",
0x08: "b",
0x09: "t",
0x0a: "n",
0x0b: "v",
0x0c: "f",
0x0d: "r",
0x22: '"',
0x5c: "\\",
};
/**
* Quotes `prefix + name` like git with the default `core.quotePath`.
*
* A double quote, a backslash, a control byte, or a non-ASCII byte makes git
* put the whole name in double quotes. git then writes a C escape or a
* three-digit octal escape for each such UTF-8 byte.
*/
function quoteGitPath(prefix: string, name: string): string {
const full = `${prefix}${name}`;
let quoted = "";
let needsQuotes = false;
for (const byte of Buffer.from(full, "utf8")) {
const escape = GIT_PATH_ESCAPES[byte];
if (escape !== undefined) {
quoted += `\\${escape}`;
needsQuotes = true;
} else if (byte < 0x20 || byte >= 0x7f) {
quoted += `\\${byte.toString(8).padStart(3, "0")}`;
needsQuotes = true;
} else {
quoted += String.fromCharCode(byte);
}
}
return needsQuotes ? `"${quoted}"` : full;
}
function hunkHeader({ oldStart, oldLines, newStart, newLines }: PatchHunk): string {
return `@@ -${hunkRange(oldStart, oldLines)} +${hunkRange(newStart, newLines)} @@`;
}
/** A git hunk range. A side with zero lines names the line before the change. */
function hunkRange(start: number, count: number): string {
if (count === 0) return `${Math.max(0, start - 1)},0`;
return count === 1 ? String(start) : `${start},${count}`;
}
/** The hunk that adds every line of `text` to an empty file. */
function wholeFileHunk(text: string): PatchHunk | undefined {
if (text.length === 0) return undefined;
const terminated = text.endsWith("\n");
const lines = (terminated ? text.slice(0, -1) : text).split("\n");
return {
oldStart: 1,
oldLines: 0,
newStart: 1,
newLines: lines.length,
lines: [...lines.map((line) => `+${line}`), ...(terminated ? [] : [NO_NEWLINE_MARKER])],
};
}
/**
* The line-diff hunks between two texts. Returns undefined when the diff runs
* out of its time budget.
*/
function diffHunks(oldText: string | null, newText: string): PatchHunk[] | undefined {
if (oldText === null) {
const hunk = wholeFileHunk(newText);
return hunk ? [hunk] : [];
}
const oldLines = textLines(oldText);
const newLines = textLines(newText);
// The diff gets only the changed middle and its context lines. An edit
// usually changes a small part of a large file.
const common = Math.min(oldLines.length, newLines.length);
let prefix = 0;
while (prefix < common && oldLines[prefix] === newLines[prefix]) prefix++;
let suffix = 0;
while (
suffix < common - prefix &&
oldLines[oldLines.length - 1 - suffix] === newLines[newLines.length - 1 - suffix]
) {
suffix++;
}
const skipped = Math.max(0, prefix - PATCH_CONTEXT_LINES);
const skippedEnd = Math.max(0, suffix - PATCH_CONTEXT_LINES);
const patch = structuredPatch(
"",
"",
oldLines.slice(skipped, oldLines.length - skippedEnd).join(""),
newLines.slice(skipped, newLines.length - skippedEnd).join(""),
"",
"",
{ context: PATCH_CONTEXT_LINES, timeout: PATCH_DIFF_TIMEOUT_MS },
);
return patch?.hunks.map((hunk) => ({
...hunk,
oldStart: hunk.oldStart + skipped,
newStart: hunk.newStart + skipped,
}));
}
/** The lines of a text, each with its line break. The last line can have none. */
function textLines(text: string): string[] {
return text.match(/[^\n]*\n|[^\n]+$/g) ?? [];
}
/**
* The patch of a change from `oldText`, null for a missing file, to
* `newText`. Returns undefined when nothing changed or the diff runs out of
* its time budget.
*/
function filePatch(oldText: string | null, newText: string): FilePatch | undefined {
const hunks = memoizedDiffHunks(oldText, newText);
if (!hunks || hunks.length === 0) return undefined;
return { change: oldText === null ? "create" : "update", hunks };
}
/** The number of recent line diffs that {@link memoizedDiffHunks} keeps. */
const DIFF_MEMO_SIZE = 4;
/** Recent line diffs not read again yet, the most recent last. A text of at
* most {@link MAX_PATCH_FILE_BYTES} is held until it is read again or later
* diffs push it out. */
const diffMemo: { oldText: string | null; newText: string; hunks: PatchHunk[] }[] = [];
/**
* {@link diffHunks} of a recent pair of texts again, without a second diff.
*
* The approval of an Edit or Write diffs the file against the predicted text,
* and the PostToolUse hook diffs the same texts once Claude wrote them. On a
* large file each diff can block the event loop for its whole time budget.
* Only a diff that completed is remembered: one that ran out of its budget
* says nothing about the texts, so a later call diffs them again. The hunks
* are shared, and no caller changes them.
*/
function memoizedDiffHunks(oldText: string | null, newText: string): PatchHunk[] | undefined {
const index = diffMemo.findIndex(
(entry) => entry.oldText === oldText && entry.newText === newText,
);
if (index >= 0) {
// The hook is normally the last reader of a pair, so the entry goes: the
// memo does not hold two large texts longer than needed.
const [entry] = diffMemo.splice(index, 1);
return entry.hunks;
}
const hunks = diffHunks(oldText, newText);
if (hunks) {
diffMemo.push({ oldText, newText, hunks });
if (diffMemo.length > DIFF_MEMO_SIZE) diffMemo.shift();
}
return hunks;
}
/** Forget the remembered line diffs. For tests. */
export function clearDiffMemo(): void {
diffMemo.length = 0;
}
/**
* Reads a file as patch input.
*
* Returns null when the file does not exist. Returns undefined when the file
* cannot be read, is not a regular file, is larger than
* {@link MAX_PATCH_FILE_BYTES}, is binary, is not valid UTF-8, or contains a
* CR.
*/
async function readPatchSource(filePath: string): Promise<string | null | undefined> {
let handle: FileHandle;
try {
// O_NONBLOCK keeps the open of a FIFO from waiting for a writer. It does not change a regular file.
handle = await open(filePath, constants.O_RDONLY | constants.O_NONBLOCK);
} catch (error) {
return isMissingFileError(error) ? null : undefined;
}
try {
// The checks and the read use one open file, so a swap of the path between them has no effect.
const stats = await handle.stat();
if (!stats.isFile() || stats.size > MAX_PATCH_FILE_BYTES) return undefined;
// One byte more than the checked size shows a file that grew after the check.
const buffer = Buffer.alloc(stats.size + 1);
let length = 0;
while (length < buffer.length) {
const { bytesRead } = await handle.read(buffer, length, buffer.length - length, length);
if (bytesRead === 0) break;
length += bytesRead;
}
if (length > stats.size) return undefined;
const text = decodeFileText(buffer.subarray(0, length));
return text?.includes("\r") ? undefined : text;
} catch {
return undefined;
} finally {
await handle.close();
}
}
/** The text of a file of at most {@link MAX_PATCH_FILE_BYTES} that is not binary and is valid UTF-8. */
function decodeFileText(bytes: Uint8Array): string | undefined {
if (bytes.length > MAX_PATCH_FILE_BYTES) return undefined;
if (bytes.subarray(0, BINARY_SNIFF_BYTES).includes(0)) return undefined;
try {
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
} catch {
return undefined;
}
}
/** Whether a tool input text can be the new side of an exact patch. */
function isPatchableText(text: string): boolean {
return (
Buffer.byteLength(text, "utf8") <= MAX_PATCH_FILE_BYTES &&
!text.includes("\0") &&
!text.includes("\r")
);
}
/** The exact patch content of a change, or undefined when it has no patch. */
function exactPatch(
filePath: string,
oldText: string | null,
newText: string,
form: PatchForm,
): ToolCallContent[] | undefined {
const patch = filePatch(oldText, newText);
return patch && [patchContent(filePath, patch, form)];
}
/** The content that carries a patch, in the given form. */
function patchContent(filePath: string, patch: FilePatch, form: PatchForm): ToolCallContent {
return form === "v2"
? v2DiffContent(filePath, patch.change, patch.hunks)
: airPatchContent(filePath, patch);
}
/**
* A diff block in the AIR patch form.
*
* `oldText: null` and `newText: ""` only satisfy the ACP schema. The adapter
* sends this form only to a client that advertised `diffPatch`.
*/
function airPatchContent(filePath: string, { change, hunks }: FilePatch): ToolCallContent {
return {
type: "diff",
path: filePath,
oldText: null,
newText: "",
_meta: withAirMeta(undefined, AIR_DIFF_PATCH_CAPABILITY, {
version: 1,
format: "git_patch",
text: gitPatchText(filePath, change, hunks),
}),
};
}
/**
* The ACP v2 diff of a change to the text file `filePath`: the change and,
* given hunks, its git patch.
*
* The agent builds tool call content in v1 types, and v1 cannot express a v2
* diff, so the diff travels as v1 content. Nothing in the agent reads inside a
* diff, and only a v2 client gets one: the v2 surface sends it as it is
* (`src/v2/tool-call.ts`).
*/
export function v2DiffContent(
filePath: string,
change: FileChange,
hunks?: PatchHunk[],
): ToolCallContent {
const fileChange: v2.DiffChange =
change === "create"
? { operation: "add", path: filePath, fileType: "text" }
: { operation: "modify", path: filePath, fileType: "text" };
const diff: V2DiffContent = {
type: "diff",
changes: [fileChange],
...(hunks?.length
? { patch: { format: "git_patch", text: gitPatchText(filePath, change, hunks, "absolute") } }
: {}),
};
return diff as unknown as ToolCallContent;
}
function resolveToolPath(filePath: string, cwd?: string): string {
return path.isAbsolute(filePath) ? filePath : path.resolve(cwd ?? process.cwd(), filePath);
}
function isMissingFileError(error: unknown): boolean {
return error instanceof Error && "code" in error && error.code === "ENOENT";
}