pum-agent 0.1.0-beta.3

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.
@@ -0,0 +1,583 @@
1
+ import type { InlineExtension } from "@earendil-works/pi-coding-agent";
2
+ import { generateUnifiedPatch, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
3
+ import { randomUUID } from "node:crypto";
4
+ import {
5
+ chmod,
6
+ lstat,
7
+ mkdir,
8
+ readFile,
9
+ realpath,
10
+ rename,
11
+ rm,
12
+ rmdir,
13
+ writeFile,
14
+ } from "node:fs/promises";
15
+ import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
16
+ import { Type } from "typebox";
17
+
18
+ export const APPLY_PATCH_TOOL_NAME = "apply_patch";
19
+
20
+ export type ApplyPatchInput = { patch: string };
21
+
22
+ type PatchLine = { text: string; number: number };
23
+ type PatchHunk = {
24
+ header?: string;
25
+ oldLines: string[];
26
+ newLines: string[];
27
+ endOfFile: boolean;
28
+ line: number;
29
+ };
30
+ type AddOperation = { type: "add"; path: string; lines: string[] };
31
+ type DeleteOperation = { type: "delete"; path: string };
32
+ type UpdateOperation = { type: "update"; path: string; moveTo?: string; hunks: PatchHunk[] };
33
+ export type PatchOperation = AddOperation | DeleteOperation | UpdateOperation;
34
+
35
+ export type ApplyPatchDetails = {
36
+ patch: string;
37
+ files: string[];
38
+ operations: Array<{ type: PatchOperation["type"]; path: string; moveTo?: string }>;
39
+ };
40
+
41
+ type FileSnapshot = {
42
+ exists: boolean;
43
+ buffer?: Buffer;
44
+ mode?: number;
45
+ };
46
+
47
+ type FileOutput = {
48
+ path: string;
49
+ buffer: Buffer;
50
+ mode?: number;
51
+ };
52
+
53
+ type PreparedChange = {
54
+ operation: PatchOperation;
55
+ sourcePath: string;
56
+ destinationPath?: string;
57
+ oldText: string;
58
+ newText: string;
59
+ };
60
+
61
+ export type ApplyPatchFileSystem = {
62
+ readFile: typeof readFile;
63
+ writeFile: typeof writeFile;
64
+ rename: typeof rename;
65
+ rm: typeof rm;
66
+ mkdir: typeof mkdir;
67
+ rmdir: typeof rmdir;
68
+ lstat: typeof lstat;
69
+ realpath: typeof realpath;
70
+ chmod: typeof chmod;
71
+ };
72
+
73
+ const defaultFileSystem: ApplyPatchFileSystem = {
74
+ readFile,
75
+ writeFile,
76
+ rename,
77
+ rm,
78
+ mkdir,
79
+ rmdir,
80
+ lstat,
81
+ realpath,
82
+ chmod,
83
+ };
84
+
85
+ function patchError(message: string, line?: number): Error {
86
+ return new Error(line === undefined ? `Invalid patch: ${message}` : `Invalid patch at line ${line}: ${message}`);
87
+ }
88
+
89
+ function normalizedPatchLines(patch: string): PatchLine[] {
90
+ if (patch.includes("\0")) throw patchError("NUL bytes are not allowed");
91
+ const normalized = patch.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\n+$/, "");
92
+ return normalized.split("\n").map((text, index) => ({ text, number: index + 1 }));
93
+ }
94
+
95
+ function fileMarker(line: string): boolean {
96
+ return line.startsWith("*** Add File: ")
97
+ || line.startsWith("*** Update File: ")
98
+ || line.startsWith("*** Delete File: ")
99
+ || line === "*** End Patch";
100
+ }
101
+
102
+ function markerPath(line: PatchLine, marker: string): string {
103
+ const path = line.text.slice(marker.length).trim();
104
+ if (!path) throw patchError("a file header requires a path", line.number);
105
+ return normalizePatchPath(path, line.number);
106
+ }
107
+
108
+ /** Parse the documented OpenAI Codex patch envelope before any filesystem access. */
109
+ export function parseApplyPatch(patch: string): PatchOperation[] {
110
+ if (typeof patch !== "string") throw patchError("patch must be a string");
111
+ const lines = normalizedPatchLines(patch);
112
+ if (lines[0]?.text.trim() !== "*** Begin Patch") {
113
+ throw patchError("the first line must be '*** Begin Patch'", lines[0]?.number);
114
+ }
115
+ if (lines.at(-1)?.text.trim() !== "*** End Patch") {
116
+ throw patchError("the last line must be '*** End Patch'", lines.at(-1)?.number);
117
+ }
118
+
119
+ const operations: PatchOperation[] = [];
120
+ let index = 1;
121
+ while (index < lines.length - 1) {
122
+ const line = lines[index]!;
123
+ if (line.text.startsWith("*** Add File: ")) {
124
+ const path = markerPath(line, "*** Add File: ");
125
+ const content: string[] = [];
126
+ index++;
127
+ while (index < lines.length - 1 && !fileMarker(lines[index]!.text)) {
128
+ const contentLine = lines[index]!;
129
+ if (!contentLine.text.startsWith("+")) {
130
+ throw patchError("every Add File content line must start with '+'", contentLine.number);
131
+ }
132
+ content.push(contentLine.text.slice(1));
133
+ index++;
134
+ }
135
+ if (content.length === 0) throw patchError("Add File requires at least one content line", line.number);
136
+ operations.push({ type: "add", path, lines: content });
137
+ continue;
138
+ }
139
+
140
+ if (line.text.startsWith("*** Delete File: ")) {
141
+ operations.push({ type: "delete", path: markerPath(line, "*** Delete File: ") });
142
+ index++;
143
+ continue;
144
+ }
145
+
146
+ if (line.text.startsWith("*** Update File: ")) {
147
+ const path = markerPath(line, "*** Update File: ");
148
+ let moveTo: string | undefined;
149
+ const hunks: PatchHunk[] = [];
150
+ index++;
151
+ if (lines[index]?.text.startsWith("*** Move to: ")) {
152
+ moveTo = markerPath(lines[index]!, "*** Move to: ");
153
+ index++;
154
+ }
155
+
156
+ while (index < lines.length - 1 && !fileMarker(lines[index]!.text)) {
157
+ const header = lines[index]!;
158
+ if (header.text !== "@@" && !header.text.startsWith("@@ ")) {
159
+ throw patchError("an Update File hunk must start with '@@'", header.number);
160
+ }
161
+ const hunk: PatchHunk = {
162
+ header: header.text === "@@" ? undefined : header.text.slice(3),
163
+ oldLines: [],
164
+ newLines: [],
165
+ endOfFile: false,
166
+ line: header.number,
167
+ };
168
+ let changed = false;
169
+ index++;
170
+ while (index < lines.length - 1) {
171
+ const hunkLine = lines[index]!;
172
+ if (fileMarker(hunkLine.text) || hunkLine.text === "@@" || hunkLine.text.startsWith("@@ ")) break;
173
+ if (hunkLine.text === "*** End of File") {
174
+ hunk.endOfFile = true;
175
+ index++;
176
+ break;
177
+ }
178
+ const prefix = hunkLine.text[0];
179
+ const text = hunkLine.text.slice(1);
180
+ if (prefix === " ") {
181
+ hunk.oldLines.push(text);
182
+ hunk.newLines.push(text);
183
+ } else if (prefix === "-") {
184
+ hunk.oldLines.push(text);
185
+ changed = true;
186
+ } else if (prefix === "+") {
187
+ hunk.newLines.push(text);
188
+ changed = true;
189
+ } else {
190
+ throw patchError("hunk lines must start with ' ', '+', or '-'", hunkLine.number);
191
+ }
192
+ index++;
193
+ }
194
+ if (!changed) throw patchError("a hunk must add or remove at least one line", hunk.line);
195
+ hunks.push(hunk);
196
+ }
197
+ if (!moveTo && hunks.length === 0) throw patchError("Update File requires a hunk or Move to", line.number);
198
+ operations.push({ type: "update", path, moveTo, hunks });
199
+ continue;
200
+ }
201
+
202
+ throw patchError("expected Add File, Update File, or Delete File", line.number);
203
+ }
204
+
205
+ if (operations.length === 0) throw patchError("the patch contains no file operations");
206
+ return operations;
207
+ }
208
+
209
+ /** Convert Windows separators safely, but reject every absolute or parent path. */
210
+ export function normalizePatchPath(input: string, line?: number): string {
211
+ const path = input.replaceAll("\\", "/");
212
+ if (isAbsolute(path) || path.startsWith("/") || /^[A-Za-z]:\//.test(path) || path.startsWith("//")) {
213
+ throw patchError(`absolute paths are not allowed: ${input}`, line);
214
+ }
215
+ const parts = path.split("/");
216
+ if (parts.some((part) => part === "..")) throw patchError(`parent traversal is not allowed: ${input}`, line);
217
+ const clean = parts.filter((part) => part !== "" && part !== ".").join("/");
218
+ if (!clean) throw patchError(`invalid project path: ${input}`, line);
219
+ return clean;
220
+ }
221
+
222
+ function insideRoot(root: string, path: string): boolean {
223
+ const rel = relative(root, path);
224
+ return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
225
+ }
226
+
227
+ async function pathExists(path: string, fs: ApplyPatchFileSystem): Promise<boolean> {
228
+ try {
229
+ await fs.lstat(path);
230
+ return true;
231
+ } catch (error) {
232
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
233
+ throw error;
234
+ }
235
+ }
236
+
237
+ async function validateProjectPath(root: string, path: string, fs: ApplyPatchFileSystem): Promise<string> {
238
+ if (!insideRoot(root, path)) throw new Error(`Patch path is outside the project: ${path}`);
239
+ let candidate = path;
240
+ while (!(await pathExists(candidate, fs))) {
241
+ const parent = dirname(candidate);
242
+ if (parent === candidate) throw new Error(`Cannot resolve patch path: ${path}`);
243
+ candidate = parent;
244
+ }
245
+ const relativeCandidate = relative(root, candidate);
246
+ let component = root;
247
+ for (const part of relativeCandidate.split(sep).filter(Boolean)) {
248
+ component = resolve(component, part);
249
+ const metadata = await fs.lstat(component);
250
+ if (metadata.isSymbolicLink()) {
251
+ throw new Error(`Patch paths cannot contain symbolic links: ${path}`);
252
+ }
253
+ }
254
+ const resolved = await fs.realpath(candidate);
255
+ const canonicalPath = resolve(resolved, relative(candidate, path));
256
+ if (!insideRoot(root, canonicalPath)) throw new Error(`Patch path resolves outside the project: ${path}`);
257
+ if (candidate === path) {
258
+ const metadata = await fs.lstat(path);
259
+ if (metadata.isSymbolicLink()) throw new Error(`Patch paths cannot be symbolic links: ${path}`);
260
+ if (metadata.isDirectory()) throw new Error(`Patch paths must be files: ${path}`);
261
+ }
262
+ return canonicalPath;
263
+ }
264
+
265
+ function decodeText(buffer: Buffer, path: string): { text: string; bom: string } {
266
+ let decoded: string;
267
+ try {
268
+ decoded = new TextDecoder("utf-8", { fatal: true }).decode(buffer);
269
+ } catch {
270
+ throw new Error(`Patch file is not valid UTF-8: ${path}`);
271
+ }
272
+ const bom = decoded.startsWith("\uFEFF") ? "\uFEFF" : "";
273
+ return { text: bom ? decoded.slice(1) : decoded, bom };
274
+ }
275
+
276
+ function lineFormat(text: string): { lines: string[]; ending: "\n" | "\r\n"; finalNewline: boolean } {
277
+ const ending = text.includes("\r\n") ? "\r\n" : "\n";
278
+ const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
279
+ const finalNewline = normalized.endsWith("\n");
280
+ const lines = normalized.split("\n");
281
+ if (finalNewline) lines.pop();
282
+ if (lines.length === 1 && lines[0] === "") lines.pop();
283
+ return { lines, ending, finalNewline };
284
+ }
285
+
286
+ function occurrences(lines: string[], pattern: string[], start: number, endOfFile: boolean): number[] {
287
+ if (pattern.length === 0) return [];
288
+ const matches: number[] = [];
289
+ for (let index = start; index + pattern.length <= lines.length; index++) {
290
+ if (endOfFile && index + pattern.length !== lines.length) continue;
291
+ if (pattern.every((line, offset) => lines[index + offset] === line)) matches.push(index);
292
+ }
293
+ return matches;
294
+ }
295
+
296
+ function uniqueMatch(lines: string[], pattern: string[], start: number, path: string, label: string, eof: boolean): number {
297
+ const matches = occurrences(lines, pattern, start, eof);
298
+ if (matches.length === 0) throw new Error(`Failed to find ${label} in ${path}:\n${pattern.join("\n")}`);
299
+ if (matches.length > 1) throw new Error(`Ambiguous ${label} in ${path}: matched ${matches.length} locations`);
300
+ return matches[0]!;
301
+ }
302
+
303
+ function applyHunks(text: string, bom: string, path: string, hunks: PatchHunk[]): string {
304
+ const format = lineFormat(text);
305
+ const replacements: Array<{ start: number; length: number; lines: string[] }> = [];
306
+ let searchStart = 0;
307
+
308
+ for (const hunk of hunks) {
309
+ let anchor: number | undefined;
310
+ if (hunk.header !== undefined) {
311
+ anchor = uniqueMatch(format.lines, [hunk.header], searchStart, path, `hunk context '${hunk.header}'`, false);
312
+ searchStart = anchor + 1;
313
+ }
314
+
315
+ if (hunk.oldLines.length === 0) {
316
+ const start = hunk.endOfFile ? format.lines.length : anchor === undefined ? format.lines.length : anchor + 1;
317
+ replacements.push({ start, length: 0, lines: hunk.newLines });
318
+ searchStart = start;
319
+ continue;
320
+ }
321
+
322
+ const start = uniqueMatch(format.lines, hunk.oldLines, searchStart, path, "expected hunk lines", hunk.endOfFile);
323
+ replacements.push({ start, length: hunk.oldLines.length, lines: hunk.newLines });
324
+ searchStart = start + hunk.oldLines.length;
325
+ }
326
+
327
+ const result = [...format.lines];
328
+ for (const replacement of replacements.sort((a, b) => b.start - a.start)) {
329
+ result.splice(replacement.start, replacement.length, ...replacement.lines);
330
+ }
331
+ let normalized = result.join("\n");
332
+ if (format.finalNewline) normalized += "\n";
333
+ return bom + normalized.replaceAll("\n", format.ending);
334
+ }
335
+
336
+ async function snapshot(path: string, fs: ApplyPatchFileSystem): Promise<FileSnapshot> {
337
+ try {
338
+ const metadata = await fs.lstat(path);
339
+ if (metadata.isSymbolicLink()) throw new Error(`Patch paths cannot be symbolic links: ${path}`);
340
+ if (!metadata.isFile()) throw new Error(`Patch paths must be files: ${path}`);
341
+ return { exists: true, buffer: await fs.readFile(path), mode: metadata.mode };
342
+ } catch (error) {
343
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return { exists: false };
344
+ throw error;
345
+ }
346
+ }
347
+
348
+ function operationPaths(root: string, operation: PatchOperation): { source: string; destination?: string } {
349
+ const source = resolve(root, ...operation.path.split("/"));
350
+ const destination = operation.type === "update" && operation.moveTo
351
+ ? resolve(root, ...operation.moveTo.split("/"))
352
+ : undefined;
353
+ return { source, destination };
354
+ }
355
+
356
+ async function withMutationQueues<T>(paths: string[], operation: () => Promise<T>): Promise<T> {
357
+ const unique = [...new Set(paths)].sort();
358
+ const acquire = (index: number): Promise<T> => {
359
+ const path = unique[index];
360
+ return path === undefined
361
+ ? operation()
362
+ : withFileMutationQueue(path, () => acquire(index + 1));
363
+ };
364
+ return acquire(0);
365
+ }
366
+
367
+ async function createMissingDirectories(
368
+ path: string,
369
+ root: string,
370
+ fs: ApplyPatchFileSystem,
371
+ created: string[],
372
+ ): Promise<void> {
373
+ const missing: string[] = [];
374
+ let candidate = path;
375
+ while (candidate !== root && !(await pathExists(candidate, fs))) {
376
+ missing.push(candidate);
377
+ candidate = dirname(candidate);
378
+ }
379
+ for (const directory of missing.reverse()) {
380
+ await fs.mkdir(directory);
381
+ created.push(directory);
382
+ }
383
+ }
384
+
385
+ async function cleanupDirectories(directories: string[], fs: ApplyPatchFileSystem): Promise<void> {
386
+ for (const directory of [...directories].reverse()) {
387
+ await fs.rmdir(directory).catch(() => {});
388
+ }
389
+ }
390
+
391
+ async function commitAtomically(
392
+ root: string,
393
+ outputs: FileOutput[],
394
+ touchedPaths: string[],
395
+ fs: ApplyPatchFileSystem,
396
+ ): Promise<void> {
397
+ const token = `${process.pid}-${randomUUID()}`;
398
+ const stages = new Map<string, string>();
399
+ const backups = new Map<string, string>();
400
+ const placed = new Set<string>();
401
+ const createdDirectories: string[] = [];
402
+
403
+ try {
404
+ for (const output of outputs) {
405
+ await createMissingDirectories(dirname(output.path), root, fs, createdDirectories);
406
+ const stage = resolve(dirname(output.path), `.pum-apply-patch-${token}-${stages.size}.tmp`);
407
+ await fs.writeFile(stage, output.buffer, { flag: "wx" });
408
+ stages.set(output.path, stage);
409
+ if (output.mode !== undefined) await fs.chmod(stage, output.mode);
410
+ }
411
+
412
+ for (const path of [...new Set(touchedPaths)].sort()) {
413
+ if (!(await pathExists(path, fs))) continue;
414
+ const backup = resolve(dirname(path), `.pum-apply-patch-${token}-${backups.size}.bak`);
415
+ await fs.rename(path, backup);
416
+ backups.set(path, backup);
417
+ }
418
+
419
+ for (const output of outputs.sort((a, b) => a.path.localeCompare(b.path))) {
420
+ const stage = stages.get(output.path)!;
421
+ await fs.rename(stage, output.path);
422
+ stages.delete(output.path);
423
+ placed.add(output.path);
424
+ }
425
+ } catch (error) {
426
+ const rollbackErrors: string[] = [];
427
+ for (const path of [...placed].reverse()) {
428
+ await fs.rm(path, { force: true }).catch((rollbackError) => rollbackErrors.push(String(rollbackError)));
429
+ }
430
+ for (const [path, backup] of [...backups].reverse()) {
431
+ await fs.rename(backup, path).catch((rollbackError) => rollbackErrors.push(String(rollbackError)));
432
+ }
433
+ for (const stage of stages.values()) {
434
+ await fs.rm(stage, { force: true }).catch(() => {});
435
+ }
436
+ await cleanupDirectories(createdDirectories, fs);
437
+ const suffix = rollbackErrors.length > 0 ? ` Rollback errors: ${rollbackErrors.join("; ")}` : "";
438
+ throw new Error(`Could not apply patch atomically: ${String(error)}.${suffix}`);
439
+ }
440
+
441
+ for (const backup of backups.values()) await fs.rm(backup, { force: true }).catch(() => {});
442
+ await cleanupDirectories(createdDirectories, fs);
443
+ }
444
+
445
+ async function prepareChanges(
446
+ root: string,
447
+ operations: PatchOperation[],
448
+ fs: ApplyPatchFileSystem,
449
+ ): Promise<{ changes: PreparedChange[]; outputs: FileOutput[]; touched: string[] }> {
450
+ const snapshots = new Map<string, FileSnapshot>();
451
+ const pathOwners = new Map<string, string>();
452
+ const changes: PreparedChange[] = [];
453
+ const outputs: FileOutput[] = [];
454
+ const touched: string[] = [];
455
+
456
+ const claim = (path: string, description: string) => {
457
+ const key = process.platform === "win32" ? path.toLowerCase() : path;
458
+ const previous = pathOwners.get(key);
459
+ if (previous) throw new Error(`Conflicting patch paths: ${previous} and ${description}`);
460
+ pathOwners.set(key, description);
461
+ };
462
+
463
+ for (const operation of operations) {
464
+ const { source, destination } = operationPaths(root, operation);
465
+ const canonicalSource = await validateProjectPath(root, source, fs);
466
+ const canonicalDestination = destination
467
+ ? await validateProjectPath(root, destination, fs)
468
+ : undefined;
469
+ if (destination && destination !== source && canonicalDestination === canonicalSource) {
470
+ throw new Error(`Move destination resolves to its source: ${operation.path}`);
471
+ }
472
+ claim(canonicalSource, operation.path);
473
+ if (canonicalDestination && canonicalDestination !== canonicalSource && operation.type === "update") {
474
+ claim(canonicalDestination, operation.moveTo!);
475
+ }
476
+ snapshots.set(source, await snapshot(source, fs));
477
+ if (destination && destination !== source) snapshots.set(destination, await snapshot(destination, fs));
478
+ }
479
+
480
+ for (const operation of operations) {
481
+ const { source, destination } = operationPaths(root, operation);
482
+ const sourceSnapshot = snapshots.get(source)!;
483
+ if (operation.type === "add") {
484
+ const oldText = sourceSnapshot.exists ? decodeText(sourceSnapshot.buffer!, operation.path).text : "";
485
+ const newText = `${operation.lines.join("\n")}\n`;
486
+ outputs.push({ path: source, buffer: Buffer.from(newText), mode: sourceSnapshot.mode });
487
+ touched.push(source);
488
+ changes.push({ operation, sourcePath: source, oldText, newText });
489
+ continue;
490
+ }
491
+ if (!sourceSnapshot.exists) throw new Error(`Patch source does not exist: ${operation.path}`);
492
+ const decoded = decodeText(sourceSnapshot.buffer!, operation.path);
493
+ if (operation.type === "delete") {
494
+ touched.push(source);
495
+ changes.push({ operation, sourcePath: source, oldText: decoded.text, newText: "" });
496
+ continue;
497
+ }
498
+
499
+ const newText = applyHunks(decoded.text, decoded.bom, operation.path, operation.hunks);
500
+ const target = destination ?? source;
501
+ const targetSnapshot = snapshots.get(target) ?? sourceSnapshot;
502
+ outputs.push({ path: target, buffer: Buffer.from(newText), mode: sourceSnapshot.mode ?? targetSnapshot.mode });
503
+ touched.push(source);
504
+ if (target !== source) touched.push(target);
505
+ changes.push({ operation, sourcePath: source, destinationPath: destination, oldText: decoded.bom + decoded.text, newText });
506
+ }
507
+
508
+ return { changes, outputs, touched };
509
+ }
510
+
511
+ function detailsPatch(changes: PreparedChange[]): string {
512
+ return changes.map((change) => {
513
+ const displayPath = change.operation.type === "update" && change.operation.moveTo
514
+ ? change.operation.moveTo
515
+ : change.operation.path;
516
+ const oldText = change.oldText.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
517
+ const newText = change.newText.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
518
+ return generateUnifiedPatch(displayPath, oldText, newText);
519
+ }).join("");
520
+ }
521
+
522
+ export async function applyPatch(
523
+ cwd: string,
524
+ patch: string,
525
+ options: { fs?: ApplyPatchFileSystem; signal?: AbortSignal } = {},
526
+ ): Promise<{ content: Array<{ type: "text"; text: string }>; details: ApplyPatchDetails }> {
527
+ const operations = parseApplyPatch(patch);
528
+ const fs = options.fs ?? defaultFileSystem;
529
+ const root = await fs.realpath(cwd);
530
+ const paths = operations.flatMap((operation) => {
531
+ const resolved = operationPaths(root, operation);
532
+ return resolved.destination ? [resolved.source, resolved.destination] : [resolved.source];
533
+ });
534
+
535
+ const queuePaths = await Promise.all(paths.map((path) => validateProjectPath(root, path, fs)));
536
+ return withMutationQueues(queuePaths, async () => {
537
+ if (options.signal?.aborted) throw new Error("Operation aborted");
538
+ const prepared = await prepareChanges(root, operations, fs);
539
+ if (options.signal?.aborted) throw new Error("Operation aborted");
540
+ await commitAtomically(root, prepared.outputs, prepared.touched, fs);
541
+
542
+ const files = operations.map((operation) => operation.type === "update" && operation.moveTo
543
+ ? operation.moveTo
544
+ : operation.path);
545
+ const details: ApplyPatchDetails = {
546
+ patch: detailsPatch(prepared.changes),
547
+ files,
548
+ operations: operations.map((operation) => ({
549
+ type: operation.type,
550
+ path: operation.path,
551
+ ...(operation.type === "update" && operation.moveTo ? { moveTo: operation.moveTo } : {}),
552
+ })),
553
+ };
554
+ return {
555
+ content: [{
556
+ type: "text",
557
+ text: `Applied patch to ${files.length} file${files.length === 1 ? "" : "s"}: ${files.join(", ")}`,
558
+ }],
559
+ details,
560
+ };
561
+ });
562
+ }
563
+
564
+ export const applyPatchExtension: InlineExtension = {
565
+ name: "pum-apply-patch",
566
+ factory(pi) {
567
+ pi.registerTool({
568
+ name: APPLY_PATCH_TOOL_NAME,
569
+ label: "Apply Patch",
570
+ description: "Apply an OpenAI Codex patch atomically inside the project. Supports Add File, Update File, Delete File, Move to, multiple files, and multiple hunks.",
571
+ promptSnippet: "Apply a multi-file OpenAI Codex patch atomically",
572
+ promptGuidelines: [
573
+ "Use apply_patch for one atomic patch that changes one or more files.",
574
+ "Start with *** Begin Patch and finish with *** End Patch.",
575
+ "Use only project-relative paths in Add File, Update File, Delete File, and Move to headers.",
576
+ ],
577
+ parameters: Type.Object({
578
+ patch: Type.String({ description: "Complete OpenAI Codex patch text, including the Begin Patch and End Patch markers" }),
579
+ }),
580
+ execute: async (_id, params, signal, _update, ctx) => applyPatch(ctx.cwd, params.patch, { signal }),
581
+ });
582
+ },
583
+ };
@@ -0,0 +1,14 @@
1
+ /** The second Escape press must arrive before this interval expires. */
2
+ export const CANCEL_WINDOW_MS = 2_000;
3
+
4
+ /** Return true only for a timely second press on the same selected agent. */
5
+ export function confirmsCancellation(
6
+ armedAt: number | null,
7
+ armedTarget: string | null,
8
+ selectedTarget: string,
9
+ now: number,
10
+ ): boolean {
11
+ if (armedAt === null || armedTarget !== selectedTarget) return false;
12
+ const elapsed = now - armedAt;
13
+ return elapsed >= 0 && elapsed < CANCEL_WINDOW_MS;
14
+ }