@oh-my-pi/pi-coding-agent 17.2.11 → 17.2.12

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 (152) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/{CHANGELOG-d9xenpn9.md → CHANGELOG-k9ghy5sn.md} +13 -0
  3. package/dist/cli.js +10795 -10791
  4. package/dist/types/advisor/runtime.d.ts +1 -1
  5. package/dist/types/config/custom-models.d.ts +31 -0
  6. package/dist/types/config/model-config-values.d.ts +19 -0
  7. package/dist/types/config/model-patch.d.ts +93 -0
  8. package/dist/types/config/model-provider-discovery.d.ts +51 -0
  9. package/dist/types/config/model-registry.d.ts +5 -52
  10. package/dist/types/config/settings.d.ts +5 -3
  11. package/dist/types/debug/raw-sse-buffer.d.ts +9 -0
  12. package/dist/types/discovery/agent-plugin-format.d.ts +6 -13
  13. package/dist/types/discovery/contained-path.d.ts +2 -3
  14. package/dist/types/eval/executor-base.d.ts +8 -2
  15. package/dist/types/eval/kernel-session-registry.d.ts +60 -0
  16. package/dist/types/export/share.d.ts +1 -1
  17. package/dist/types/lsp/diagnostics.d.ts +96 -0
  18. package/dist/types/lsp/index.d.ts +7 -128
  19. package/dist/types/lsp/servers.d.ts +72 -0
  20. package/dist/types/lsp/tool.d.ts +42 -0
  21. package/dist/types/lsp/workspace-diagnostics.d.ts +12 -0
  22. package/dist/types/lsp/writethrough.d.ts +33 -0
  23. package/dist/types/mcp/transports/header-policy.d.ts +2 -1
  24. package/dist/types/modes/components/agent-hub-projection.d.ts +2 -7
  25. package/dist/types/modes/components/agent-hub-renderer.d.ts +0 -7
  26. package/dist/types/modes/theme/color.d.ts +19 -0
  27. package/dist/types/modes/theme/loader.d.ts +19 -0
  28. package/dist/types/modes/theme/schema.d.ts +175 -0
  29. package/dist/types/modes/theme/symbols.d.ts +28 -0
  30. package/dist/types/modes/theme/theme-class.d.ts +244 -0
  31. package/dist/types/modes/theme/theme.d.ts +9 -280
  32. package/dist/types/modes/theme/tui-adapters.d.ts +13 -0
  33. package/dist/types/registry/agent-registry.d.ts +1 -3
  34. package/dist/types/secrets/index.d.ts +3 -1
  35. package/dist/types/secrets/message-transform.d.ts +40 -0
  36. package/dist/types/secrets/obfuscator.d.ts +0 -108
  37. package/dist/types/secrets/placeholder-scan.d.ts +95 -0
  38. package/dist/types/secrets/placeholder.d.ts +94 -0
  39. package/dist/types/secrets/replacement.d.ts +82 -0
  40. package/dist/types/session/agent-session-types.d.ts +6 -0
  41. package/dist/types/session/agent-session.d.ts +1 -1
  42. package/dist/types/session/session-handoff.d.ts +3 -3
  43. package/dist/types/session/session-manager.d.ts +32 -0
  44. package/dist/types/session/session-provider-boundary.d.ts +1 -1
  45. package/dist/types/session/turn-recovery.d.ts +2 -2
  46. package/dist/types/slash-commands/builtin-collaboration.d.ts +2 -0
  47. package/dist/types/slash-commands/builtin-completions.d.ts +36 -0
  48. package/dist/types/slash-commands/builtin-control.d.ts +2 -0
  49. package/dist/types/slash-commands/builtin-lifecycle.d.ts +3 -0
  50. package/dist/types/slash-commands/builtin-marketplace.d.ts +11 -0
  51. package/dist/types/slash-commands/builtin-modes.d.ts +5 -0
  52. package/dist/types/slash-commands/builtin-registry.d.ts +1 -1
  53. package/dist/types/slash-commands/builtin-session.d.ts +2 -0
  54. package/dist/types/tools/gh-common.d.ts +69 -0
  55. package/dist/types/tools/gh-pr-checkout.d.ts +84 -0
  56. package/dist/types/tools/gh-pr-diff.d.ts +102 -0
  57. package/dist/types/tools/gh-run-watch.d.ts +78 -0
  58. package/dist/types/tools/gh-search.d.ts +61 -0
  59. package/dist/types/tools/gh-types.d.ts +340 -0
  60. package/dist/types/tools/gh-view.d.ts +88 -0
  61. package/dist/types/tools/gh.d.ts +4 -201
  62. package/dist/types/tools/read-archive.d.ts +16 -0
  63. package/dist/types/tools/read-format.d.ts +104 -0
  64. package/dist/types/tools/read-path-resolution.d.ts +19 -0
  65. package/dist/types/tools/read-pdf-images.d.ts +12 -0
  66. package/dist/types/tools/read-renderer.d.ts +24 -0
  67. package/dist/types/tools/read-selector.d.ts +27 -0
  68. package/dist/types/tools/read-sqlite.d.ts +16 -0
  69. package/dist/types/tools/read-summary.d.ts +19 -0
  70. package/dist/types/tools/read.d.ts +1 -23
  71. package/dist/types/tools/shell-tokenize.d.ts +2 -1
  72. package/dist/types/utils/changelog.d.ts +0 -5
  73. package/package.json +13 -13
  74. package/src/advisor/runtime.ts +12 -7
  75. package/src/config/custom-models.ts +188 -0
  76. package/src/config/model-config-values.ts +128 -0
  77. package/src/config/model-patch.ts +252 -0
  78. package/src/config/model-provider-discovery.ts +132 -0
  79. package/src/config/model-registry.ts +64 -704
  80. package/src/config/settings.ts +7 -3
  81. package/src/debug/raw-sse-buffer.ts +20 -0
  82. package/src/discovery/agent-plugin-format.ts +7 -7
  83. package/src/discovery/contained-path.ts +2 -5
  84. package/src/edit/hashline/diff.ts +5 -1
  85. package/src/eval/executor-base.ts +39 -6
  86. package/src/eval/jl/executor.ts +82 -371
  87. package/src/eval/kernel-session-registry.ts +398 -0
  88. package/src/eval/py/executor.ts +53 -261
  89. package/src/eval/rb/executor.ts +36 -279
  90. package/src/export/share.ts +2 -1
  91. package/src/lsp/diagnostics.ts +516 -0
  92. package/src/lsp/index.ts +20 -2819
  93. package/src/lsp/servers.ts +296 -0
  94. package/src/lsp/tool.ts +1352 -0
  95. package/src/lsp/workspace-diagnostics.ts +170 -0
  96. package/src/lsp/writethrough.ts +561 -0
  97. package/src/mcp/transports/header-policy.ts +1 -1
  98. package/src/modes/components/agent-hub-projection.ts +2 -2
  99. package/src/modes/components/agent-hub-renderer.ts +3 -7
  100. package/src/modes/controllers/command-controller.ts +4 -1
  101. package/src/modes/theme/color.ts +133 -0
  102. package/src/modes/theme/loader.ts +178 -0
  103. package/src/modes/theme/schema.ts +263 -0
  104. package/src/modes/theme/symbols.ts +1000 -0
  105. package/src/modes/theme/theme-class.ts +611 -0
  106. package/src/modes/theme/theme.ts +27 -2453
  107. package/src/modes/theme/tui-adapters.ts +279 -0
  108. package/src/registry/agent-registry.ts +2 -2
  109. package/src/secrets/index.ts +6 -12
  110. package/src/secrets/message-transform.ts +287 -0
  111. package/src/secrets/obfuscator.ts +39 -1303
  112. package/src/secrets/placeholder-scan.ts +506 -0
  113. package/src/secrets/placeholder.ts +309 -0
  114. package/src/secrets/replacement.ts +216 -0
  115. package/src/session/agent-session-types.ts +6 -0
  116. package/src/session/agent-session.ts +113 -7
  117. package/src/session/indexed-session-storage.ts +7 -2
  118. package/src/session/session-advisors.ts +32 -34
  119. package/src/session/session-handoff.ts +39 -20
  120. package/src/session/session-manager.ts +67 -3
  121. package/src/session/session-provider-boundary.ts +3 -6
  122. package/src/session/turn-recovery.ts +21 -12
  123. package/src/slash-commands/builtin-collaboration.ts +504 -0
  124. package/src/slash-commands/builtin-completions.ts +291 -0
  125. package/src/slash-commands/builtin-control.ts +78 -0
  126. package/src/slash-commands/builtin-lifecycle.ts +506 -0
  127. package/src/slash-commands/builtin-marketplace.ts +567 -0
  128. package/src/slash-commands/builtin-modes.ts +518 -0
  129. package/src/slash-commands/builtin-registry.ts +21 -3000
  130. package/src/slash-commands/builtin-session.ts +592 -0
  131. package/src/task/executor.ts +17 -14
  132. package/src/tools/gh-common.ts +288 -0
  133. package/src/tools/gh-pr-checkout.ts +679 -0
  134. package/src/tools/gh-pr-diff.ts +473 -0
  135. package/src/tools/gh-run-watch.ts +1015 -0
  136. package/src/tools/gh-search.ts +500 -0
  137. package/src/tools/gh-types.ts +379 -0
  138. package/src/tools/gh-view.ts +612 -0
  139. package/src/tools/gh.ts +109 -3821
  140. package/src/tools/read-archive.ts +209 -0
  141. package/src/tools/read-format.ts +593 -0
  142. package/src/tools/read-path-resolution.ts +36 -0
  143. package/src/tools/read-pdf-images.ts +250 -0
  144. package/src/tools/read-renderer.ts +289 -0
  145. package/src/tools/read-selector.ts +84 -0
  146. package/src/tools/read-sqlite.ts +215 -0
  147. package/src/tools/read-summary.ts +199 -0
  148. package/src/tools/read.ts +77 -1820
  149. package/src/tools/shell-tokenize.ts +1 -1
  150. package/src/utils/changelog.ts +1 -1
  151. package/src/utils/title-generator.ts +3 -1
  152. package/src/web/search/providers/zai.ts +12 -3
@@ -0,0 +1,473 @@
1
+ import { logger } from "@oh-my-pi/pi-utils";
2
+ import type { Settings } from "../config/settings";
3
+ import * as git from "../utils/git";
4
+ import { appendRepoFlag } from "./gh-common";
5
+ import type { ViewLookupResult } from "./gh-view";
6
+ import { getOrFetchView, resolveGithubCacheAuthKey } from "./github-cache";
7
+ import { ToolError } from "./tool-errors";
8
+
9
+ export const PR_DIFF_FILES_PAGE_SIZE = 100;
10
+ export const PR_DIFF_FILES_MAX = 3000;
11
+
12
+ // ────────────────────────────────────────────────────────────────────────────
13
+ // PR diff fetcher
14
+ //
15
+ // Used by the `pr://<n>/diff[/…]` internal-URL family. Stores the verbatim
16
+ // `gh pr diff` text plus a parsed file index so the listing, full-diff, and
17
+ // per-file slice variants all share one cache row.
18
+ // ────────────────────────────────────────────────────────────────────────────
19
+
20
+ export interface PrDiffFile {
21
+ /** Display path. Prefers the post-image (`b/<path>`) when present. */
22
+ path: string;
23
+ additions: number;
24
+ deletions: number;
25
+ changeType: "modified" | "added" | "deleted" | "renamed" | "binary";
26
+ /** Pre-image path for renames/deletes; same as `path` otherwise. */
27
+ oldPath?: string;
28
+ /** Byte offset of the section's `diff --git` line in the unified diff. */
29
+ startOffset: number;
30
+ /** Byte offset of the next section (or end-of-text). */
31
+ endOffset: number;
32
+ }
33
+
34
+ export interface PrDiffPayload {
35
+ /** Full unified diff text as returned by `gh pr diff --color never`. */
36
+ unified: string;
37
+ files: PrDiffFile[];
38
+ }
39
+
40
+ export interface PrDiffLookupOptions {
41
+ cwd: string;
42
+ repo: string;
43
+ number: number;
44
+ signal?: AbortSignal;
45
+ settings?: Settings;
46
+ cacheAuthKey?: string | null;
47
+ }
48
+ /**
49
+ * Split `gh pr diff` output on `^diff --git ` boundaries and parse per-file
50
+ * metadata. The unified diff is preserved verbatim so callers can slice it by
51
+ * byte offsets without re-running gh.
52
+ */
53
+ export function parsePrUnifiedDiff(text: string): PrDiffPayload {
54
+ const files: PrDiffFile[] = [];
55
+ if (text.length === 0) {
56
+ return { unified: text, files };
57
+ }
58
+
59
+ // Walk match positions manually so we capture each section's byte range.
60
+ const sectionStarts: number[] = [];
61
+ const re = /^diff --git /gm;
62
+ let m: RegExpExecArray | null = re.exec(text);
63
+ while (m !== null) {
64
+ sectionStarts.push(m.index);
65
+ // Avoid zero-length match infinite loop (regex has fixed prefix, but
66
+ // be explicit).
67
+ if (re.lastIndex === m.index) re.lastIndex += 1;
68
+ m = re.exec(text);
69
+ }
70
+
71
+ for (let i = 0; i < sectionStarts.length; i += 1) {
72
+ const startOffset = sectionStarts[i] ?? 0;
73
+ const endOffset = sectionStarts[i + 1] ?? text.length;
74
+ const section = text.slice(startOffset, endOffset);
75
+ files.push(parsePrDiffSection(section, startOffset, endOffset));
76
+ }
77
+ return { unified: text, files };
78
+ }
79
+
80
+ export interface ParsedDiffHeaderToken {
81
+ value: string;
82
+ nextIndex: number;
83
+ }
84
+
85
+ export function skipDiffHeaderSpaces(text: string, index: number): number {
86
+ let i = index;
87
+ while (text.charAt(i) === " ") i += 1;
88
+ return i;
89
+ }
90
+
91
+ export function parseDiffQuotedEscape(text: string, slashIndex: number): ParsedDiffHeaderToken {
92
+ const next = text.charAt(slashIndex + 1);
93
+ if (next === "") return { value: "\\", nextIndex: slashIndex + 1 };
94
+
95
+ if (next >= "0" && next <= "7") {
96
+ let end = slashIndex + 1;
97
+ while (end < text.length && end < slashIndex + 4) {
98
+ const digit = text.charAt(end);
99
+ if (digit < "0" || digit > "7") break;
100
+ end += 1;
101
+ }
102
+ return {
103
+ value: String.fromCharCode(Number.parseInt(text.slice(slashIndex + 1, end), 8)),
104
+ nextIndex: end,
105
+ };
106
+ }
107
+
108
+ switch (next) {
109
+ case "a":
110
+ return { value: "\x07", nextIndex: slashIndex + 2 };
111
+ case "b":
112
+ return { value: "\b", nextIndex: slashIndex + 2 };
113
+ case "f":
114
+ return { value: "\f", nextIndex: slashIndex + 2 };
115
+ case "n":
116
+ return { value: "\n", nextIndex: slashIndex + 2 };
117
+ case "r":
118
+ return { value: "\r", nextIndex: slashIndex + 2 };
119
+ case "t":
120
+ return { value: "\t", nextIndex: slashIndex + 2 };
121
+ case "v":
122
+ return { value: "\v", nextIndex: slashIndex + 2 };
123
+ case "\\":
124
+ case '"':
125
+ return { value: next, nextIndex: slashIndex + 2 };
126
+ default:
127
+ return { value: next, nextIndex: slashIndex + 2 };
128
+ }
129
+ }
130
+
131
+ export function parseDiffQuotedToken(text: string, startIndex: number): ParsedDiffHeaderToken | undefined {
132
+ if (text.charAt(startIndex) !== '"') return undefined;
133
+ let value = "";
134
+ for (let i = startIndex + 1; i < text.length; i += 1) {
135
+ const ch = text.charAt(i);
136
+ if (ch === '"') return { value, nextIndex: i + 1 };
137
+ if (ch !== "\\") {
138
+ value += ch;
139
+ continue;
140
+ }
141
+ const escaped = parseDiffQuotedEscape(text, i);
142
+ value += escaped.value;
143
+ i = escaped.nextIndex - 1;
144
+ }
145
+ return undefined;
146
+ }
147
+
148
+ export function parseDiffHeaderToken(text: string, startIndex: number): ParsedDiffHeaderToken | undefined {
149
+ const start = skipDiffHeaderSpaces(text, startIndex);
150
+ if (start >= text.length) return undefined;
151
+ const quoted = parseDiffQuotedToken(text, start);
152
+ if (quoted) return quoted;
153
+ const end = text.indexOf(" ", start);
154
+ if (end === -1) return { value: text.slice(start), nextIndex: text.length };
155
+ return { value: text.slice(start, end), nextIndex: end };
156
+ }
157
+
158
+ export function stripPrDiffPathPrefix(value: string, prefix: "a/" | "b/"): string | undefined {
159
+ return value.startsWith(prefix) ? value.slice(prefix.length) : undefined;
160
+ }
161
+
162
+ export function parsePrDiffHeaderPaths(header: string): { oldPath?: string; newPath?: string } {
163
+ const trail = header.slice("diff --git ".length);
164
+ if (trail.startsWith('"')) {
165
+ const oldToken = parseDiffQuotedToken(trail, 0);
166
+ if (!oldToken) return {};
167
+ const newToken = parseDiffHeaderToken(trail, oldToken.nextIndex);
168
+ if (!newToken) return {};
169
+ return {
170
+ oldPath: stripPrDiffPathPrefix(oldToken.value, "a/"),
171
+ newPath: stripPrDiffPathPrefix(newToken.value, "b/"),
172
+ };
173
+ }
174
+
175
+ const bIdx = trail.indexOf(" b/");
176
+ if (trail.startsWith("a/") && bIdx > 0) {
177
+ return {
178
+ oldPath: trail.slice(2, bIdx),
179
+ newPath: trail.slice(bIdx + 3),
180
+ };
181
+ }
182
+ return {};
183
+ }
184
+
185
+ export function isPrDiffFileHeaderLine(line: string): boolean {
186
+ return (
187
+ line === "--- /dev/null" ||
188
+ line === "+++ /dev/null" ||
189
+ line.startsWith("--- a/") ||
190
+ line.startsWith("+++ b/") ||
191
+ line.startsWith('--- "a/') ||
192
+ line.startsWith('+++ "b/')
193
+ );
194
+ }
195
+
196
+ export function parsePrDiffSection(section: string, startOffset: number, endOffset: number): PrDiffFile {
197
+ const lines = section.split("\n");
198
+ const header = lines[0] ?? "";
199
+ const headerPaths = parsePrDiffHeaderPaths(header);
200
+ let oldPath = headerPaths.oldPath;
201
+ let newPath = headerPaths.newPath;
202
+
203
+ let changeType: PrDiffFile["changeType"] = "modified";
204
+ let isBinary = false;
205
+ let additions = 0;
206
+ let deletions = 0;
207
+
208
+ let inHunk = false;
209
+ for (let li = 1; li < lines.length; li += 1) {
210
+ const line = lines[li] ?? "";
211
+ if (line.startsWith("new file mode")) {
212
+ changeType = "added";
213
+ continue;
214
+ }
215
+ if (line.startsWith("deleted file mode")) {
216
+ changeType = "deleted";
217
+ continue;
218
+ }
219
+ if (line.startsWith("rename from ")) {
220
+ changeType = "renamed";
221
+ oldPath = line.slice("rename from ".length);
222
+ continue;
223
+ }
224
+ if (line.startsWith("rename to ")) {
225
+ newPath = line.slice("rename to ".length);
226
+ continue;
227
+ }
228
+ if (line.startsWith("Binary files ") && line.endsWith(" differ")) {
229
+ isBinary = true;
230
+ continue;
231
+ }
232
+ if (line.startsWith("@@ ")) {
233
+ inHunk = true;
234
+ continue;
235
+ }
236
+ if (!inHunk && isPrDiffFileHeaderLine(line)) continue;
237
+ if (line.startsWith("+")) {
238
+ additions += 1;
239
+ } else if (line.startsWith("-")) {
240
+ deletions += 1;
241
+ }
242
+ }
243
+
244
+ if (isBinary) {
245
+ if (changeType === "modified") changeType = "binary";
246
+ additions = 0;
247
+ deletions = 0;
248
+ }
249
+
250
+ const displayPath =
251
+ changeType === "deleted" ? (oldPath ?? newPath ?? "(unknown)") : (newPath ?? oldPath ?? "(unknown)");
252
+ const file: PrDiffFile = {
253
+ path: displayPath,
254
+ additions,
255
+ deletions,
256
+ changeType,
257
+ startOffset,
258
+ endOffset,
259
+ };
260
+ if (oldPath && oldPath !== displayPath) {
261
+ file.oldPath = oldPath;
262
+ }
263
+ return file;
264
+ }
265
+
266
+ /**
267
+ * A single entry from `GET /repos/{owner}/{repo}/pulls/{n}/files`. `patch` is
268
+ * absent for binary files and for individual file diffs GitHub deems too large
269
+ * to render.
270
+ */
271
+ export interface GhPrFileApi {
272
+ filename?: string;
273
+ previous_filename?: string;
274
+ status?: string;
275
+ additions?: number;
276
+ deletions?: number;
277
+ patch?: string;
278
+ }
279
+
280
+ export interface GhPrApi {
281
+ changed_files?: number;
282
+ }
283
+
284
+ /**
285
+ * GitHub rejects the aggregate PR diff endpoint with HTTP 406 once the diff
286
+ * exceeds 20,000 lines. Detect that specific failure so the caller can fall
287
+ * back to the per-file endpoint instead of aborting the whole review.
288
+ */
289
+ export function isPrDiffTooLargeError(err: unknown): boolean {
290
+ const message = err instanceof Error ? err.message : String(err);
291
+ return (
292
+ /\bHTTP 406\b/.test(message) ||
293
+ /exceeded the maximum number of lines/i.test(message) ||
294
+ /\btoo_large\b/.test(message)
295
+ );
296
+ }
297
+
298
+ export function formatSyntheticDiffPath(prefix: "a/" | "b/", path: string): string {
299
+ const prefixedPath = `${prefix}${path}`;
300
+ if (!/[\u0000-\u001F\s"\\]/.test(prefixedPath)) return prefixedPath;
301
+
302
+ let escaped = "";
303
+ for (const char of prefixedPath) {
304
+ switch (char) {
305
+ case "\\":
306
+ escaped += "\\\\";
307
+ break;
308
+ case '"':
309
+ escaped += '\\"';
310
+ break;
311
+ case "\n":
312
+ escaped += "\\n";
313
+ break;
314
+ case "\r":
315
+ escaped += "\\r";
316
+ break;
317
+ case "\t":
318
+ escaped += "\\t";
319
+ break;
320
+ default: {
321
+ const code = char.charCodeAt(0);
322
+ escaped += code < 32 ? `\\${code.toString(8).padStart(3, "0")}` : char;
323
+ }
324
+ }
325
+ }
326
+ return `"${escaped}"`;
327
+ }
328
+
329
+ /**
330
+ * Reconstruct a `diff --git` section from a single files-API entry. The API's
331
+ * `patch` field carries only the hunk body, so the `diff --git`/`---`/`+++`
332
+ * headers are synthesized to match `gh pr diff` output — this keeps
333
+ * {@link parsePrUnifiedDiff} and the review parser producing identical section
334
+ * boundaries and byte offsets. Files whose `patch` is omitted (binary or
335
+ * too-large) stay visible with an explicit marker rather than being dropped.
336
+ */
337
+ export function buildSyntheticDiffSection(file: GhPrFileApi): string | undefined {
338
+ const newPath = file.filename;
339
+ if (!newPath) return undefined;
340
+ const status = file.status ?? "modified";
341
+ const oldPath = file.previous_filename ?? newPath;
342
+ const oldDiffPath = formatSyntheticDiffPath("a/", oldPath);
343
+ const newDiffPath = formatSyntheticDiffPath("b/", newPath);
344
+ const lines: string[] = [`diff --git ${oldDiffPath} ${newDiffPath}`];
345
+ if (status === "added") {
346
+ lines.push("new file mode 100644");
347
+ } else if (status === "removed") {
348
+ lines.push("deleted file mode 100644");
349
+ } else if (status === "renamed" || file.previous_filename) {
350
+ lines.push(`rename from ${oldPath}`, `rename to ${newPath}`);
351
+ }
352
+ if (typeof file.patch === "string" && file.patch.length > 0) {
353
+ lines.push(status === "added" ? "--- /dev/null" : `--- ${oldDiffPath}`);
354
+ lines.push(status === "removed" ? "+++ /dev/null" : `+++ ${newDiffPath}`);
355
+ lines.push(file.patch);
356
+ } else {
357
+ lines.push(
358
+ `* patch unavailable (binary or too large); additions ${file.additions ?? 0}, deletions ${file.deletions ?? 0}`,
359
+ );
360
+ }
361
+ return lines.join("\n");
362
+ }
363
+
364
+ /**
365
+ * Fallback PR diff retrieval via the paginated per-file endpoint, used when the
366
+ * aggregate `gh pr diff` is rejected for exceeding GitHub's 20,000-line limit.
367
+ * The per-file patches are not subject to that aggregate cap, so even very
368
+ * large PRs can be reassembled into a synthetic unified diff.
369
+ */
370
+ export async function fetchPrDiffViaFilesApi(
371
+ cwd: string,
372
+ repo: string,
373
+ number: number,
374
+ signal: AbortSignal | undefined,
375
+ ): Promise<string> {
376
+ const pull = await git.github.json<GhPrApi>(
377
+ cwd,
378
+ ["api", "--method", "GET", `/repos/${repo}/pulls/${number}`],
379
+ signal,
380
+ { repoProvided: true },
381
+ );
382
+ if ((pull.changed_files ?? 0) > PR_DIFF_FILES_MAX) {
383
+ throw new ToolError(
384
+ `Pull request changes ${pull.changed_files} files, exceeding GitHub's ${PR_DIFF_FILES_MAX}-file limit for the per-file diff API.`,
385
+ );
386
+ }
387
+
388
+ const sections: string[] = [];
389
+ let page = 1;
390
+ while (true) {
391
+ const response = await git.github.json<GhPrFileApi[]>(
392
+ cwd,
393
+ [
394
+ "api",
395
+ "--method",
396
+ "GET",
397
+ `/repos/${repo}/pulls/${number}/files`,
398
+ "-F",
399
+ `per_page=${PR_DIFF_FILES_PAGE_SIZE}`,
400
+ "-F",
401
+ `page=${page}`,
402
+ ],
403
+ signal,
404
+ { repoProvided: true },
405
+ );
406
+ for (const file of response) {
407
+ const section = buildSyntheticDiffSection(file);
408
+ if (section) sections.push(section);
409
+ }
410
+ if (response.length < PR_DIFF_FILES_PAGE_SIZE) {
411
+ break;
412
+ }
413
+ page += 1;
414
+ }
415
+ // Trailing newline mirrors `gh pr diff` so downstream parsers splitting on
416
+ // `^diff --git ` see identical boundaries.
417
+ return sections.length > 0 ? `${sections.join("\n")}\n` : "";
418
+ }
419
+
420
+ export async function fetchPrDiffFresh(
421
+ cwd: string,
422
+ repo: string,
423
+ number: number,
424
+ signal: AbortSignal | undefined,
425
+ ): Promise<{ rendered: string; sourceUrl: string | undefined; payload: PrDiffPayload }> {
426
+ const args = ["pr", "diff", String(number), "--color", "never"];
427
+ appendRepoFlag(args, repo, String(number));
428
+ let text: string;
429
+ try {
430
+ text = await git.github.text(cwd, args, signal, { repoProvided: true, trimOutput: false });
431
+ } catch (err) {
432
+ if (!isPrDiffTooLargeError(err)) throw err;
433
+ logger.debug("gh pr diff exceeded GitHub's aggregate line limit; falling back to per-file API", {
434
+ repo,
435
+ number,
436
+ err: String(err),
437
+ });
438
+ text = await fetchPrDiffViaFilesApi(cwd, repo, number, signal);
439
+ }
440
+ const payload = parsePrUnifiedDiff(text);
441
+ // `rendered` already carries the verbatim diff; blank the payload copy so
442
+ // the cache row stores a potentially huge diff once instead of twice.
443
+ // `getOrFetchPrDiff` rehydrates `unified` from `rendered`.
444
+ return { rendered: text, sourceUrl: undefined, payload: { unified: "", files: payload.files } };
445
+ }
446
+
447
+ /**
448
+ * Cache-aware PR diff fetcher. Stores the full unified diff plus a parsed
449
+ * file index in a single `pr-diff` cache row so the listing, full-diff, and
450
+ * per-file slice variants of `pr://<n>/diff` share one `gh pr diff`
451
+ * invocation.
452
+ */
453
+ export async function getOrFetchPrDiff(options: PrDiffLookupOptions): Promise<ViewLookupResult<PrDiffPayload>> {
454
+ const authKey = options.cacheAuthKey === undefined ? (resolveGithubCacheAuthKey() ?? null) : options.cacheAuthKey;
455
+ const doFetch = () => fetchPrDiffFresh(options.cwd, options.repo, options.number, options.signal);
456
+ const lookup = await getOrFetchView<PrDiffPayload>({
457
+ repo: options.repo,
458
+ kind: "pr-diff",
459
+ number: options.number,
460
+ includeComments: false,
461
+ settings: options.settings,
462
+ authKey,
463
+ fetchFresh: doFetch,
464
+ });
465
+ return {
466
+ rendered: lookup.rendered,
467
+ sourceUrl: lookup.sourceUrl,
468
+ // Rehydrate the unified text from `rendered` (stored once per row).
469
+ payload: { unified: lookup.rendered, files: lookup.payload.files },
470
+ status: lookup.status,
471
+ fetchedAt: lookup.fetchedAt,
472
+ };
473
+ }