@oh-my-pi/pi-coding-agent 6.2.0 → 6.7.67

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 (93) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/docs/sdk.md +1 -1
  3. package/package.json +5 -5
  4. package/scripts/generate-template.ts +6 -6
  5. package/src/cli/args.ts +3 -0
  6. package/src/core/agent-session.ts +39 -0
  7. package/src/core/bash-executor.ts +3 -3
  8. package/src/core/cursor/exec-bridge.ts +95 -88
  9. package/src/core/custom-commands/bundled/review/index.ts +142 -145
  10. package/src/core/custom-commands/bundled/wt/index.ts +68 -66
  11. package/src/core/custom-commands/loader.ts +4 -6
  12. package/src/core/custom-tools/index.ts +2 -2
  13. package/src/core/custom-tools/loader.ts +66 -61
  14. package/src/core/custom-tools/types.ts +4 -4
  15. package/src/core/custom-tools/wrapper.ts +61 -25
  16. package/src/core/event-bus.ts +19 -47
  17. package/src/core/extensions/index.ts +8 -4
  18. package/src/core/extensions/loader.ts +160 -120
  19. package/src/core/extensions/types.ts +4 -4
  20. package/src/core/extensions/wrapper.ts +149 -100
  21. package/src/core/hooks/index.ts +1 -1
  22. package/src/core/hooks/tool-wrapper.ts +96 -70
  23. package/src/core/hooks/types.ts +1 -2
  24. package/src/core/index.ts +1 -0
  25. package/src/core/mcp/index.ts +6 -2
  26. package/src/core/mcp/json-rpc.ts +88 -0
  27. package/src/core/mcp/loader.ts +22 -4
  28. package/src/core/mcp/manager.ts +202 -48
  29. package/src/core/mcp/tool-bridge.ts +143 -55
  30. package/src/core/mcp/tool-cache.ts +122 -0
  31. package/src/core/python-executor.ts +3 -9
  32. package/src/core/sdk.ts +33 -32
  33. package/src/core/session-manager.ts +30 -0
  34. package/src/core/settings-manager.ts +54 -1
  35. package/src/core/ssh/ssh-executor.ts +6 -84
  36. package/src/core/streaming-output.ts +107 -53
  37. package/src/core/tools/ask.ts +92 -93
  38. package/src/core/tools/bash.ts +103 -94
  39. package/src/core/tools/calculator.ts +41 -26
  40. package/src/core/tools/complete.ts +76 -66
  41. package/src/core/tools/context.ts +22 -24
  42. package/src/core/tools/exa/index.ts +1 -1
  43. package/src/core/tools/exa/mcp-client.ts +56 -101
  44. package/src/core/tools/find.ts +250 -253
  45. package/src/core/tools/git.ts +39 -33
  46. package/src/core/tools/grep.ts +440 -427
  47. package/src/core/tools/index.ts +63 -61
  48. package/src/core/tools/ls.ts +119 -114
  49. package/src/core/tools/lsp/clients/biome-client.ts +5 -7
  50. package/src/core/tools/lsp/clients/index.ts +4 -4
  51. package/src/core/tools/lsp/clients/lsp-linter-client.ts +5 -7
  52. package/src/core/tools/lsp/config.ts +2 -2
  53. package/src/core/tools/lsp/index.ts +604 -578
  54. package/src/core/tools/notebook.ts +121 -119
  55. package/src/core/tools/output.ts +163 -147
  56. package/src/core/tools/patch/applicator.ts +1100 -0
  57. package/src/core/tools/patch/diff.ts +362 -0
  58. package/src/core/tools/patch/fuzzy.ts +647 -0
  59. package/src/core/tools/patch/index.ts +430 -0
  60. package/src/core/tools/patch/normalize.ts +220 -0
  61. package/src/core/tools/patch/normative.ts +73 -0
  62. package/src/core/tools/patch/parser.ts +528 -0
  63. package/src/core/tools/patch/shared.ts +257 -0
  64. package/src/core/tools/patch/types.ts +244 -0
  65. package/src/core/tools/python.ts +139 -136
  66. package/src/core/tools/read.ts +239 -216
  67. package/src/core/tools/render-utils.ts +196 -77
  68. package/src/core/tools/renderers.ts +6 -2
  69. package/src/core/tools/ssh.ts +99 -80
  70. package/src/core/tools/task/executor.ts +11 -7
  71. package/src/core/tools/task/index.ts +352 -343
  72. package/src/core/tools/task/worker.ts +13 -23
  73. package/src/core/tools/todo-write.ts +74 -59
  74. package/src/core/tools/web-fetch.ts +54 -47
  75. package/src/core/tools/web-search/index.ts +27 -16
  76. package/src/core/tools/write.ts +108 -47
  77. package/src/core/ttsr.ts +106 -152
  78. package/src/core/voice.ts +49 -39
  79. package/src/index.ts +16 -12
  80. package/src/lib/worktree/index.ts +1 -9
  81. package/src/modes/interactive/components/diff.ts +15 -8
  82. package/src/modes/interactive/components/settings-defs.ts +42 -0
  83. package/src/modes/interactive/components/tool-execution.ts +46 -8
  84. package/src/modes/interactive/controllers/event-controller.ts +6 -19
  85. package/src/modes/interactive/controllers/input-controller.ts +1 -1
  86. package/src/modes/interactive/utils/ui-helpers.ts +5 -1
  87. package/src/modes/rpc/rpc-mode.ts +99 -81
  88. package/src/prompts/tools/patch.md +76 -0
  89. package/src/prompts/tools/read.md +1 -1
  90. package/src/prompts/tools/{edit.md → replace.md} +1 -0
  91. package/src/utils/shell.ts +0 -40
  92. package/src/core/tools/edit-diff.ts +0 -574
  93. package/src/core/tools/edit.ts +0 -345
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Normalize applied patch output into a canonical edit tool payload.
3
+ */
4
+
5
+ import { generateUnifiedDiffString } from "./diff";
6
+ import { normalizeToLF, stripBom } from "./normalize";
7
+ import { parseHunks } from "./parser";
8
+ import type { PatchInput } from "./types";
9
+
10
+ export interface NormativePatchOptions {
11
+ path: string;
12
+ rename?: string;
13
+ oldContent: string;
14
+ newContent: string;
15
+ contextLines?: number;
16
+ anchor?: string | string[];
17
+ }
18
+
19
+ /** Normative patch input is the MongoDB-style update variant */
20
+
21
+ function applyAnchors(diff: string, anchors: Array<string | undefined> | undefined): string {
22
+ if (!anchors || anchors.length === 0) {
23
+ return diff;
24
+ }
25
+ const lines = diff.split("\n");
26
+ let anchorIndex = 0;
27
+ for (let i = 0; i < lines.length; i++) {
28
+ if (!lines[i].startsWith("@@")) continue;
29
+ const anchor = anchors[anchorIndex];
30
+ if (anchor !== undefined) {
31
+ lines[i] = anchor.trim().length === 0 ? "@@" : `@@ ${anchor}`;
32
+ }
33
+ anchorIndex++;
34
+ }
35
+ return lines.join("\n");
36
+ }
37
+
38
+ function deriveAnchors(diff: string): Array<string | undefined> {
39
+ const hunks = parseHunks(diff);
40
+ return hunks.map((hunk) => {
41
+ if (hunk.oldLines.length === 0 || hunk.newLines.length === 0) {
42
+ return undefined;
43
+ }
44
+ const newLines = new Set(hunk.newLines);
45
+ for (const line of hunk.oldLines) {
46
+ const trimmed = line.trim();
47
+ if (trimmed.length === 0) continue;
48
+ if (!/[A-Za-z0-9_]/.test(trimmed)) continue;
49
+ if (newLines.has(line)) {
50
+ return trimmed;
51
+ }
52
+ }
53
+ return undefined;
54
+ });
55
+ }
56
+
57
+ export function buildNormativeUpdateInput(options: NormativePatchOptions): PatchInput {
58
+ const normalizedOld = normalizeToLF(stripBom(options.oldContent).text);
59
+ const normalizedNew = normalizeToLF(stripBom(options.newContent).text);
60
+ const diffResult = generateUnifiedDiffString(normalizedOld, normalizedNew, options.contextLines ?? 3);
61
+ let anchors: Array<string | undefined> | undefined =
62
+ typeof options.anchor === "string" ? [options.anchor] : options.anchor;
63
+ if (!anchors) {
64
+ anchors = deriveAnchors(diffResult.diff);
65
+ }
66
+ const diff = applyAnchors(diffResult.diff, anchors);
67
+ return {
68
+ path: options.path,
69
+ op: "update",
70
+ rename: options.rename,
71
+ diff,
72
+ };
73
+ }
@@ -0,0 +1,528 @@
1
+ /**
2
+ * Diff/patch parsing for the edit tool.
3
+ *
4
+ * Supports multiple input formats:
5
+ * - Simple +/- diffs
6
+ * - Unified diff format (@@ -X,Y +A,B @@)
7
+ * - Codex-style wrapped patches (*** Begin Patch / *** End Patch)
8
+ */
9
+
10
+ import type { DiffHunk } from "./types";
11
+ import { ApplyPatchError, ParseError } from "./types";
12
+
13
+ // ═══════════════════════════════════════════════════════════════════════════
14
+ // Constants
15
+ // ═══════════════════════════════════════════════════════════════════════════
16
+
17
+ const EOF_MARKER = "*** End of File";
18
+ const CHANGE_CONTEXT_MARKER = "@@ ";
19
+ const EMPTY_CHANGE_CONTEXT_MARKER = "@@";
20
+
21
+ /** Regex to match unified diff hunk headers: @@ -OLD,COUNT +NEW,COUNT @@ optional-context */
22
+ const UNIFIED_HUNK_HEADER_REGEX = /^@@\s*-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s*@@(?:\s*(.*))?$/;
23
+
24
+ /** Regex to match @@ line/lines N or N-M pattern (model-generated line hints) */
25
+ const LINE_HINT_REGEX = /^lines?\s+(\d+)(?:\s*-\s*(\d+))?(?:\s*@@)?$/i;
26
+ const TOP_OF_FILE_REGEX = /^(top|start|beginning)\s+of\s+file$/i;
27
+
28
+ /**
29
+ * Check if a line is a diff content line (context, addition, or removal).
30
+ * These should never be treated as metadata even if their content looks like it.
31
+ * Note: `--- ` and `+++ ` are metadata headers, not content lines.
32
+ */
33
+ function isDiffContentLine(line: string): boolean {
34
+ const firstChar = line[0];
35
+ if (firstChar === " ") return true;
36
+ if (firstChar === "+") {
37
+ // `+++ ` is metadata, single `+` followed by content is addition
38
+ return !line.startsWith("+++ ");
39
+ }
40
+ if (firstChar === "-") {
41
+ // `--- ` is metadata, single `-` followed by content is removal
42
+ return !line.startsWith("--- ");
43
+ }
44
+ return false;
45
+ }
46
+
47
+ // ═══════════════════════════════════════════════════════════════════════════
48
+ // Normalization
49
+ // ═══════════════════════════════════════════════════════════════════════════
50
+
51
+ /**
52
+ * Normalize a diff by stripping various wrapper formats and metadata.
53
+ *
54
+ * Handles:
55
+ * - `*** Begin Patch` / `*** End Patch` markers (partial or complete)
56
+ * - Codex file markers: `*** Update File:`, `*** Add File:`, `*** Delete File:`, `*** End of File`
57
+ * - Unified diff metadata: `diff --git`, `index`, `---`, `+++`, mode changes, rename markers
58
+ */
59
+ export function normalizeDiff(diff: string): string {
60
+ let lines = diff.split("\n");
61
+
62
+ // Strip trailing truly empty lines (not diff content lines like " " which represent blank context)
63
+ while (lines.length > 0) {
64
+ const lastLine = lines[lines.length - 1];
65
+ // Only strip if line is completely empty (no characters) OR
66
+ // if it's whitespace-only but NOT a diff content line (space prefix = context line)
67
+ if (lastLine === "" || (lastLine?.trim() === "" && !isDiffContentLine(lastLine ?? ""))) {
68
+ lines = lines.slice(0, -1);
69
+ } else {
70
+ break;
71
+ }
72
+ }
73
+
74
+ // Layer 1: Strip *** Begin Patch / *** End Patch (may have only one or both)
75
+ if (lines[0]?.trim().startsWith("*** Begin Patch")) {
76
+ lines = lines.slice(1);
77
+ }
78
+ // Also strip bare *** at the beginning (model hallucination)
79
+ if (lines[0]?.trim() === "***") {
80
+ lines = lines.slice(1);
81
+ }
82
+ if (lines.length > 0 && lines[lines.length - 1]?.trim().startsWith("*** End Patch")) {
83
+ lines = lines.slice(0, -1);
84
+ }
85
+ // Also strip bare *** terminator (model hallucination)
86
+ if (lines.length > 0 && lines[lines.length - 1]?.trim() === "***") {
87
+ lines = lines.slice(0, -1);
88
+ }
89
+
90
+ // Layer 2: Strip Codex-style file operation markers and unified diff metadata
91
+ // NOTE: Do NOT strip "*** End of File" - that's a valid marker within hunks, not a wrapper
92
+ // IMPORTANT: Only strip actual metadata lines, NOT diff content lines (starting with space, +, or -)
93
+ lines = lines.filter((line) => {
94
+ // Preserve diff content lines even if their content looks like metadata
95
+ // Note: `--- ` and `+++ ` are metadata, not content lines
96
+ if (isDiffContentLine(line)) {
97
+ return true;
98
+ }
99
+
100
+ const trimmed = line.trim();
101
+
102
+ // Codex file operation markers (these wrap multiple file changes)
103
+ if (trimmed.startsWith("*** Update File:")) return false;
104
+ if (trimmed.startsWith("*** Add File:")) return false;
105
+ if (trimmed.startsWith("*** Delete File:")) return false;
106
+
107
+ // Unified diff metadata
108
+ if (trimmed.startsWith("diff --git ")) return false;
109
+ if (trimmed.startsWith("index ")) return false;
110
+ if (trimmed.startsWith("--- ")) return false;
111
+ if (trimmed.startsWith("+++ ")) return false;
112
+ if (trimmed.startsWith("new file mode ")) return false;
113
+ if (trimmed.startsWith("deleted file mode ")) return false;
114
+ if (trimmed.startsWith("rename from ")) return false;
115
+ if (trimmed.startsWith("rename to ")) return false;
116
+ if (trimmed.startsWith("similarity index ")) return false;
117
+ if (trimmed.startsWith("dissimilarity index ")) return false;
118
+ if (trimmed.startsWith("old mode ")) return false;
119
+ if (trimmed.startsWith("new mode ")) return false;
120
+
121
+ return true;
122
+ });
123
+
124
+ return lines.join("\n");
125
+ }
126
+
127
+ /**
128
+ * Strip `+ ` prefix from file creation content if all non-empty lines have it.
129
+ * This handles diffs where file content is formatted as additions.
130
+ */
131
+ export function normalizeCreateContent(content: string): string {
132
+ const lines = content.split("\n");
133
+ const nonEmptyLines = lines.filter((l) => l.length > 0);
134
+
135
+ // Check if all non-empty lines start with "+ " or "+"
136
+ if (nonEmptyLines.length > 0 && nonEmptyLines.every((l) => l.startsWith("+ ") || l.startsWith("+"))) {
137
+ return lines
138
+ .map((l) => {
139
+ if (l.startsWith("+ ")) return l.slice(2);
140
+ if (l.startsWith("+")) return l.slice(1);
141
+ return l;
142
+ })
143
+ .join("\n");
144
+ }
145
+
146
+ return content;
147
+ }
148
+
149
+ // ═══════════════════════════════════════════════════════════════════════════
150
+ // Header Parsing
151
+ // ═══════════════════════════════════════════════════════════════════════════
152
+
153
+ interface UnifiedHunkHeader {
154
+ oldStartLine: number;
155
+ oldLineCount: number;
156
+ newStartLine: number;
157
+ newLineCount: number;
158
+ changeContext?: string;
159
+ }
160
+
161
+ function parseUnifiedHunkHeader(line: string): UnifiedHunkHeader | undefined {
162
+ const match = line.match(UNIFIED_HUNK_HEADER_REGEX);
163
+ if (!match) return undefined;
164
+
165
+ const oldStartLine = Number(match[1]);
166
+ const oldLineCount = match[2] ? Number(match[2]) : 1;
167
+ const newStartLine = Number(match[3]);
168
+ const newLineCount = match[4] ? Number(match[4]) : 1;
169
+ const changeContext = match[5]?.trim();
170
+
171
+ return {
172
+ oldStartLine,
173
+ oldLineCount,
174
+ newStartLine,
175
+ newLineCount,
176
+ changeContext: changeContext && changeContext.length > 0 ? changeContext : undefined,
177
+ };
178
+ }
179
+
180
+ function isUnifiedDiffMetadataLine(line: string): boolean {
181
+ return (
182
+ line.startsWith("diff --git ") ||
183
+ line.startsWith("index ") ||
184
+ line.startsWith("--- ") ||
185
+ line.startsWith("+++ ") ||
186
+ line.startsWith("new file mode ") ||
187
+ line.startsWith("deleted file mode ") ||
188
+ line.startsWith("rename from ") ||
189
+ line.startsWith("rename to ") ||
190
+ line.startsWith("similarity index ") ||
191
+ line.startsWith("dissimilarity index ") ||
192
+ line.startsWith("old mode ") ||
193
+ line.startsWith("new mode ")
194
+ );
195
+ }
196
+
197
+ // ═══════════════════════════════════════════════════════════════════════════
198
+ // Hunk Parsing
199
+ // ═══════════════════════════════════════════════════════════════════════════
200
+
201
+ interface ParseHunkResult {
202
+ hunk: DiffHunk;
203
+ linesConsumed: number;
204
+ }
205
+
206
+ /**
207
+ * Parse a single hunk from lines starting at the current position.
208
+ *
209
+ * Handles several context formats:
210
+ * - Empty: `@@` (no context, match from current position)
211
+ * - Unified: `@@ -10,3 +10,3 @@` (line numbers as hints)
212
+ * - Context: `@@ function foo` (search for context line)
213
+ * - Line hint: `@@ line 125` (use line 125 as starting position)
214
+ * - Nested: `@@ class Foo\n@@ method` (hierarchical context search)
215
+ */
216
+ function parseOneHunk(lines: string[], lineNumber: number, allowMissingContext: boolean): ParseHunkResult {
217
+ if (lines.length === 0) {
218
+ throw new ParseError("Diff does not contain any lines", lineNumber);
219
+ }
220
+
221
+ const changeContexts: string[] = [];
222
+ let oldStartLine: number | undefined;
223
+ let newStartLine: number | undefined;
224
+ let startIndex: number;
225
+
226
+ const headerLine = lines[0];
227
+ const headerTrimmed = headerLine.trimEnd();
228
+ const isHeaderLine = headerLine.startsWith("@@");
229
+ const unifiedHeader = isHeaderLine ? parseUnifiedHunkHeader(headerTrimmed) : undefined;
230
+ const isEmptyContextMarker = /^@@\s*@@$/.test(headerTrimmed);
231
+
232
+ // Check for context marker
233
+ if (isHeaderLine && (headerTrimmed === EMPTY_CHANGE_CONTEXT_MARKER || isEmptyContextMarker)) {
234
+ startIndex = 1;
235
+ } else if (unifiedHeader) {
236
+ if (unifiedHeader.oldStartLine < 1 || unifiedHeader.newStartLine < 1) {
237
+ throw new ParseError("Line numbers in @@ header must be >= 1", lineNumber);
238
+ }
239
+ if (unifiedHeader.changeContext) {
240
+ changeContexts.push(unifiedHeader.changeContext);
241
+ }
242
+ oldStartLine = unifiedHeader.oldStartLine;
243
+ newStartLine = unifiedHeader.newStartLine;
244
+ startIndex = 1;
245
+ } else if (isHeaderLine && headerTrimmed.startsWith(CHANGE_CONTEXT_MARKER)) {
246
+ const contextValue = headerTrimmed.slice(CHANGE_CONTEXT_MARKER.length);
247
+ const trimmedContextValue = contextValue.trim();
248
+ const normalizedContextValue = trimmedContextValue.replace(/^@@\s*/u, "");
249
+
250
+ const lineHintMatch = normalizedContextValue.match(LINE_HINT_REGEX);
251
+ if (lineHintMatch) {
252
+ oldStartLine = Number(lineHintMatch[1]);
253
+ newStartLine = oldStartLine;
254
+ if (oldStartLine < 1) {
255
+ throw new ParseError("Line hint must be >= 1", lineNumber);
256
+ }
257
+ } else if (TOP_OF_FILE_REGEX.test(normalizedContextValue)) {
258
+ oldStartLine = 1;
259
+ newStartLine = 1;
260
+ } else if (trimmedContextValue.length > 0) {
261
+ changeContexts.push(contextValue);
262
+ }
263
+ startIndex = 1;
264
+ } else if (isHeaderLine) {
265
+ const contextValue = headerTrimmed.slice(2).trim();
266
+ if (contextValue.length > 0) {
267
+ changeContexts.push(contextValue);
268
+ }
269
+ startIndex = 1;
270
+ } else {
271
+ if (!allowMissingContext) {
272
+ throw new ParseError(`Expected hunk to start with @@ context marker, got: '${lines[0]}'`, lineNumber);
273
+ }
274
+ startIndex = 0;
275
+ }
276
+
277
+ if (oldStartLine !== undefined && oldStartLine < 1) {
278
+ throw new ParseError(`Line numbers must be >= 1 (got ${oldStartLine})`, lineNumber);
279
+ }
280
+ if (newStartLine !== undefined && newStartLine < 1) {
281
+ throw new ParseError(`Line numbers must be >= 1 (got ${newStartLine})`, lineNumber);
282
+ }
283
+
284
+ // Check for nested @@ anchors on subsequent lines
285
+ // Format: @@ class Foo
286
+ // @@ method
287
+ while (startIndex < lines.length) {
288
+ const nextLine = lines[startIndex];
289
+ if (!nextLine.startsWith("@@")) {
290
+ break;
291
+ }
292
+ const trimmed = nextLine.trimEnd();
293
+
294
+ // Check if it's another @@ line (nested anchor)
295
+ if (trimmed.startsWith(CHANGE_CONTEXT_MARKER)) {
296
+ const nestedContext = trimmed.slice(CHANGE_CONTEXT_MARKER.length);
297
+ if (nestedContext.trim().length > 0) {
298
+ changeContexts.push(nestedContext);
299
+ }
300
+ startIndex++;
301
+ } else if (trimmed === EMPTY_CHANGE_CONTEXT_MARKER) {
302
+ // Empty @@ as separator - skip it
303
+ startIndex++;
304
+ } else {
305
+ // Not an @@ line, stop accumulating
306
+ break;
307
+ }
308
+ }
309
+
310
+ if (startIndex >= lines.length) {
311
+ throw new ParseError("Hunk does not contain any lines", lineNumber + 1);
312
+ }
313
+
314
+ // Combine contexts: if multiple, join with newline for hierarchical matching
315
+ const changeContext = changeContexts.length > 0 ? changeContexts.join("\n") : undefined;
316
+
317
+ const hunk: DiffHunk = {
318
+ changeContext,
319
+ oldStartLine,
320
+ newStartLine,
321
+ hasContextLines: false,
322
+ oldLines: [],
323
+ newLines: [],
324
+ isEndOfFile: false,
325
+ };
326
+
327
+ let parsedLines = 0;
328
+
329
+ for (let i = startIndex; i < lines.length; i++) {
330
+ const line = lines[i];
331
+ const trimmed = line.trim();
332
+
333
+ if (!isDiffContentLine(line) && line.trimEnd() === EOF_MARKER && line.startsWith(EOF_MARKER)) {
334
+ if (parsedLines === 0) {
335
+ throw new ParseError("Hunk does not contain any lines", lineNumber + 1);
336
+ }
337
+ hunk.isEndOfFile = true;
338
+ parsedLines++;
339
+ break;
340
+ }
341
+
342
+ if (trimmed === "..." || trimmed === "…") {
343
+ hunk.hasContextLines = true;
344
+ parsedLines++;
345
+ continue;
346
+ }
347
+
348
+ const firstChar = line[0];
349
+
350
+ if (firstChar === undefined || firstChar === "") {
351
+ // Empty line - treat as context
352
+ hunk.hasContextLines = true;
353
+ hunk.oldLines.push("");
354
+ hunk.newLines.push("");
355
+ } else if (firstChar === " ") {
356
+ // Context line
357
+ hunk.hasContextLines = true;
358
+ hunk.oldLines.push(line.slice(1));
359
+ hunk.newLines.push(line.slice(1));
360
+ } else if (firstChar === "+") {
361
+ // Added line
362
+ hunk.newLines.push(line.slice(1));
363
+ } else if (firstChar === "-") {
364
+ // Removed line
365
+ hunk.oldLines.push(line.slice(1));
366
+ } else if (!line.startsWith("@@")) {
367
+ // Implicit context line (model omitted leading space)
368
+ hunk.hasContextLines = true;
369
+ hunk.oldLines.push(line);
370
+ hunk.newLines.push(line);
371
+ } else {
372
+ if (parsedLines === 0) {
373
+ throw new ParseError(
374
+ `Unexpected line in hunk: '${line}'. Lines must start with ' ' (context), '+' (add), or '-' (remove)`,
375
+ lineNumber + 1,
376
+ );
377
+ }
378
+ // Assume start of next hunk
379
+ break;
380
+ }
381
+ parsedLines++;
382
+ }
383
+
384
+ if (parsedLines === 0) {
385
+ throw new ParseError("Hunk does not contain any lines", lineNumber + startIndex);
386
+ }
387
+
388
+ stripLineNumberPrefixes(hunk);
389
+ return { hunk, linesConsumed: parsedLines + startIndex };
390
+ }
391
+
392
+ function stripLineNumberPrefixes(hunk: DiffHunk): void {
393
+ const allLines = [...hunk.oldLines, ...hunk.newLines].filter((line) => line.trim().length > 0);
394
+ if (allLines.length < 2) return;
395
+
396
+ const numberMatches = allLines
397
+ .map((line) => line.match(/^\s*(\d{1,6})\s+(.+)$/u))
398
+ .filter((match): match is RegExpMatchArray => match !== null);
399
+
400
+ if (numberMatches.length < Math.max(2, Math.ceil(allLines.length * 0.6))) {
401
+ return;
402
+ }
403
+
404
+ const numbers = numberMatches.map((match) => Number(match[1]));
405
+ let sequential = 0;
406
+ for (let i = 1; i < numbers.length; i++) {
407
+ if (numbers[i] === numbers[i - 1] + 1) {
408
+ sequential++;
409
+ }
410
+ }
411
+
412
+ if (numbers.length >= 3 && sequential < Math.max(1, numbers.length - 2)) {
413
+ return;
414
+ }
415
+
416
+ const strip = (line: string): string => {
417
+ const match = line.match(/^\s*\d{1,6}\s+(.+)$/u);
418
+ return match ? match[1] : line;
419
+ };
420
+
421
+ hunk.oldLines = hunk.oldLines.map(strip);
422
+ hunk.newLines = hunk.newLines.map(strip);
423
+ }
424
+
425
+ /** Multi-file patch markers that indicate this is not a single-file patch */
426
+ const MULTI_FILE_MARKERS = ["*** Update File:", "*** Add File:", "*** Delete File:", "diff --git "];
427
+
428
+ /**
429
+ * Count multi-file markers in a diff.
430
+ * Returns the count of file-level markers found.
431
+ * Only counts lines that are actual metadata (not diff content lines).
432
+ */
433
+ function countMultiFileMarkers(diff: string): number {
434
+ const counts = new Map<string, number>();
435
+ const paths = new Set<string>();
436
+ const lines = diff.split("\n");
437
+ for (const line of lines) {
438
+ if (isDiffContentLine(line)) {
439
+ continue;
440
+ }
441
+ const trimmed = line.trim();
442
+ for (const marker of MULTI_FILE_MARKERS) {
443
+ if (trimmed.startsWith(marker)) {
444
+ const path = extractMarkerPath(trimmed);
445
+ if (path) {
446
+ paths.add(path);
447
+ }
448
+ counts.set(marker, (counts.get(marker) ?? 0) + 1);
449
+ break;
450
+ }
451
+ }
452
+ }
453
+ if (paths.size > 0) {
454
+ return paths.size;
455
+ }
456
+ let maxCount = 0;
457
+ for (const count of counts.values()) {
458
+ if (count > maxCount) {
459
+ maxCount = count;
460
+ }
461
+ }
462
+ return maxCount;
463
+ }
464
+
465
+ function extractMarkerPath(line: string): string | undefined {
466
+ if (line.startsWith("diff --git ")) {
467
+ const parts = line.split(/\s+/);
468
+ const candidate = parts[3] ?? parts[2];
469
+ if (!candidate) return undefined;
470
+ return candidate.replace(/^(a|b)\//, "");
471
+ }
472
+ if (line.startsWith("*** Update File:")) {
473
+ return line.slice("*** Update File:".length).trim();
474
+ }
475
+ if (line.startsWith("*** Add File:")) {
476
+ return line.slice("*** Add File:".length).trim();
477
+ }
478
+ if (line.startsWith("*** Delete File:")) {
479
+ return line.slice("*** Delete File:".length).trim();
480
+ }
481
+ return undefined;
482
+ }
483
+
484
+ /**
485
+ * Parse all diff hunks from a diff string.
486
+ */
487
+ export function parseHunks(diff: string): DiffHunk[] {
488
+ const multiFileCount = countMultiFileMarkers(diff);
489
+ if (multiFileCount > 1) {
490
+ throw new ApplyPatchError(
491
+ `Diff contains ${multiFileCount} file markers. Single-file patches cannot contain multi-file markers.`,
492
+ );
493
+ }
494
+
495
+ const normalizedDiff = normalizeDiff(diff);
496
+ const lines = normalizedDiff.split("\n");
497
+ const hunks: DiffHunk[] = [];
498
+ let i = 0;
499
+
500
+ while (i < lines.length) {
501
+ const line = lines[i];
502
+ const trimmed = line.trim();
503
+
504
+ // Skip blank lines between hunks
505
+ if (trimmed === "") {
506
+ i++;
507
+ continue;
508
+ }
509
+
510
+ // Skip unified diff metadata lines, but only if they're not diff content lines
511
+ const firstChar = line[0];
512
+ const isDiffContent = firstChar === " " || firstChar === "+" || firstChar === "-";
513
+ if (!isDiffContent && isUnifiedDiffMetadataLine(trimmed)) {
514
+ i++;
515
+ continue;
516
+ }
517
+
518
+ if (trimmed.startsWith("@@") && lines.slice(i + 1).every((l) => l.trim() === "")) {
519
+ break;
520
+ }
521
+
522
+ const { hunk, linesConsumed } = parseOneHunk(lines.slice(i), i + 1, true);
523
+ hunks.push(hunk);
524
+ i += linesConsumed;
525
+ }
526
+
527
+ return hunks;
528
+ }