pi-openai-codex-compat 0.0.1-alpha.0

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 (38) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/LICENSE +20 -0
  3. package/LICENSES/Apache-2.0.txt +201 -0
  4. package/LICENSES/pi-ai-MIT.txt +21 -0
  5. package/README.md +331 -0
  6. package/THIRD_PARTY_NOTICES.md +21 -0
  7. package/extensions/openai-codex-compat/apply-patch-diff-render.ts +436 -0
  8. package/extensions/openai-codex-compat/apply-patch-engine.ts +1004 -0
  9. package/extensions/openai-codex-compat/apply-patch-render.ts +133 -0
  10. package/extensions/openai-codex-compat/apply-patch.ts +142 -0
  11. package/extensions/openai-codex-compat/codex-protocol.ts +598 -0
  12. package/extensions/openai-codex-compat/codex-provider.ts +740 -0
  13. package/extensions/openai-codex-compat/codex-stream.ts +444 -0
  14. package/extensions/openai-codex-compat/codex-tool-surface.ts +186 -0
  15. package/extensions/openai-codex-compat/codex-transport.ts +855 -0
  16. package/extensions/openai-codex-compat/compaction-checkpoint.ts +304 -0
  17. package/extensions/openai-codex-compat/config.ts +268 -0
  18. package/extensions/openai-codex-compat/footer.ts +99 -0
  19. package/extensions/openai-codex-compat/image-generation-render.ts +166 -0
  20. package/extensions/openai-codex-compat/image-generation.ts +355 -0
  21. package/extensions/openai-codex-compat/index.ts +65 -0
  22. package/extensions/openai-codex-compat/model-policy.ts +67 -0
  23. package/extensions/openai-codex-compat/namespaced-tools.ts +43 -0
  24. package/extensions/openai-codex-compat/native-history.ts +78 -0
  25. package/extensions/openai-codex-compat/remote-compaction.ts +198 -0
  26. package/extensions/openai-codex-compat/request-options.ts +121 -0
  27. package/extensions/openai-codex-compat/responses-replay.ts +33 -0
  28. package/extensions/openai-codex-compat/settings-pane.ts +298 -0
  29. package/extensions/openai-codex-compat/tool-runtime.ts +32 -0
  30. package/extensions/openai-codex-compat/tools.ts +70 -0
  31. package/extensions/openai-codex-compat/vendor/pi-ai/README.md +15 -0
  32. package/extensions/openai-codex-compat/vendor/pi-ai/openai-responses-serialization.ts +660 -0
  33. package/extensions/openai-codex-compat/web-run-description.txt +105 -0
  34. package/extensions/openai-codex-compat/web-run-output.ts +172 -0
  35. package/extensions/openai-codex-compat/web-run-render.ts +681 -0
  36. package/extensions/openai-codex-compat/web-run-schema.ts +301 -0
  37. package/extensions/openai-codex-compat/web-run.ts +164 -0
  38. package/package.json +63 -0
@@ -0,0 +1,1004 @@
1
+ import { lstat, mkdir, readFile, stat, unlink, writeFile } from "node:fs/promises";
2
+ import { dirname, isAbsolute, resolve } from "node:path";
3
+ import { generateDiffString, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
4
+
5
+ const BEGIN_PATCH = "*** Begin Patch";
6
+ const END_PATCH = "*** End Patch";
7
+ const ADD_FILE = "*** Add File: ";
8
+ const DELETE_FILE = "*** Delete File: ";
9
+ const UPDATE_FILE = "*** Update File: ";
10
+ const MOVE_TO = "*** Move to: ";
11
+ const END_OF_FILE = "*** End of File";
12
+ const CHANGE_CONTEXT = "@@ ";
13
+ const EMPTY_CHANGE_CONTEXT = "@@";
14
+ const ENVIRONMENT_ID = "*** Environment ID:";
15
+
16
+ function isRustWhitespace(codePoint: number): boolean {
17
+ return (
18
+ (codePoint >= 0x0009 && codePoint <= 0x000d) ||
19
+ codePoint === 0x0020 ||
20
+ codePoint === 0x0085 ||
21
+ codePoint === 0x00a0 ||
22
+ codePoint === 0x1680 ||
23
+ (codePoint >= 0x2000 && codePoint <= 0x200a) ||
24
+ codePoint === 0x2028 ||
25
+ codePoint === 0x2029 ||
26
+ codePoint === 0x202f ||
27
+ codePoint === 0x205f ||
28
+ codePoint === 0x3000
29
+ );
30
+ }
31
+
32
+ function rustTrimStart(value: string): string {
33
+ let index = 0;
34
+ while (index < value.length && isRustWhitespace(value.charCodeAt(index))) index += 1;
35
+ return value.slice(index);
36
+ }
37
+
38
+ function rustTrimEnd(value: string): string {
39
+ let index = value.length;
40
+ while (index > 0 && isRustWhitespace(value.charCodeAt(index - 1))) index -= 1;
41
+ return value.slice(0, index);
42
+ }
43
+
44
+ function rustTrim(value: string): string {
45
+ return rustTrimEnd(rustTrimStart(value));
46
+ }
47
+
48
+ type ParserMode =
49
+ | { kind: "not-started" }
50
+ | { kind: "started" }
51
+ | { kind: "add" }
52
+ | { kind: "delete" }
53
+ | { kind: "update"; hunkLineNumber: number }
54
+ | { kind: "ended" };
55
+
56
+ export type UpdateChunk = {
57
+ context?: string;
58
+ oldLines: string[];
59
+ newLines: string[];
60
+ endOfFile: boolean;
61
+ };
62
+
63
+ export type PatchOperation =
64
+ | { kind: "add"; path: string; content: string }
65
+ | { kind: "delete"; path: string }
66
+ | { kind: "update"; path: string; moveTo?: string; chunks: UpdateChunk[] };
67
+
68
+ export type ParsedPatch = {
69
+ patch: string;
70
+ operations: PatchOperation[];
71
+ environmentId?: string;
72
+ };
73
+
74
+ export type AppliedPatchChange =
75
+ | {
76
+ kind: "add";
77
+ path: string;
78
+ content: string;
79
+ overwrittenContent?: string;
80
+ displayDiff: string;
81
+ additions: number;
82
+ deletions: number;
83
+ }
84
+ | {
85
+ kind: "delete";
86
+ path: string;
87
+ content: string;
88
+ displayDiff: string;
89
+ additions: number;
90
+ deletions: number;
91
+ }
92
+ | {
93
+ kind: "update";
94
+ path: string;
95
+ moveTo?: string;
96
+ oldContent: string;
97
+ newContent: string;
98
+ overwrittenMoveContent?: string;
99
+ displayDiff: string;
100
+ additions: number;
101
+ deletions: number;
102
+ };
103
+
104
+ export type ApplyPatchDetails = {
105
+ status: "completed" | "failed";
106
+ exact: boolean;
107
+ changes: AppliedPatchChange[];
108
+ added: string[];
109
+ modified: string[];
110
+ deleted: string[];
111
+ error?: string;
112
+ };
113
+
114
+ type ResolvedOperation =
115
+ | { kind: "add"; path: string; absolutePath: string; content: string }
116
+ | { kind: "delete"; path: string; absolutePath: string }
117
+ | {
118
+ kind: "update";
119
+ path: string;
120
+ absolutePath: string;
121
+ moveTo?: string;
122
+ moveAbsolutePath?: string;
123
+ chunks: UpdateChunk[];
124
+ };
125
+
126
+ export class ApplyPatchParseError extends Error {
127
+ readonly kind: "patch" | "hunk";
128
+ readonly lineNumber: number | undefined;
129
+
130
+ constructor(kind: "patch" | "hunk", message: string, lineNumber?: number) {
131
+ super(
132
+ kind === "patch"
133
+ ? `invalid patch: ${message}`
134
+ : `invalid hunk at line ${lineNumber}, ${message}`,
135
+ );
136
+ this.kind = kind;
137
+ this.lineNumber = lineNumber;
138
+ }
139
+ }
140
+
141
+ export class ApplyPatchInputError extends Error {}
142
+
143
+ export class ApplyPatchVerificationError extends Error {}
144
+
145
+ export class ApplyPatchExecutionError extends Error {
146
+ readonly details: ApplyPatchDetails;
147
+
148
+ constructor(message: string, details: ApplyPatchDetails) {
149
+ super(message);
150
+ this.details = details;
151
+ }
152
+ }
153
+
154
+ class PatchParser {
155
+ private mode: ParserMode = { kind: "not-started" };
156
+ private readonly operations: PatchOperation[] = [];
157
+ private environmentId?: string;
158
+ private lineNumber = 0;
159
+
160
+ parse(lines: readonly string[], patch: string): ParsedPatch {
161
+ for (const [index, line] of lines.entries()) {
162
+ this.lineNumber += 1;
163
+ if (index === lines.length - 1 && rustTrim(line) === END_PATCH) {
164
+ this.ensureUpdateHunkIsNotEmpty(rustTrim(line));
165
+ this.mode = { kind: "ended" };
166
+ } else {
167
+ this.processLine(line);
168
+ }
169
+ }
170
+ if (this.mode.kind !== "ended") {
171
+ throw new ApplyPatchParseError("patch", `The last line of the patch must be '${END_PATCH}'`);
172
+ }
173
+ return {
174
+ patch,
175
+ operations: this.operations,
176
+ ...(this.environmentId ? { environmentId: this.environmentId } : {}),
177
+ };
178
+ }
179
+
180
+ private lastUpdate(): Extract<PatchOperation, { kind: "update" }> | undefined {
181
+ const operation = this.operations.at(-1);
182
+ return operation?.kind === "update" ? operation : undefined;
183
+ }
184
+
185
+ private ensureUpdateHunkIsNotEmpty(line: string): void {
186
+ const operation = this.lastUpdate();
187
+ if (!operation || this.mode.kind !== "update") return;
188
+ if (operation.chunks.length === 0) {
189
+ throw new ApplyPatchParseError(
190
+ "hunk",
191
+ `Update file hunk for path '${operation.path}' is empty`,
192
+ this.mode.hunkLineNumber,
193
+ );
194
+ }
195
+ const chunk = operation.chunks.at(-1);
196
+ if (!chunk || chunk.oldLines.length !== 0 || chunk.newLines.length !== 0) return;
197
+ if (line === END_PATCH) {
198
+ throw new ApplyPatchParseError(
199
+ "hunk",
200
+ "Update hunk does not contain any lines",
201
+ this.lineNumber,
202
+ );
203
+ }
204
+ throw this.unexpectedUpdateLine(line);
205
+ }
206
+
207
+ private invalidHunkHeader(line: string): ApplyPatchParseError {
208
+ return new ApplyPatchParseError(
209
+ "hunk",
210
+ `'${line}' is not a valid hunk header. Valid hunk headers: '${ADD_FILE}{path}', '${DELETE_FILE}{path}', '${UPDATE_FILE}{path}'`,
211
+ this.lineNumber,
212
+ );
213
+ }
214
+
215
+ private unexpectedUpdateLine(line: string): ApplyPatchParseError {
216
+ return new ApplyPatchParseError(
217
+ "hunk",
218
+ `Unexpected line found in update hunk: '${line}'. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)`,
219
+ this.lineNumber,
220
+ );
221
+ }
222
+
223
+ private handleHeadersAndEnd(line: string): boolean {
224
+ if (this.mode.kind === "started" && line.startsWith(ENVIRONMENT_ID)) {
225
+ if (this.environmentId) {
226
+ throw new ApplyPatchParseError(
227
+ "patch",
228
+ "apply_patch environment_id cannot be specified more than once",
229
+ );
230
+ }
231
+ const environmentId = rustTrim(line.slice(ENVIRONMENT_ID.length));
232
+ if (!environmentId) {
233
+ throw new ApplyPatchParseError("patch", "apply_patch environment_id cannot be empty");
234
+ }
235
+ this.environmentId = environmentId;
236
+ return true;
237
+ }
238
+ if (line === END_PATCH) {
239
+ this.ensureUpdateHunkIsNotEmpty(line);
240
+ this.mode = { kind: "ended" };
241
+ return true;
242
+ }
243
+ if (line.startsWith(ADD_FILE)) {
244
+ this.ensureUpdateHunkIsNotEmpty(line);
245
+ this.operations.push({ kind: "add", path: line.slice(ADD_FILE.length), content: "" });
246
+ this.mode = { kind: "add" };
247
+ return true;
248
+ }
249
+ if (line.startsWith(DELETE_FILE)) {
250
+ this.ensureUpdateHunkIsNotEmpty(line);
251
+ this.operations.push({ kind: "delete", path: line.slice(DELETE_FILE.length) });
252
+ this.mode = { kind: "delete" };
253
+ return true;
254
+ }
255
+ if (line.startsWith(UPDATE_FILE)) {
256
+ this.ensureUpdateHunkIsNotEmpty(line);
257
+ this.operations.push({
258
+ kind: "update",
259
+ path: line.slice(UPDATE_FILE.length),
260
+ chunks: [],
261
+ });
262
+ this.mode = { kind: "update", hunkLineNumber: this.lineNumber };
263
+ return true;
264
+ }
265
+ return false;
266
+ }
267
+
268
+ private ensureUpdateChunk(operation: Extract<PatchOperation, { kind: "update" }>): UpdateChunk {
269
+ let chunk = operation.chunks.at(-1);
270
+ if (!chunk) {
271
+ chunk = { oldLines: [], newLines: [], endOfFile: false };
272
+ operation.chunks.push(chunk);
273
+ }
274
+ return chunk;
275
+ }
276
+
277
+ private processLine(line: string): void {
278
+ const trimmed = rustTrim(line);
279
+ switch (this.mode.kind) {
280
+ case "not-started":
281
+ if (trimmed === BEGIN_PATCH) {
282
+ this.mode = { kind: "started" };
283
+ return;
284
+ }
285
+ throw new ApplyPatchParseError(
286
+ "patch",
287
+ `The first line of the patch must be '${BEGIN_PATCH}'`,
288
+ );
289
+
290
+ case "started":
291
+ if (this.handleHeadersAndEnd(trimmed)) return;
292
+ throw this.invalidHunkHeader(trimmed);
293
+
294
+ case "add": {
295
+ if (this.handleHeadersAndEnd(trimmed)) return;
296
+ const operation = this.operations.at(-1);
297
+ if (operation?.kind === "add" && line.startsWith("+")) {
298
+ operation.content += `${line.slice(1)}\n`;
299
+ return;
300
+ }
301
+ throw this.invalidHunkHeader(trimmed);
302
+ }
303
+
304
+ case "delete":
305
+ if (this.handleHeadersAndEnd(trimmed)) return;
306
+ throw this.invalidHunkHeader(trimmed);
307
+
308
+ case "update": {
309
+ const updateLine = rustTrimEnd(line);
310
+ if (this.handleHeadersAndEnd(updateLine)) return;
311
+ const operation = this.lastUpdate();
312
+ if (!operation) throw this.unexpectedUpdateLine(line);
313
+
314
+ const lastChunk = operation.chunks.at(-1);
315
+ if (lastChunk?.endOfFile) {
316
+ if (!updateLine) return;
317
+ if (updateLine !== EMPTY_CHANGE_CONTEXT && !updateLine.startsWith(CHANGE_CONTEXT)) {
318
+ throw new ApplyPatchParseError(
319
+ "hunk",
320
+ `Expected update hunk to start with a @@ context marker, got: '${line}'`,
321
+ this.lineNumber,
322
+ );
323
+ }
324
+ }
325
+
326
+ if (operation.chunks.length === 0 && !operation.moveTo && updateLine.startsWith(MOVE_TO)) {
327
+ operation.moveTo = updateLine.slice(MOVE_TO.length);
328
+ return;
329
+ }
330
+
331
+ if (
332
+ (updateLine === EMPTY_CHANGE_CONTEXT || updateLine.startsWith(CHANGE_CONTEXT)) &&
333
+ lastChunk &&
334
+ lastChunk.oldLines.length === 0 &&
335
+ lastChunk.newLines.length === 0
336
+ ) {
337
+ throw this.unexpectedUpdateLine(line);
338
+ }
339
+
340
+ if (updateLine === EMPTY_CHANGE_CONTEXT) {
341
+ operation.chunks.push({ oldLines: [], newLines: [], endOfFile: false });
342
+ return;
343
+ }
344
+ if (updateLine.startsWith(CHANGE_CONTEXT)) {
345
+ operation.chunks.push({
346
+ context: updateLine.slice(CHANGE_CONTEXT.length),
347
+ oldLines: [],
348
+ newLines: [],
349
+ endOfFile: false,
350
+ });
351
+ return;
352
+ }
353
+ if (updateLine === END_OF_FILE) {
354
+ const chunk = operation.chunks.at(-1);
355
+ if (chunk && chunk.oldLines.length === 0 && chunk.newLines.length === 0) {
356
+ throw new ApplyPatchParseError(
357
+ "hunk",
358
+ "Update hunk does not contain any lines",
359
+ this.lineNumber,
360
+ );
361
+ }
362
+ if (chunk) chunk.endOfFile = true;
363
+ return;
364
+ }
365
+
366
+ if (line === "") {
367
+ const chunk = this.ensureUpdateChunk(operation);
368
+ chunk.oldLines.push("");
369
+ chunk.newLines.push("");
370
+ return;
371
+ }
372
+ if (line.startsWith(" ")) {
373
+ const chunk = this.ensureUpdateChunk(operation);
374
+ chunk.oldLines.push(line.slice(1));
375
+ chunk.newLines.push(line.slice(1));
376
+ return;
377
+ }
378
+ if (line.startsWith("+")) {
379
+ this.ensureUpdateChunk(operation).newLines.push(line.slice(1));
380
+ return;
381
+ }
382
+ if (line.startsWith("-")) {
383
+ this.ensureUpdateChunk(operation).oldLines.push(line.slice(1));
384
+ return;
385
+ }
386
+
387
+ const currentChunk = operation.chunks.at(-1);
388
+ if (
389
+ currentChunk &&
390
+ (currentChunk.oldLines.length > 0 || currentChunk.newLines.length > 0)
391
+ ) {
392
+ throw new ApplyPatchParseError(
393
+ "hunk",
394
+ `Expected update hunk to start with a @@ context marker, got: '${line}'`,
395
+ this.lineNumber,
396
+ );
397
+ }
398
+ throw this.unexpectedUpdateLine(line);
399
+ }
400
+
401
+ case "ended":
402
+ if (!trimmed) return;
403
+ throw new ApplyPatchParseError(
404
+ "patch",
405
+ `The last line of the patch must be '${END_PATCH}'`,
406
+ );
407
+ }
408
+ }
409
+ }
410
+
411
+ function normalizedLines(patch: string): string[] {
412
+ const normalized = rustTrim(patch);
413
+ if (!normalized) return [];
414
+ return normalized.split("\n").map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line));
415
+ }
416
+
417
+ function checkBoundaries(lines: readonly string[]): void {
418
+ const first = lines[0] === undefined ? undefined : rustTrim(lines[0]);
419
+ const lastLine = lines.at(-1);
420
+ const last = lastLine === undefined ? undefined : rustTrim(lastLine);
421
+ if (first !== undefined && first !== BEGIN_PATCH) {
422
+ throw new ApplyPatchParseError("patch", `The first line of the patch must be '${BEGIN_PATCH}'`);
423
+ }
424
+ if (last !== END_PATCH) {
425
+ throw new ApplyPatchParseError("patch", `The last line of the patch must be '${END_PATCH}'`);
426
+ }
427
+ }
428
+
429
+ export function parsePatchDocument(patch: string): ParsedPatch {
430
+ const originalLines = normalizedLines(patch);
431
+ let lines = originalLines;
432
+ try {
433
+ checkBoundaries(lines);
434
+ } catch (originalError) {
435
+ const first = originalLines[0];
436
+ const last = originalLines.at(-1);
437
+ const heredocStart = first === "<<EOF" || first === "<<'EOF'" || first === '<<"EOF"';
438
+ if (!heredocStart || !last?.endsWith("EOF") || originalLines.length < 4) {
439
+ throw originalError;
440
+ }
441
+ lines = originalLines.slice(1, -1);
442
+ checkBoundaries(lines);
443
+ }
444
+
445
+ const normalizedPatch = lines.join("\n");
446
+ return new PatchParser().parse(lines, normalizedPatch);
447
+ }
448
+
449
+ export function parsePatch(patch: string): PatchOperation[] {
450
+ return parsePatchDocument(patch).operations;
451
+ }
452
+
453
+ function normalizeFuzzyText(value: string): string {
454
+ const replacements: Record<string, string> = {
455
+ "\u2010": "-",
456
+ "\u2011": "-",
457
+ "\u2012": "-",
458
+ "\u2013": "-",
459
+ "\u2014": "-",
460
+ "\u2015": "-",
461
+ "\u2212": "-",
462
+ "\u2018": "'",
463
+ "\u2019": "'",
464
+ "\u201a": "'",
465
+ "\u201b": "'",
466
+ "\u201c": '"',
467
+ "\u201d": '"',
468
+ "\u201e": '"',
469
+ "\u201f": '"',
470
+ "\u00a0": " ",
471
+ "\u2002": " ",
472
+ "\u2003": " ",
473
+ "\u2004": " ",
474
+ "\u2005": " ",
475
+ "\u2006": " ",
476
+ "\u2007": " ",
477
+ "\u2008": " ",
478
+ "\u2009": " ",
479
+ "\u200a": " ",
480
+ "\u202f": " ",
481
+ "\u205f": " ",
482
+ "\u3000": " ",
483
+ };
484
+ return Array.from(rustTrim(value))
485
+ .map((character) => replacements[character] ?? character)
486
+ .join("");
487
+ }
488
+
489
+ function sequenceMatches(
490
+ lines: readonly string[],
491
+ pattern: readonly string[],
492
+ index: number,
493
+ mode: "exact" | "trim-end" | "trim" | "unicode",
494
+ ): boolean {
495
+ const candidate = lines.slice(index, index + pattern.length);
496
+ if (candidate.length !== pattern.length) return false;
497
+ return candidate.every((line, offset) => {
498
+ const expected = pattern[offset]!;
499
+ switch (mode) {
500
+ case "exact":
501
+ return line === expected;
502
+ case "trim-end":
503
+ return rustTrimEnd(line) === rustTrimEnd(expected);
504
+ case "trim":
505
+ return rustTrim(line) === rustTrim(expected);
506
+ case "unicode":
507
+ return normalizeFuzzyText(line) === normalizeFuzzyText(expected);
508
+ }
509
+ });
510
+ }
511
+
512
+ function findSequence(
513
+ lines: readonly string[],
514
+ pattern: readonly string[],
515
+ start: number,
516
+ endOfFile: boolean,
517
+ ): number | undefined {
518
+ if (pattern.length === 0) return start;
519
+ if (pattern.length > lines.length) return undefined;
520
+ const last = lines.length - pattern.length;
521
+ const searchStart = endOfFile ? last : start;
522
+ for (const mode of ["exact", "trim-end", "trim", "unicode"] as const) {
523
+ for (let index = searchStart; index <= last; index++) {
524
+ if (sequenceMatches(lines, pattern, index, mode)) return index;
525
+ }
526
+ }
527
+ return undefined;
528
+ }
529
+
530
+ function deriveNewContent(content: string, chunks: readonly UpdateChunk[], path: string): string {
531
+ const lines = content.split("\n");
532
+ if (lines.at(-1) === "") lines.pop();
533
+ const replacements: Array<{ index: number; oldLength: number; newLines: string[] }> = [];
534
+ let cursor = 0;
535
+
536
+ for (const chunk of chunks) {
537
+ if (chunk.context) {
538
+ const contextIndex = findSequence(lines, [chunk.context], cursor, false);
539
+ if (contextIndex === undefined) {
540
+ throw new Error(`Failed to find context '${chunk.context}' in ${path}`);
541
+ }
542
+ cursor = contextIndex + 1;
543
+ }
544
+
545
+ if (chunk.oldLines.length === 0) {
546
+ const insertionIndex = lines.at(-1) === "" ? lines.length - 1 : lines.length;
547
+ replacements.push({
548
+ index: insertionIndex,
549
+ oldLength: 0,
550
+ newLines: [...chunk.newLines],
551
+ });
552
+ continue;
553
+ }
554
+
555
+ let oldLines = chunk.oldLines;
556
+ let newLines = chunk.newLines;
557
+ let found = findSequence(lines, oldLines, cursor, chunk.endOfFile);
558
+ if (found === undefined && oldLines.at(-1) === "") {
559
+ oldLines = oldLines.slice(0, -1);
560
+ if (newLines.at(-1) === "") newLines = newLines.slice(0, -1);
561
+ found = findSequence(lines, oldLines, cursor, chunk.endOfFile);
562
+ }
563
+ if (found === undefined) {
564
+ throw new Error(`Failed to find expected lines in ${path}:\n${chunk.oldLines.join("\n")}`);
565
+ }
566
+ replacements.push({
567
+ index: found,
568
+ oldLength: oldLines.length,
569
+ newLines: [...newLines],
570
+ });
571
+ cursor = found + oldLines.length;
572
+ }
573
+
574
+ replacements.sort((left, right) => left.index - right.index);
575
+ for (const replacement of replacements.toReversed()) {
576
+ lines.splice(replacement.index, replacement.oldLength, ...replacement.newLines);
577
+ }
578
+ if (lines.at(-1) !== "") lines.push("");
579
+ return lines.join("\n");
580
+ }
581
+
582
+ function hasErrorCode(error: unknown, code: string): boolean {
583
+ return typeof error === "object" && error !== null && "code" in error && error.code === code;
584
+ }
585
+
586
+ function isNotFound(error: unknown): boolean {
587
+ return hasErrorCode(error, "ENOENT");
588
+ }
589
+
590
+ function resolvePatchPath(cwd: string, patchPath: string): string {
591
+ return isAbsolute(patchPath) ? resolve(patchPath) : resolve(cwd, patchPath);
592
+ }
593
+
594
+ function resolveOperations(
595
+ cwd: string,
596
+ operations: readonly PatchOperation[],
597
+ ): ResolvedOperation[] {
598
+ return operations.map((operation) => {
599
+ const absolutePath = resolvePatchPath(cwd, operation.path);
600
+ if (operation.kind !== "update" || !operation.moveTo) {
601
+ return { ...operation, absolutePath };
602
+ }
603
+ return {
604
+ ...operation,
605
+ absolutePath,
606
+ moveAbsolutePath: resolvePatchPath(cwd, operation.moveTo),
607
+ };
608
+ });
609
+ }
610
+
611
+ const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true });
612
+
613
+ function errorMessage(error: unknown): string {
614
+ return error instanceof Error ? error.message : String(error);
615
+ }
616
+
617
+ async function readUtf8(path: string, context: string): Promise<string> {
618
+ try {
619
+ return UTF8_DECODER.decode(await readFile(path));
620
+ } catch (error) {
621
+ throw new Error(`${context}: ${errorMessage(error)}`);
622
+ }
623
+ }
624
+
625
+ async function supportsExactDelta(path: string): Promise<boolean> {
626
+ try {
627
+ const metadata = await lstat(path);
628
+ return metadata.isFile() && !metadata.isSymbolicLink();
629
+ } catch (error) {
630
+ return isNotFound(error);
631
+ }
632
+ }
633
+
634
+ async function readOptionalUtf8(path: string): Promise<{ content?: string; exact: boolean }> {
635
+ const exact = await supportsExactDelta(path);
636
+ try {
637
+ return { content: UTF8_DECODER.decode(await readFile(path)), exact };
638
+ } catch (error) {
639
+ if (isNotFound(error)) return { exact };
640
+ return { exact: false };
641
+ }
642
+ }
643
+
644
+ async function verifyOperations(operations: readonly ResolvedOperation[]): Promise<void> {
645
+ for (const operation of operations) {
646
+ if (operation.kind === "add") continue;
647
+ if (operation.kind === "delete") {
648
+ await readUtf8(operation.absolutePath, `Failed to read ${operation.absolutePath}`);
649
+ continue;
650
+ }
651
+ const current = await readUtf8(
652
+ operation.absolutePath,
653
+ `Failed to read file to update ${operation.absolutePath}`,
654
+ );
655
+ deriveNewContent(current, operation.chunks, operation.absolutePath);
656
+ }
657
+ }
658
+
659
+ function diffDetails(
660
+ oldContent: string,
661
+ newContent: string,
662
+ ): {
663
+ displayDiff: string;
664
+ additions: number;
665
+ deletions: number;
666
+ } {
667
+ const displayDiff = generateDiffString(oldContent, newContent, 1).diff;
668
+ let additions = 0;
669
+ let deletions = 0;
670
+ for (const line of displayDiff.split("\n")) {
671
+ if (line.startsWith("+")) additions += 1;
672
+ if (line.startsWith("-")) deletions += 1;
673
+ }
674
+ return { displayDiff, additions, deletions };
675
+ }
676
+
677
+ function emptyDetails(): ApplyPatchDetails {
678
+ return {
679
+ status: "completed",
680
+ exact: true,
681
+ changes: [],
682
+ added: [],
683
+ modified: [],
684
+ deleted: [],
685
+ };
686
+ }
687
+
688
+ export function cloneApplyPatchDetails(details: ApplyPatchDetails): ApplyPatchDetails {
689
+ return {
690
+ ...details,
691
+ changes: details.changes.map((change) => ({ ...change })),
692
+ added: [...details.added],
693
+ modified: [...details.modified],
694
+ deleted: [...details.deleted],
695
+ };
696
+ }
697
+
698
+ export type ApplyPatchExecutionHooks = {
699
+ onExecutionStart?: () => void;
700
+ onProgress?: (details: ApplyPatchDetails) => void;
701
+ };
702
+
703
+ async function writeFileWithParents(path: string, content: string): Promise<void> {
704
+ try {
705
+ await writeFile(path, content, "utf8");
706
+ } catch (error) {
707
+ if (!isNotFound(error)) throw error;
708
+ await mkdir(dirname(path), { recursive: true });
709
+ await writeFile(path, content, "utf8");
710
+ }
711
+ }
712
+
713
+ function throwIfAborted(signal: AbortSignal | undefined): void {
714
+ if (signal?.aborted) throw new Error("apply_patch was cancelled.");
715
+ }
716
+
717
+ async function withMutationQueues<T>(
718
+ paths: readonly string[],
719
+ callback: () => Promise<T>,
720
+ index = 0,
721
+ ): Promise<T> {
722
+ const path = paths[index];
723
+ if (!path) return callback();
724
+ return withFileMutationQueue(path, () => withMutationQueues(paths, callback, index + 1));
725
+ }
726
+
727
+ async function applyOperations(
728
+ operations: readonly ResolvedOperation[],
729
+ signal: AbortSignal | undefined,
730
+ onProgress?: (details: ApplyPatchDetails) => void,
731
+ ): Promise<ApplyPatchDetails> {
732
+ const details = emptyDetails();
733
+ try {
734
+ for (const operation of operations) {
735
+ throwIfAborted(signal);
736
+ if (operation.kind === "add") {
737
+ const previous = await readOptionalUtf8(operation.absolutePath);
738
+ details.exact &&= previous.exact;
739
+ try {
740
+ await writeFileWithParents(operation.absolutePath, operation.content);
741
+ } catch (error) {
742
+ details.exact = false;
743
+ throw new Error(`Failed to write file ${operation.absolutePath}: ${errorMessage(error)}`);
744
+ }
745
+ const diff = diffDetails("", operation.content);
746
+ details.changes.push({
747
+ kind: "add",
748
+ path: operation.path,
749
+ content: operation.content,
750
+ ...(previous.content !== undefined ? { overwrittenContent: previous.content } : {}),
751
+ ...diff,
752
+ });
753
+ details.added.push(operation.path);
754
+ } else if (operation.kind === "delete") {
755
+ const previous = await readOptionalUtf8(operation.absolutePath);
756
+ details.exact &&= previous.exact;
757
+ try {
758
+ const metadata = await stat(operation.absolutePath);
759
+ if (metadata.isDirectory()) throw new Error("path is a directory");
760
+ await unlink(operation.absolutePath);
761
+ } catch (error) {
762
+ if (previous.content !== undefined) {
763
+ try {
764
+ details.exact &&=
765
+ (await readUtf8(operation.absolutePath, "Failed to inspect delete failure")) ===
766
+ previous.content;
767
+ } catch {
768
+ details.exact = false;
769
+ }
770
+ } else {
771
+ details.exact = false;
772
+ }
773
+ throw new Error(
774
+ `Failed to delete file ${operation.absolutePath}: ${errorMessage(error)}`,
775
+ );
776
+ }
777
+ const content = previous.content ?? "";
778
+ if (previous.content !== undefined) {
779
+ details.changes.push({
780
+ kind: "delete",
781
+ path: operation.path,
782
+ content,
783
+ ...diffDetails(content, ""),
784
+ });
785
+ }
786
+ details.deleted.push(operation.path);
787
+ } else {
788
+ details.exact &&= await supportsExactDelta(operation.absolutePath);
789
+ const oldContent = await readUtf8(
790
+ operation.absolutePath,
791
+ `Failed to read file to update ${operation.absolutePath}`,
792
+ );
793
+ const newContent = deriveNewContent(oldContent, operation.chunks, operation.absolutePath);
794
+ if (operation.moveAbsolutePath && operation.moveTo) {
795
+ const previousDestination = await readOptionalUtf8(operation.moveAbsolutePath);
796
+ details.exact &&= previousDestination.exact;
797
+ try {
798
+ await writeFileWithParents(operation.moveAbsolutePath, newContent);
799
+ } catch (error) {
800
+ details.exact = false;
801
+ throw new Error(
802
+ `Failed to write file ${operation.moveAbsolutePath}: ${errorMessage(error)}`,
803
+ );
804
+ }
805
+ const provisionalIndex = details.changes.length;
806
+ details.changes.push({
807
+ kind: "add",
808
+ path: operation.moveTo,
809
+ content: newContent,
810
+ ...(previousDestination.content !== undefined
811
+ ? { overwrittenContent: previousDestination.content }
812
+ : {}),
813
+ ...diffDetails("", newContent),
814
+ });
815
+ try {
816
+ const metadata = await stat(operation.absolutePath);
817
+ if (metadata.isDirectory()) throw new Error("path is a directory");
818
+ await unlink(operation.absolutePath);
819
+ } catch (error) {
820
+ try {
821
+ details.exact &&=
822
+ (await readUtf8(operation.absolutePath, "Failed to inspect move failure")) ===
823
+ oldContent;
824
+ } catch {
825
+ details.exact = false;
826
+ }
827
+ throw new Error(
828
+ `Failed to remove original ${operation.absolutePath}: ${errorMessage(error)}`,
829
+ );
830
+ }
831
+ details.changes[provisionalIndex] = {
832
+ kind: "update",
833
+ path: operation.path,
834
+ moveTo: operation.moveTo,
835
+ oldContent,
836
+ newContent,
837
+ ...(previousDestination.content !== undefined
838
+ ? { overwrittenMoveContent: previousDestination.content }
839
+ : {}),
840
+ ...diffDetails(oldContent, newContent),
841
+ };
842
+ details.modified.push(operation.moveTo);
843
+ } else {
844
+ try {
845
+ await writeFile(operation.absolutePath, newContent, "utf8");
846
+ } catch (error) {
847
+ details.exact = false;
848
+ throw new Error(
849
+ `Failed to write file ${operation.absolutePath}: ${errorMessage(error)}`,
850
+ );
851
+ }
852
+ details.changes.push({
853
+ kind: "update",
854
+ path: operation.path,
855
+ oldContent,
856
+ newContent,
857
+ ...diffDetails(oldContent, newContent),
858
+ });
859
+ details.modified.push(operation.path);
860
+ }
861
+ }
862
+ throwIfAborted(signal);
863
+ onProgress?.(cloneApplyPatchDetails(details));
864
+ }
865
+ return details;
866
+ } catch (error) {
867
+ details.status = "failed";
868
+ details.error = errorMessage(error);
869
+ throw new ApplyPatchExecutionError(details.error, cloneApplyPatchDetails(details));
870
+ }
871
+ }
872
+
873
+ export async function previewPatch(cwd: string, patch: string): Promise<ApplyPatchDetails> {
874
+ const parsed = parsePatchDocument(patch);
875
+ if (parsed.environmentId) {
876
+ throw new ApplyPatchInputError(
877
+ "apply_patch environment selection is unavailable for this turn",
878
+ );
879
+ }
880
+ if (parsed.operations.length === 0) {
881
+ throw new ApplyPatchInputError("patch rejected: empty patch");
882
+ }
883
+ const operations = resolveOperations(cwd, parsed.operations);
884
+ await verifyOperations(operations);
885
+ const details = emptyDetails();
886
+ const changes = new Map<string, AppliedPatchChange>();
887
+ for (const operation of operations) {
888
+ if (operation.kind === "add") {
889
+ changes.set(operation.absolutePath, {
890
+ kind: "add",
891
+ path: operation.path,
892
+ content: operation.content,
893
+ ...diffDetails("", operation.content),
894
+ });
895
+ } else if (operation.kind === "delete") {
896
+ const content = await readUtf8(
897
+ operation.absolutePath,
898
+ `Failed to read ${operation.absolutePath}`,
899
+ );
900
+ changes.set(operation.absolutePath, {
901
+ kind: "delete",
902
+ path: operation.path,
903
+ content,
904
+ ...diffDetails(content, ""),
905
+ });
906
+ } else {
907
+ const oldContent = await readUtf8(
908
+ operation.absolutePath,
909
+ `Failed to read file to update ${operation.absolutePath}`,
910
+ );
911
+ const newContent = deriveNewContent(oldContent, operation.chunks, operation.absolutePath);
912
+ changes.set(operation.absolutePath, {
913
+ kind: "update",
914
+ path: operation.path,
915
+ ...(operation.moveTo ? { moveTo: operation.moveTo } : {}),
916
+ oldContent,
917
+ newContent,
918
+ ...diffDetails(oldContent, newContent),
919
+ });
920
+ }
921
+ }
922
+ details.changes = [...changes.values()];
923
+ for (const change of details.changes) {
924
+ if (change.kind === "add") details.added.push(change.path);
925
+ else if (change.kind === "delete") details.deleted.push(change.path);
926
+ else details.modified.push(change.moveTo ?? change.path);
927
+ }
928
+ return details;
929
+ }
930
+
931
+ export async function applyPatch(
932
+ cwd: string,
933
+ patch: string,
934
+ signal?: AbortSignal,
935
+ hooks: ApplyPatchExecutionHooks = {},
936
+ ): Promise<ApplyPatchDetails> {
937
+ throwIfAborted(signal);
938
+ let parsed: ParsedPatch;
939
+ let operations: ResolvedOperation[];
940
+ try {
941
+ parsed = parsePatchDocument(patch);
942
+ if (parsed.environmentId) {
943
+ throw new ApplyPatchInputError(
944
+ "apply_patch environment selection is unavailable for this turn",
945
+ );
946
+ }
947
+ if (parsed.operations.length === 0) {
948
+ throw new ApplyPatchInputError("patch rejected: empty patch");
949
+ }
950
+ operations = resolveOperations(cwd, parsed.operations);
951
+ } catch (error) {
952
+ if (error instanceof ApplyPatchInputError) throw error;
953
+ throw new ApplyPatchVerificationError(
954
+ `apply_patch verification failed: ${errorMessage(error)}`,
955
+ );
956
+ }
957
+
958
+ const queuePaths = [
959
+ ...new Set(
960
+ operations.flatMap((operation) => [
961
+ operation.absolutePath,
962
+ ...(operation.kind === "update" && operation.moveAbsolutePath
963
+ ? [operation.moveAbsolutePath]
964
+ : []),
965
+ ]),
966
+ ),
967
+ ].sort();
968
+
969
+ return withMutationQueues(queuePaths, async () => {
970
+ throwIfAborted(signal);
971
+ try {
972
+ await verifyOperations(operations);
973
+ } catch (error) {
974
+ throw new ApplyPatchVerificationError(
975
+ `apply_patch verification failed: ${errorMessage(error)}`,
976
+ );
977
+ }
978
+ throwIfAborted(signal);
979
+ hooks.onExecutionStart?.();
980
+ return applyOperations(operations, signal, hooks.onProgress);
981
+ });
982
+ }
983
+
984
+ export function formatApplyPatchSummary(details: ApplyPatchDetails): string {
985
+ const lines = ["Success. Updated the following files:"];
986
+ for (const path of details.added) lines.push(`A ${path}`);
987
+ for (const path of details.modified) lines.push(`M ${path}`);
988
+ for (const path of details.deleted) lines.push(`D ${path}`);
989
+ return `${lines.join("\n")}\n`;
990
+ }
991
+
992
+ export function formatApplyPatchModelOutput(
993
+ exitCode: number,
994
+ durationMs: number,
995
+ output: string,
996
+ ): string {
997
+ const durationSeconds = Math.round(durationMs / 100) / 10;
998
+ return [
999
+ `Exit code: ${exitCode}`,
1000
+ `Wall time: ${durationSeconds} seconds`,
1001
+ "Output:",
1002
+ output,
1003
+ ].join("\n");
1004
+ }