@agentclientprotocol/claude-agent-acp 0.81.3-preview.1 → 0.81.3-preview.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +8 -5
  2. package/dist/acp-agent.d.ts +31 -37
  3. package/dist/acp-agent.d.ts.map +1 -1
  4. package/dist/acp-agent.js +546 -534
  5. package/dist/acp-subagents.d.ts +6 -0
  6. package/dist/acp-subagents.d.ts.map +1 -1
  7. package/dist/air-extension.d.ts +31 -1
  8. package/dist/air-extension.d.ts.map +1 -1
  9. package/dist/air-extension.js +35 -1
  10. package/dist/async-tasks.d.ts +39 -2
  11. package/dist/async-tasks.d.ts.map +1 -1
  12. package/dist/async-tasks.js +147 -44
  13. package/dist/context-compaction-meta.d.ts +6 -5
  14. package/dist/context-compaction-meta.d.ts.map +1 -1
  15. package/dist/context-compaction-meta.js +10 -10
  16. package/dist/context-compaction.d.ts +3 -0
  17. package/dist/context-compaction.d.ts.map +1 -1
  18. package/dist/context-compaction.js +23 -57
  19. package/dist/diff.d.ts +73 -5
  20. package/dist/diff.d.ts.map +1 -1
  21. package/dist/diff.js +435 -64
  22. package/dist/elicitation.d.ts +1 -1
  23. package/dist/elicitation.d.ts.map +1 -1
  24. package/dist/elicitation.js +12 -14
  25. package/dist/native-subagents.d.ts +58 -0
  26. package/dist/native-subagents.d.ts.map +1 -1
  27. package/dist/native-subagents.js +186 -34
  28. package/dist/paths.d.ts +3 -0
  29. package/dist/paths.d.ts.map +1 -0
  30. package/dist/paths.js +6 -0
  31. package/dist/permissions/presentation.d.ts +13 -2
  32. package/dist/permissions/presentation.d.ts.map +1 -1
  33. package/dist/permissions/presentation.js +49 -44
  34. package/dist/resumed-session.d.ts +11 -0
  35. package/dist/resumed-session.d.ts.map +1 -1
  36. package/dist/resumed-session.js +106 -4
  37. package/dist/session-mode.d.ts +2 -0
  38. package/dist/session-mode.d.ts.map +1 -1
  39. package/dist/session-mode.js +12 -5
  40. package/dist/settings.js +2 -2
  41. package/dist/subagent-history.d.ts +20 -0
  42. package/dist/subagent-history.d.ts.map +1 -0
  43. package/dist/subagent-history.js +46 -0
  44. package/dist/tool-calls/background.d.ts +20 -0
  45. package/dist/tool-calls/background.d.ts.map +1 -0
  46. package/dist/tool-calls/background.js +41 -0
  47. package/dist/tool-calls/changed-meta-filter.d.ts +27 -0
  48. package/dist/tool-calls/changed-meta-filter.d.ts.map +1 -0
  49. package/dist/tool-calls/changed-meta-filter.js +102 -0
  50. package/dist/tool-calls/client-capabilities.d.ts +51 -0
  51. package/dist/tool-calls/client-capabilities.d.ts.map +1 -0
  52. package/dist/tool-calls/client-capabilities.js +43 -0
  53. package/dist/tool-calls/content.d.ts +59 -0
  54. package/dist/tool-calls/content.d.ts.map +1 -0
  55. package/dist/tool-calls/content.js +194 -0
  56. package/dist/tool-calls/facts.d.ts +116 -0
  57. package/dist/tool-calls/facts.d.ts.map +1 -0
  58. package/dist/tool-calls/facts.js +1 -0
  59. package/dist/tool-calls/field-tracker.d.ts +58 -0
  60. package/dist/tool-calls/field-tracker.d.ts.map +1 -0
  61. package/dist/tool-calls/field-tracker.js +167 -0
  62. package/dist/tool-calls/renderer.d.ts +216 -0
  63. package/dist/tool-calls/renderer.d.ts.map +1 -0
  64. package/dist/tool-calls/renderer.js +487 -0
  65. package/dist/tool-calls/reporters/agent.d.ts +7 -0
  66. package/dist/tool-calls/reporters/agent.d.ts.map +1 -0
  67. package/dist/tool-calls/reporters/agent.js +190 -0
  68. package/dist/tool-calls/reporters/bash.d.ts +9 -0
  69. package/dist/tool-calls/reporters/bash.d.ts.map +1 -0
  70. package/dist/tool-calls/reporters/bash.js +83 -0
  71. package/dist/tool-calls/reporters/compaction.d.ts +24 -0
  72. package/dist/tool-calls/reporters/compaction.d.ts.map +1 -0
  73. package/dist/tool-calls/reporters/compaction.js +66 -0
  74. package/dist/tool-calls/reporters/file-edit.d.ts +46 -0
  75. package/dist/tool-calls/reporters/file-edit.d.ts.map +1 -0
  76. package/dist/tool-calls/reporters/file-edit.js +180 -0
  77. package/dist/tool-calls/reporters/index.d.ts +4 -0
  78. package/dist/tool-calls/reporters/index.d.ts.map +1 -0
  79. package/dist/tool-calls/reporters/index.js +40 -0
  80. package/dist/tool-calls/reporters/interaction.d.ts +71 -0
  81. package/dist/tool-calls/reporters/interaction.d.ts.map +1 -0
  82. package/dist/tool-calls/reporters/interaction.js +222 -0
  83. package/dist/tool-calls/reporters/read.d.ts +7 -0
  84. package/dist/tool-calls/reporters/read.d.ts.map +1 -0
  85. package/dist/tool-calls/reporters/read.js +64 -0
  86. package/dist/tool-calls/reporters/search.d.ts +10 -0
  87. package/dist/tool-calls/reporters/search.d.ts.map +1 -0
  88. package/dist/tool-calls/reporters/search.js +50 -0
  89. package/dist/tool-calls/reporters/web.d.ts +11 -0
  90. package/dist/tool-calls/reporters/web.d.ts.map +1 -0
  91. package/dist/tool-calls/reporters/web.js +42 -0
  92. package/dist/tools.d.ts +32 -37
  93. package/dist/tools.d.ts.map +1 -1
  94. package/dist/tools.js +48 -970
  95. package/package.json +4 -1
package/dist/diff.js CHANGED
@@ -1,10 +1,207 @@
1
- import { AIR_DIFF_STATS_KEY, withAirMeta } from "./air-extension.js";
1
+ import { structuredPatch } from "diff";
2
+ import { constants } from "node:fs";
3
+ import { open } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { AIR_DIFF_PATCH_CAPABILITY, withAirMeta } from "./air-extension.js";
6
+ import { normalizeWriteInput } from "./tool-calls/reporters/file-edit.js";
2
7
  /**
3
- * Builds diff ToolUpdate content from the structured toolResponse provided by
4
- * the PostToolUse hook for diff-producing tools (Edit, Write). Unlike parsing
5
- * the plain unified diff string, this uses the pre-parsed structuredPatch
6
- * which supports multiple replacement sites (replaceAll) and always includes
7
- * context lines for better readability.
8
+ * The largest file, in bytes, that the adapter turns into a git patch.
9
+ *
10
+ * A larger file, a larger new text, or an Edit whose result can be larger
11
+ * gets no patch. The tool call then keeps its standard ACP content. The limit
12
+ * bounds the file read, the replacement, and the line diff that run while
13
+ * Claude waits for an approval.
14
+ */
15
+ export const MAX_PATCH_FILE_BYTES = 1024 * 1024;
16
+ /**
17
+ * The wall-clock budget, in milliseconds, of one line diff for a patch.
18
+ *
19
+ * The diff runs synchronously, so the budget is also the longest block of the
20
+ * event loop.
21
+ */
22
+ const PATCH_DIFF_TIMEOUT_MS = 100;
23
+ /** The number of context lines around a change in a hunk. */
24
+ const PATCH_CONTEXT_LINES = 3;
25
+ /** Git reads this many leading bytes to decide that a file is binary. */
26
+ const BINARY_SNIFF_BYTES = 8000;
27
+ const NO_NEWLINE_MARKER = "\";
28
+ /**
29
+ * Builds the exact patch shown before Claude runs an Edit or Write tool.
30
+ *
31
+ * Returns undefined when the adapter cannot predict the file change exactly.
32
+ * The caller then keeps the standard tool-call content. The adapter declines
33
+ * a file that is missing for an Edit, too large, binary, or that has CR line
34
+ * endings. Claude converts the line endings of an edited file, so an in-memory
35
+ * replacement would not match the bytes that Claude writes. It also declines
36
+ * an `old_string` that does not match exactly once, because Claude then
37
+ * normalizes quotes or fails.
38
+ *
39
+ * A Write always gets content that shows the change: the patch, the standard
40
+ * diff of a text that cannot have an exact patch, or a notice that the Write
41
+ * overwrites a file that the adapter cannot show.
42
+ *
43
+ * The preview uses the tool input as it is. Claude can still remove the
44
+ * trailing whitespace of a line before it writes. The PostToolUse hook then
45
+ * sends the patch of the written file.
46
+ */
47
+ export async function previewPatchContent(toolName, input, cwd) {
48
+ if (toolName === "Edit") {
49
+ const edit = input;
50
+ if (typeof edit.file_path !== "string" ||
51
+ typeof edit.old_string !== "string" ||
52
+ typeof edit.new_string !== "string") {
53
+ return undefined;
54
+ }
55
+ const oldString = edit.old_string;
56
+ const filePath = resolveToolPath(edit.file_path, cwd);
57
+ const oldText = await readPatchSource(filePath);
58
+ if (oldText === undefined)
59
+ return undefined;
60
+ const newString = edit.new_string;
61
+ if (oldString === newString || !isPatchableText(newString))
62
+ return undefined;
63
+ if (oldString.length === 0) {
64
+ // An empty old_string creates the file, or fills an existing file that
65
+ // holds only whitespace. `trim` also removes a byte order mark, whose
66
+ // handling the adapter does not predict, so such a file is declined.
67
+ if (oldText !== null && (oldText.trim() !== "" || oldText.includes("\uFEFF"))) {
68
+ return undefined;
69
+ }
70
+ return optionalContent(filePatch(filePath, oldText, newString)?.content);
71
+ }
72
+ if (oldText === null)
73
+ return undefined;
74
+ const occurrences = oldText.split(oldString).length - 1;
75
+ if (occurrences === 0 || (edit.replace_all !== true && occurrences !== 1))
76
+ return undefined;
77
+ if (replacedTextBytesBound(oldText, oldString, newString, occurrences) > MAX_PATCH_FILE_BYTES) {
78
+ return undefined;
79
+ }
80
+ const newText = replacedText(oldText, oldString, newString, edit.replace_all === true);
81
+ return optionalContent(filePatch(filePath, oldText, newText)?.content);
82
+ }
83
+ if (toolName === "Write") {
84
+ const write = normalizeWriteInput(input);
85
+ if (typeof write?.file_path !== "string" || typeof write.content !== "string") {
86
+ return undefined;
87
+ }
88
+ // The Write tool call shows no diff, so the approval is the only place that
89
+ // shows what the Write changes.
90
+ const content = write.content;
91
+ const filePath = resolveToolPath(write.file_path, cwd);
92
+ const oldText = await readPatchSource(filePath);
93
+ if (oldText === undefined) {
94
+ return [
95
+ {
96
+ type: "content",
97
+ content: {
98
+ type: "text",
99
+ text: `Overwrites the existing file \`${write.file_path}\`. The adapter cannot show its current content.`,
100
+ },
101
+ },
102
+ ];
103
+ }
104
+ if (oldText === content)
105
+ return undefined;
106
+ const standard = [
107
+ { type: "diff", path: write.file_path, oldText, newText: content },
108
+ ];
109
+ if (!isPatchableText(content))
110
+ return standard;
111
+ // A dense change can exceed the diff budget. The approval then shows the standard diff.
112
+ return optionalContent(filePatch(filePath, oldText, content)?.content) ?? standard;
113
+ }
114
+ return undefined;
115
+ }
116
+ /**
117
+ * An upper bound of the UTF-8 size, in bytes, of {@link replacedText}.
118
+ *
119
+ * The caller checks the bound before it builds the text. A `replace_all` of
120
+ * many short matches with a long `newString` can otherwise build a text of
121
+ * gigabytes before the timed line diff starts.
122
+ */
123
+ function replacedTextBytesBound(fileText, oldString, newString, occurrences) {
124
+ const growth = Buffer.byteLength(newString, "utf8") - Buffer.byteLength(oldString, "utf8");
125
+ return Buffer.byteLength(fileText, "utf8") + occurrences * Math.max(0, growth);
126
+ }
127
+ /**
128
+ * The file text after Claude replaces `oldString` with `newString`.
129
+ *
130
+ * An empty `newString` deletes a line: when `oldString` does not end with a
131
+ * line break and the file holds `oldString` and a line break, Claude also
132
+ * removes that line break. With `replaceAll`, it then replaces only the
133
+ * occurrences that a line break follows.
134
+ */
135
+ function replacedText(fileText, oldString, newString, replaceAll) {
136
+ const target = newString === "" && !oldString.endsWith("\n") && fileText.includes(`${oldString}\n`)
137
+ ? `${oldString}\n`
138
+ : oldString;
139
+ return replaceAll
140
+ ? fileText.split(target).join(newString)
141
+ : fileText.replace(target, () => newString);
142
+ }
143
+ /**
144
+ * Builds the git patch for a finished Edit or Write from the file on disk.
145
+ *
146
+ * The Claude SDK `structuredPatch` is display data: Claude converts leading
147
+ * tabs to spaces and CRLF line endings to LF before it computes the hunks. A
148
+ * patch built from those hunks does not match the file. This function diffs
149
+ * `originalFile` against the written file instead. It returns undefined when
150
+ * it cannot build an exact patch, and the caller then sends the standard diff.
151
+ * A written file that contains a CR or a byte order mark is declined, because
152
+ * Claude removed those bytes from `originalFile`.
153
+ */
154
+ export async function patchUpdateFromDiffToolResponse(toolResponse) {
155
+ if (!toolResponse || typeof toolResponse !== "object")
156
+ return undefined;
157
+ const response = toolResponse;
158
+ if (typeof response.filePath !== "string")
159
+ return undefined;
160
+ // An Edit with an empty old_string reports "" as the original of a created
161
+ // file and of an existing empty file. The response does not tell the two
162
+ // apart, so the caller keeps the standard diff.
163
+ if (response.type === undefined && response.oldString === "" && response.originalFile === "") {
164
+ return undefined;
165
+ }
166
+ // A Write whose content is the previous text changed nothing. The file on
167
+ // disk can hold a later change, and that change is not the change of this Write.
168
+ if (response.type === "update" &&
169
+ typeof response.content === "string" &&
170
+ response.originalFile === response.content) {
171
+ return undefined;
172
+ }
173
+ const oldText = response.type === "create"
174
+ ? null
175
+ : typeof response.originalFile === "string"
176
+ ? response.originalFile
177
+ : undefined;
178
+ if (oldText === undefined)
179
+ return undefined;
180
+ // The same size limit as the new side: a larger text gets the standard diff.
181
+ if (oldText !== null && Buffer.byteLength(oldText, "utf8") > MAX_PATCH_FILE_BYTES)
182
+ return undefined;
183
+ const newText = await readPatchSource(response.filePath);
184
+ if (typeof newText !== "string" || newText.startsWith("\uFEFF"))
185
+ return undefined;
186
+ if (response.type === "create" && newText !== response.content)
187
+ return undefined;
188
+ const patch = filePatch(response.filePath, oldText, newText);
189
+ if (!patch)
190
+ return undefined;
191
+ return {
192
+ content: [patch.content],
193
+ // A created file keeps the location of its Write tool call.
194
+ locations: oldText === null
195
+ ? [{ path: response.filePath }]
196
+ : patch.hunks.map(({ newStart }) => ({ path: response.filePath, line: newStart })),
197
+ };
198
+ }
199
+ /**
200
+ * Builds standard ACP diff content from the structured toolResponse provided
201
+ * by the PostToolUse hook for diff-producing tools (Edit, Write). Unlike
202
+ * parsing the plain unified diff string, this uses the pre-parsed
203
+ * structuredPatch which supports multiple replacement sites (replaceAll) and
204
+ * always includes context lines for better readability.
8
205
  */
9
206
  export function toolUpdateFromDiffToolResponse(toolResponse) {
10
207
  if (!toolResponse || typeof toolResponse !== "object")
@@ -14,67 +211,20 @@ export function toolUpdateFromDiffToolResponse(toolResponse) {
14
211
  return {};
15
212
  const content = [];
16
213
  const locations = [];
17
- let previousOldEnd = 0;
18
- let previousNewEnd = 0;
19
- let accumulatedDelta = 0;
20
- let coordinatesRemainConsistent = true;
21
- for (const { lines, oldStart, newStart, oldLines, newLines } of response.structuredPatch) {
214
+ for (const { lines, newStart } of response.structuredPatch) {
22
215
  const oldText = [];
23
216
  const newText = [];
24
- let added = 0;
25
- let removed = 0;
26
- let validPrefixes = true;
27
- let validEofMarkers = true;
28
- const oldPosition = oldStart + (oldLines === 0 ? 1 : 0);
29
- const newPosition = newStart + (newLines === 0 ? 1 : 0);
30
- const oldEnd = oldPosition + oldLines;
31
- const newEnd = newPosition + newLines;
32
- const nextDelta = accumulatedDelta + newLines - oldLines;
33
- const validCoordinates = coordinatesRemainConsistent &&
34
- Number.isSafeInteger(oldStart) &&
35
- Number.isSafeInteger(newStart) &&
36
- Number.isSafeInteger(oldLines) &&
37
- Number.isSafeInteger(newLines) &&
38
- oldStart >= (oldLines === 0 ? 0 : 1) &&
39
- newStart >= (newLines === 0 ? 0 : 1) &&
40
- oldLines >= 0 &&
41
- newLines >= 0 &&
42
- Number.isSafeInteger(oldPosition) &&
43
- Number.isSafeInteger(newPosition) &&
44
- Number.isSafeInteger(oldEnd) &&
45
- Number.isSafeInteger(newEnd) &&
46
- Number.isSafeInteger(nextDelta) &&
47
- oldPosition >= previousOldEnd &&
48
- newPosition >= previousNewEnd &&
49
- newPosition - oldPosition === accumulatedDelta;
50
- coordinatesRemainConsistent = validCoordinates;
51
- if (validCoordinates) {
52
- previousOldEnd = oldEnd;
53
- previousNewEnd = newEnd;
54
- accumulatedDelta = nextDelta;
55
- }
56
- for (const [index, line] of lines.entries()) {
217
+ for (const line of lines) {
57
218
  if (line.startsWith("-")) {
58
219
  oldText.push(line.slice(1));
59
- removed++;
60
220
  }
61
221
  else if (line.startsWith("+")) {
62
222
  newText.push(line.slice(1));
63
- added++;
64
223
  }
65
- else if (line === "\") {
66
- const previousLine = lines[index - 1];
67
- if (typeof previousLine !== "string" ||
68
- (!previousLine.startsWith("-") &&
69
- !previousLine.startsWith("+") &&
70
- !previousLine.startsWith(" "))) {
71
- validEofMarkers = false;
72
- }
224
+ else if (line === NO_NEWLINE_MARKER) {
73
225
  continue;
74
226
  }
75
227
  else {
76
- if (!line.startsWith(" "))
77
- validPrefixes = false;
78
228
  oldText.push(line.slice(1));
79
229
  newText.push(line.slice(1));
80
230
  }
@@ -86,13 +236,6 @@ export function toolUpdateFromDiffToolResponse(toolResponse) {
86
236
  path: response.filePath,
87
237
  oldText: oldText.join("\n") || null,
88
238
  newText: newText.join("\n"),
89
- ...(validCoordinates &&
90
- validPrefixes &&
91
- validEofMarkers &&
92
- oldText.length === oldLines &&
93
- newText.length === newLines
94
- ? { _meta: withAirMeta(undefined, AIR_DIFF_STATS_KEY, { version: 1, added, removed }) }
95
- : {}),
96
239
  });
97
240
  }
98
241
  }
@@ -104,7 +247,9 @@ export function toolUpdateFromDiffToolResponse(toolResponse) {
104
247
  // of a large existing file would render as creating it. Emit a truthful
105
248
  // replacement instead. Gated on `type` so Edit (whose output carries no
106
249
  // `type` and whose optimistic old/new diff is already truthful) keeps the
107
- // empty-return behavior.
250
+ // empty-return behavior. A `create` needs nothing here: the
251
+ // tool_use-time diff shows the created file, and a client with diffPatch
252
+ // gets it from the Write reporter.
108
253
  if (content.length === 0 && response.type === "update" && typeof response.content === "string") {
109
254
  locations.push({ path: response.filePath });
110
255
  content.push(typeof response.originalFile === "string"
@@ -129,3 +274,229 @@ export function toolUpdateFromDiffToolResponse(toolResponse) {
129
274
  result.locations = locations;
130
275
  return result;
131
276
  }
277
+ /**
278
+ * The text of one git patch for `filePath`.
279
+ *
280
+ * The headers follow `git diff`: the path loses its leading slash, gets the
281
+ * `a/` and `b/` prefixes, and is quoted when git would quote it. A created
282
+ * file gets its mode line and a `/dev/null` side.
283
+ */
284
+ export function gitPatchText(filePath, change, hunks) {
285
+ return [
286
+ ...gitPatchHeader(filePath, change),
287
+ ...hunks.flatMap((hunk) => [hunkHeader(hunk), ...hunk.lines]),
288
+ "",
289
+ ].join("\n");
290
+ }
291
+ function gitPatchHeader(filePath, change) {
292
+ // git drops one leading slash of an absolute path; a Windows path gets
293
+ // forward slashes so the header names the same file on every platform.
294
+ const name = filePath.replaceAll("\\", "/").replace(/^\/+/u, "");
295
+ const oldName = quoteGitPath("a/", name);
296
+ const newName = quoteGitPath("b/", name);
297
+ // git ends a ---/+++ name that contains a space with a tab, for GNU patch.
298
+ const tab = name.includes(" ") ? "\t" : "";
299
+ return [
300
+ `diff --git ${oldName} ${newName}`,
301
+ ...(change === "create" ? ["new file mode 100644"] : []),
302
+ `--- ${change === "create" ? "/dev/null" : `${oldName}${tab}`}`,
303
+ `+++ ${newName}${tab}`,
304
+ ];
305
+ }
306
+ const GIT_PATH_ESCAPES = {
307
+ 0x07: "a",
308
+ 0x08: "b",
309
+ 0x09: "t",
310
+ 0x0a: "n",
311
+ 0x0b: "v",
312
+ 0x0c: "f",
313
+ 0x0d: "r",
314
+ 0x22: '"',
315
+ 0x5c: "\\",
316
+ };
317
+ /**
318
+ * Quotes `prefix + name` like git with the default `core.quotePath`.
319
+ *
320
+ * A double quote, a backslash, a control byte, or a non-ASCII byte makes git
321
+ * put the whole name in double quotes. git then writes a C escape or a
322
+ * three-digit octal escape for each such UTF-8 byte.
323
+ */
324
+ function quoteGitPath(prefix, name) {
325
+ const full = `${prefix}${name}`;
326
+ let quoted = "";
327
+ let needsQuotes = false;
328
+ for (const byte of Buffer.from(full, "utf8")) {
329
+ const escape = GIT_PATH_ESCAPES[byte];
330
+ if (escape !== undefined) {
331
+ quoted += `\\${escape}`;
332
+ needsQuotes = true;
333
+ }
334
+ else if (byte < 0x20 || byte >= 0x7f) {
335
+ quoted += `\\${byte.toString(8).padStart(3, "0")}`;
336
+ needsQuotes = true;
337
+ }
338
+ else {
339
+ quoted += String.fromCharCode(byte);
340
+ }
341
+ }
342
+ return needsQuotes ? `"${quoted}"` : full;
343
+ }
344
+ function hunkHeader({ oldStart, oldLines, newStart, newLines }) {
345
+ return `@@ -${hunkRange(oldStart, oldLines)} +${hunkRange(newStart, newLines)} @@`;
346
+ }
347
+ /** A git hunk range. A side with zero lines names the line before the change. */
348
+ function hunkRange(start, count) {
349
+ if (count === 0)
350
+ return `${Math.max(0, start - 1)},0`;
351
+ return count === 1 ? String(start) : `${start},${count}`;
352
+ }
353
+ /** The hunk that adds every line of `text` to an empty file. */
354
+ function wholeFileHunk(text) {
355
+ if (text.length === 0)
356
+ return undefined;
357
+ const terminated = text.endsWith("\n");
358
+ const lines = (terminated ? text.slice(0, -1) : text).split("\n");
359
+ return {
360
+ oldStart: 1,
361
+ oldLines: 0,
362
+ newStart: 1,
363
+ newLines: lines.length,
364
+ lines: [...lines.map((line) => `+${line}`), ...(terminated ? [] : [NO_NEWLINE_MARKER])],
365
+ };
366
+ }
367
+ /**
368
+ * The line-diff hunks between two texts. Returns undefined when the diff runs
369
+ * out of its time budget.
370
+ */
371
+ function diffHunks(oldText, newText) {
372
+ if (oldText === null) {
373
+ const hunk = wholeFileHunk(newText);
374
+ return hunk ? [hunk] : [];
375
+ }
376
+ const oldLines = textLines(oldText);
377
+ const newLines = textLines(newText);
378
+ // The diff gets only the changed middle and its context lines. An edit
379
+ // usually changes a small part of a large file.
380
+ const common = Math.min(oldLines.length, newLines.length);
381
+ let prefix = 0;
382
+ while (prefix < common && oldLines[prefix] === newLines[prefix])
383
+ prefix++;
384
+ let suffix = 0;
385
+ while (suffix < common - prefix &&
386
+ oldLines[oldLines.length - 1 - suffix] === newLines[newLines.length - 1 - suffix]) {
387
+ suffix++;
388
+ }
389
+ const skipped = Math.max(0, prefix - PATCH_CONTEXT_LINES);
390
+ const skippedEnd = Math.max(0, suffix - PATCH_CONTEXT_LINES);
391
+ 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 });
392
+ return patch?.hunks.map((hunk) => ({
393
+ ...hunk,
394
+ oldStart: hunk.oldStart + skipped,
395
+ newStart: hunk.newStart + skipped,
396
+ }));
397
+ }
398
+ /** The lines of a text, each with its line break. The last line can have none. */
399
+ function textLines(text) {
400
+ return text.match(/[^\n]*\n|[^\n]+$/g) ?? [];
401
+ }
402
+ /**
403
+ * The git patch of a file change and its hunks. Returns undefined when
404
+ * nothing changed or the diff runs out of its time budget.
405
+ */
406
+ function filePatch(filePath, oldText, newText) {
407
+ const hunks = diffHunks(oldText, newText);
408
+ if (!hunks || hunks.length === 0)
409
+ return undefined;
410
+ const change = oldText === null ? "create" : "update";
411
+ return { content: patchContent(filePath, gitPatchText(filePath, change, hunks)), hunks };
412
+ }
413
+ /**
414
+ * Reads a file as patch input.
415
+ *
416
+ * Returns null when the file does not exist. Returns undefined when the file
417
+ * cannot be read, is not a regular file, is larger than
418
+ * {@link MAX_PATCH_FILE_BYTES}, is binary, is not valid UTF-8, or contains a
419
+ * CR.
420
+ */
421
+ async function readPatchSource(filePath) {
422
+ let handle;
423
+ try {
424
+ // O_NONBLOCK keeps the open of a FIFO from waiting for a writer. It does not change a regular file.
425
+ handle = await open(filePath, constants.O_RDONLY | constants.O_NONBLOCK);
426
+ }
427
+ catch (error) {
428
+ return isMissingFileError(error) ? null : undefined;
429
+ }
430
+ try {
431
+ // The checks and the read use one open file, so a swap of the path between them has no effect.
432
+ const stats = await handle.stat();
433
+ if (!stats.isFile() || stats.size > MAX_PATCH_FILE_BYTES)
434
+ return undefined;
435
+ // One byte more than the checked size shows a file that grew after the check.
436
+ const buffer = Buffer.alloc(stats.size + 1);
437
+ let length = 0;
438
+ while (length < buffer.length) {
439
+ const { bytesRead } = await handle.read(buffer, length, buffer.length - length, length);
440
+ if (bytesRead === 0)
441
+ break;
442
+ length += bytesRead;
443
+ }
444
+ if (length > stats.size)
445
+ return undefined;
446
+ const text = decodeFileText(buffer.subarray(0, length));
447
+ return text?.includes("\r") ? undefined : text;
448
+ }
449
+ catch {
450
+ return undefined;
451
+ }
452
+ finally {
453
+ await handle.close();
454
+ }
455
+ }
456
+ /** The text of a file of at most {@link MAX_PATCH_FILE_BYTES} that is not binary and is valid UTF-8. */
457
+ function decodeFileText(bytes) {
458
+ if (bytes.length > MAX_PATCH_FILE_BYTES)
459
+ return undefined;
460
+ if (bytes.subarray(0, BINARY_SNIFF_BYTES).includes(0))
461
+ return undefined;
462
+ try {
463
+ return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
464
+ }
465
+ catch {
466
+ return undefined;
467
+ }
468
+ }
469
+ /** Whether a tool input text can be the new side of an exact patch. */
470
+ function isPatchableText(text) {
471
+ return (Buffer.byteLength(text, "utf8") <= MAX_PATCH_FILE_BYTES &&
472
+ !text.includes("\0") &&
473
+ !text.includes("\r"));
474
+ }
475
+ function optionalContent(content) {
476
+ return content ? [content] : undefined;
477
+ }
478
+ /**
479
+ * A diff block in the AIR patch form.
480
+ *
481
+ * `oldText: null` and `newText: ""` only satisfy the ACP schema. The adapter
482
+ * sends this form only to a client that advertised `diffPatch`.
483
+ */
484
+ function patchContent(filePath, text) {
485
+ return {
486
+ type: "diff",
487
+ path: filePath,
488
+ oldText: null,
489
+ newText: "",
490
+ _meta: withAirMeta(undefined, AIR_DIFF_PATCH_CAPABILITY, {
491
+ version: 1,
492
+ format: "git_patch",
493
+ text,
494
+ }),
495
+ };
496
+ }
497
+ function resolveToolPath(filePath, cwd) {
498
+ return path.isAbsolute(filePath) ? filePath : path.resolve(cwd ?? process.cwd(), filePath);
499
+ }
500
+ function isMissingFileError(error) {
501
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
502
+ }
@@ -63,7 +63,7 @@ export declare function extractAskUserQuestions(input: Record<string, unknown>):
63
63
  * marked required, so the user can also just skip — matching the built-in tool,
64
64
  * which always offers Skip + a free-text box.
65
65
  */
66
- export declare function askUserQuestionsToCreateRequest(questions: AskUserQuestion[], sessionId: string, toolCallId: string | undefined): CreateElicitationRequest;
66
+ export declare function askUserQuestionsToCreateRequest(questions: AskUserQuestion[], sessionId: string, toolCallId: string | undefined, airClient?: boolean): CreateElicitationRequest;
67
67
  /** Outcome of an AskUserQuestion elicitation, decoupled from any transport. */
68
68
  export type AskUserQuestionOutcome = {
69
69
  action: "answered";
@@ -1 +1 @@
1
- {"version":3,"file":"elicitation.d.ts","sourceRoot":"","sources":["../src/elicitation.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,yBAAyB,EAAE,MAAM,0BAA0B,CAAC;AACrE,OAAO,KAAK,EACV,wBAAwB,EAKzB,MAAM,0BAA0B,CAAC;AAClC,OAAO,KAAK,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,MAAM,gCAAgC,CAAC;AAC5F,OAAO,KAAK,EACV,oBAAoB,EAErB,MAAM,6CAA6C,CAAC;AAErD;;;;;;;;;;;;;GAaG;AAEH,yDAAyD;AACzD,MAAM,MAAM,kBAAkB,GAAG;IAC/B,IAAI,EAAE,OAAO,CAAC;IACd,GAAG,EAAE,OAAO,CAAC;CACd,CAAC;AAEF;;;;GAIG;AACH,wBAAgB,6BAA6B,CAC3C,OAAO,EAAE,kBAAkB,EAC3B,SAAS,EAAE,MAAM,GAChB,wBAAwB,GAAG,IAAI,CAyBjC;AAgBD;;;GAGG;AACH,wBAAgB,uCAAuC,CACrD,QAAQ,EAAE,yBAAyB,GAClC,iBAAiB,CAUnB;AAED;;;;GAIG;AACH,MAAM,MAAM,eAAe,GAAG,oBAAoB,CAAC,WAAW,CAAC,CAAC,MAAM,CAAC,CAAC;AAExE;;;;;GAKG;AACH,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,eAAe,EAAE,GAAG,IAAI,CAUhG;AAgCD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,+BAA+B,CAC7C,SAAS,EAAE,eAAe,EAAE,EAC5B,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,GAAG,SAAS,GAC7B,wBAAwB,CA6D1B;AAED,+EAA+E;AAC/E,MAAM,MAAM,sBAAsB,GAChC;IAAE,MAAM,EAAE,UAAU,CAAC;IAAC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,GAAG;IAAE,MAAM,EAAE,QAAQ,CAAA;CAAE,CAAC;AAevF;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,2BAA2B,CACzC,QAAQ,EAAE,yBAAyB,EACnC,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAClC,SAAS,EAAE,eAAe,EAAE,GAC3B,sBAAsB,CAgExB;AAgBD;;;;;;;GAOG;AACH,eAAO,MAAM,4BAA4B,4BAA4B,CAAC;AAEtE;;;;;;;GAOG;AACH,MAAM,MAAM,qBAAqB,GAAG;IAClC,aAAa,EAAE,MAAM,CAAC;IACtB,aAAa,EAAE,MAAM,CAAC;IACtB,kBAAkB,EAAE,MAAM,GAAG,IAAI,CAAC;IAClC,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB,CAAC;AAEF;;;;;GAKG;AACH,wBAAgB,4BAA4B,CAC1C,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC/B,qBAAqB,GAAG,IAAI,CAW9B;AAYD;;;;;GAKG;AACH,wBAAgB,8BAA8B,CAC5C,MAAM,EAAE,qBAAqB,EAC7B,SAAS,EAAE,MAAM,GAChB,wBAAwB,CA+B1B;AAED;;;;;;GAMG;AACH,wBAAgB,iCAAiC,CAAC,QAAQ,EAAE,yBAAyB,GAAG,MAAM,CAG7F"}
1
+ {"version":3,"file":"elicitation.d.ts","sourceRoot":"","sources":["../src/elicitation.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,yBAAyB,EAAE,MAAM,0BAA0B,CAAC;AACrE,OAAO,KAAK,EACV,wBAAwB,EAKzB,MAAM,0BAA0B,CAAC;AAClC,OAAO,KAAK,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,MAAM,gCAAgC,CAAC;AAC5F,OAAO,KAAK,EACV,oBAAoB,EAErB,MAAM,6CAA6C,CAAC;AAGrD;;;;;;;;;;;;;GAaG;AAEH,yDAAyD;AACzD,MAAM,MAAM,kBAAkB,GAAG;IAC/B,IAAI,EAAE,OAAO,CAAC;IACd,GAAG,EAAE,OAAO,CAAC;CACd,CAAC;AAEF;;;;GAIG;AACH,wBAAgB,6BAA6B,CAC3C,OAAO,EAAE,kBAAkB,EAC3B,SAAS,EAAE,MAAM,GAChB,wBAAwB,GAAG,IAAI,CAyBjC;AAgBD;;;GAGG;AACH,wBAAgB,uCAAuC,CACrD,QAAQ,EAAE,yBAAyB,GAClC,iBAAiB,CAUnB;AAED;;;;GAIG;AACH,MAAM,MAAM,eAAe,GAAG,oBAAoB,CAAC,WAAW,CAAC,CAAC,MAAM,CAAC,CAAC;AAExE;;;;;GAKG;AACH,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,eAAe,EAAE,GAAG,IAAI,CAUhG;AAwBD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,+BAA+B,CAC7C,SAAS,EAAE,eAAe,EAAE,EAC5B,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,GAAG,SAAS,EAC9B,SAAS,UAAQ,GAChB,wBAAwB,CAiE1B;AAED,+EAA+E;AAC/E,MAAM,MAAM,sBAAsB,GAChC;IAAE,MAAM,EAAE,UAAU,CAAC;IAAC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,GAAG;IAAE,MAAM,EAAE,QAAQ,CAAA;CAAE,CAAC;AAevF;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,2BAA2B,CACzC,QAAQ,EAAE,yBAAyB,EACnC,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAClC,SAAS,EAAE,eAAe,EAAE,GAC3B,sBAAsB,CAgExB;AAgBD;;;;;;;GAOG;AACH,eAAO,MAAM,4BAA4B,4BAA4B,CAAC;AAEtE;;;;;;;GAOG;AACH,MAAM,MAAM,qBAAqB,GAAG;IAClC,aAAa,EAAE,MAAM,CAAC;IACtB,aAAa,EAAE,MAAM,CAAC;IACtB,kBAAkB,EAAE,MAAM,GAAG,IAAI,CAAC;IAClC,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB,CAAC;AAEF;;;;;GAKG;AACH,wBAAgB,4BAA4B,CAC1C,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC/B,qBAAqB,GAAG,IAAI,CAW9B;AAYD;;;;;GAKG;AACH,wBAAgB,8BAA8B,CAC5C,MAAM,EAAE,qBAAqB,EAC7B,SAAS,EAAE,MAAM,GAChB,wBAAwB,CA+B1B;AAED;;;;;;GAMG;AACH,wBAAgB,iCAAiC,CAAC,QAAQ,EAAE,yBAAyB,GAAG,MAAM,CAG7F"}
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { CreateElicitationResponse } from "@agentclientprotocol/sdk";
3
+ import { AIR_CUSTOM_ANSWER_KEY, withAirMeta } from "./air-extension.js";
3
4
  /**
4
5
  * Convert an MCP elicitation request (from the SDK's `onElicitation` callback)
5
6
  * into an ACP `CreateElicitationRequest`. Returns `null` when the request can't
@@ -89,13 +90,6 @@ function questionCustomFieldKey(index) {
89
90
  * extensions (`_claude/...`).
90
91
  */
91
92
  const OPTION_META_KEY = "_claude/askUserQuestionOption";
92
- /**
93
- * Shared `_meta` key for marking a per-question free-text field as the custom
94
- * answer companion for a select question. This intentionally has no
95
- * agent-specific namespace so ACP clients can recognize the same marker across
96
- * Codex, Claude, and other AskUserQuestion bridges.
97
- */
98
- const CUSTOM_ANSWER_META_KEY = "_askUserQuestionCustomAnswer";
99
93
  /**
100
94
  * Render the AskUserQuestion tool's questions as an ACP form elicitation.
101
95
  *
@@ -114,7 +108,7 @@ const CUSTOM_ANSWER_META_KEY = "_askUserQuestionCustomAnswer";
114
108
  * marked required, so the user can also just skip — matching the built-in tool,
115
109
  * which always offers Skip + a free-text box.
116
110
  */
117
- export function askUserQuestionsToCreateRequest(questions, sessionId, toolCallId) {
111
+ export function askUserQuestionsToCreateRequest(questions, sessionId, toolCallId, airClient = false) {
118
112
  const single = questions.length === 1;
119
113
  const properties = {};
120
114
  questions.forEach((question, index) => {
@@ -148,12 +142,16 @@ export function askUserQuestionsToCreateRequest(questions, sessionId, toolCallId
148
142
  description: question.multiSelect
149
143
  ? "Type your own answer to add to your selection above (optional)."
150
144
  : "Type your own answer, or add a note to the option you chose above (optional).",
151
- _meta: {
152
- [CUSTOM_ANSWER_META_KEY]: {
153
- questionId: questionFieldKey(index),
154
- isCustomAnswer: true,
155
- },
156
- },
145
+ // Marks the field as the custom answer companion of a select question,
146
+ // under `_meta.jetbrains.air.customAnswer`. Only AIR gets the marker.
147
+ ...(airClient
148
+ ? {
149
+ _meta: withAirMeta(undefined, AIR_CUSTOM_ANSWER_KEY, {
150
+ questionId: questionFieldKey(index),
151
+ isCustomAnswer: true,
152
+ }),
153
+ }
154
+ : {}),
157
155
  };
158
156
  });
159
157
  const requestedSchema = {