@oh-my-pi/pi-coding-agent 17.0.1 → 17.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/CHANGELOG.md +92 -20
  2. package/dist/cli.js +3485 -3448
  3. package/dist/types/advisor/config.d.ts +14 -6
  4. package/dist/types/advisor/runtime.d.ts +20 -11
  5. package/dist/types/autolearn/controller.d.ts +1 -0
  6. package/dist/types/config/model-discovery.d.ts +17 -0
  7. package/dist/types/config/model-resolver.d.ts +6 -2
  8. package/dist/types/config/settings-schema.d.ts +32 -7
  9. package/dist/types/config/settings.d.ts +50 -0
  10. package/dist/types/cursor.d.ts +11 -0
  11. package/dist/types/discovery/helpers.d.ts +5 -2
  12. package/dist/types/exec/bash-executor.d.ts +2 -0
  13. package/dist/types/extensibility/extensions/managed-timers.d.ts +15 -0
  14. package/dist/types/extensibility/extensions/runner.d.ts +7 -0
  15. package/dist/types/extensibility/extensions/types.d.ts +18 -0
  16. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +40 -0
  17. package/dist/types/extensibility/shared-events.d.ts +6 -0
  18. package/dist/types/extensibility/utils.d.ts +2 -2
  19. package/dist/types/mcp/transports/stdio.d.ts +13 -1
  20. package/dist/types/modes/components/advisor-config.d.ts +8 -1
  21. package/dist/types/modes/components/hook-editor.d.ts +7 -0
  22. package/dist/types/modes/components/model-hub.d.ts +5 -2
  23. package/dist/types/modes/components/session-selector.d.ts +2 -0
  24. package/dist/types/modes/controllers/command-controller.d.ts +8 -0
  25. package/dist/types/modes/controllers/selector-controller.d.ts +3 -1
  26. package/dist/types/modes/print-mode.d.ts +1 -1
  27. package/dist/types/modes/warp-events.d.ts +24 -0
  28. package/dist/types/modes/warp-events.test.d.ts +1 -0
  29. package/dist/types/plan-mode/model-transition.d.ts +47 -0
  30. package/dist/types/plan-mode/model-transition.test.d.ts +1 -0
  31. package/dist/types/registry/agent-lifecycle.d.ts +26 -1
  32. package/dist/types/sdk.d.ts +13 -2
  33. package/dist/types/session/agent-session.d.ts +34 -10
  34. package/dist/types/session/session-history-format.d.ts +10 -0
  35. package/dist/types/session/session-manager.d.ts +14 -0
  36. package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +9 -0
  37. package/dist/types/task/label.d.ts +1 -1
  38. package/dist/types/telemetry-export.d.ts +34 -9
  39. package/dist/types/tools/approval.d.ts +8 -0
  40. package/dist/types/tools/bash.d.ts +2 -0
  41. package/dist/types/tools/essential-tools.d.ts +29 -0
  42. package/dist/types/tools/image-gen.d.ts +2 -1
  43. package/dist/types/tools/index.d.ts +1 -0
  44. package/dist/types/tools/xdev.d.ts +11 -2
  45. package/dist/types/utils/title-generator.d.ts +2 -1
  46. package/dist/types/web/search/providers/kimi.d.ts +4 -1
  47. package/dist/types/web/search/types.d.ts +1 -1
  48. package/package.json +21 -16
  49. package/src/advisor/__tests__/advisor.test.ts +1304 -42
  50. package/src/advisor/__tests__/config.test.ts +58 -2
  51. package/src/advisor/config.ts +76 -24
  52. package/src/advisor/runtime.ts +445 -92
  53. package/src/autolearn/controller.ts +23 -28
  54. package/src/cli.ts +5 -1
  55. package/src/config/model-discovery.ts +81 -21
  56. package/src/config/model-registry.ts +25 -6
  57. package/src/config/model-resolver.ts +14 -7
  58. package/src/config/settings-schema.ts +42 -6
  59. package/src/config/settings.ts +405 -25
  60. package/src/cursor.ts +20 -3
  61. package/src/debug/report-bundle.ts +40 -4
  62. package/src/discovery/helpers.ts +28 -5
  63. package/src/exec/bash-executor.ts +14 -5
  64. package/src/extensibility/custom-tools/loader.ts +3 -3
  65. package/src/extensibility/custom-tools/wrapper.ts +2 -1
  66. package/src/extensibility/extensions/loader.ts +3 -3
  67. package/src/extensibility/extensions/managed-timers.ts +83 -0
  68. package/src/extensibility/extensions/runner.ts +26 -0
  69. package/src/extensibility/extensions/types.ts +18 -0
  70. package/src/extensibility/extensions/wrapper.ts +2 -1
  71. package/src/extensibility/hooks/loader.ts +3 -3
  72. package/src/extensibility/legacy-pi-coding-agent-shim.ts +96 -1
  73. package/src/extensibility/plugins/legacy-pi-compat.ts +225 -22
  74. package/src/extensibility/plugins/manager.ts +2 -2
  75. package/src/extensibility/shared-events.ts +6 -0
  76. package/src/extensibility/utils.ts +91 -25
  77. package/src/irc/bus.ts +22 -3
  78. package/src/launch/broker.ts +3 -2
  79. package/src/launch/client.ts +2 -2
  80. package/src/launch/presence.ts +2 -2
  81. package/src/lsp/client.ts +1 -1
  82. package/src/main.ts +11 -8
  83. package/src/mcp/manager.ts +9 -3
  84. package/src/mcp/transports/stdio.ts +103 -23
  85. package/src/modes/components/advisor-config.ts +65 -3
  86. package/src/modes/components/ask-dialog.ts +1 -1
  87. package/src/modes/components/hook-editor.ts +18 -3
  88. package/src/modes/components/model-hub.ts +138 -42
  89. package/src/modes/components/session-selector.ts +4 -0
  90. package/src/modes/components/status-line/component.test.ts +1 -0
  91. package/src/modes/components/status-line/segments.ts +21 -6
  92. package/src/modes/controllers/command-controller.ts +167 -47
  93. package/src/modes/controllers/event-controller.ts +5 -0
  94. package/src/modes/controllers/extension-ui-controller.ts +4 -22
  95. package/src/modes/controllers/input-controller.ts +12 -12
  96. package/src/modes/controllers/selector-controller.ts +191 -31
  97. package/src/modes/interactive-mode.ts +139 -54
  98. package/src/modes/print-mode.ts +3 -3
  99. package/src/modes/rpc/host-tools.ts +2 -1
  100. package/src/modes/rpc/rpc-mode.ts +19 -4
  101. package/src/modes/warp-events.test.ts +794 -0
  102. package/src/modes/warp-events.ts +232 -0
  103. package/src/plan-mode/model-transition.test.ts +60 -0
  104. package/src/plan-mode/model-transition.ts +51 -0
  105. package/src/registry/agent-lifecycle.ts +133 -18
  106. package/src/sdk.ts +221 -42
  107. package/src/session/agent-session.ts +1285 -348
  108. package/src/session/session-history-format.ts +20 -5
  109. package/src/session/session-manager.ts +48 -0
  110. package/src/slash-commands/builtin-registry.ts +7 -0
  111. package/src/slash-commands/helpers/active-oauth-account.ts +16 -0
  112. package/src/task/executor.ts +1 -1
  113. package/src/task/label.ts +2 -0
  114. package/src/telemetry-export.ts +453 -97
  115. package/src/tools/approval.ts +11 -0
  116. package/src/tools/bash.ts +71 -38
  117. package/src/tools/essential-tools.ts +45 -0
  118. package/src/tools/gh.ts +169 -2
  119. package/src/tools/image-gen.ts +69 -7
  120. package/src/tools/index.ts +7 -5
  121. package/src/tools/read.ts +48 -3
  122. package/src/tools/write.ts +22 -4
  123. package/src/tools/xdev.ts +14 -3
  124. package/src/utils/title-generator.ts +15 -4
  125. package/src/web/search/providers/kimi.ts +18 -12
  126. package/src/web/search/types.ts +6 -1
package/src/tools/bash.ts CHANGED
@@ -177,6 +177,8 @@ export interface BashToolDetails {
177
177
  wallTimeMs?: number;
178
178
  /** Exit code of a command that ran to completion but failed (non-zero). */
179
179
  exitCode?: number;
180
+ /** True when the command was killed by its timeout deadline (not a failure). */
181
+ timedOut?: boolean;
180
182
  terminalId?: string;
181
183
  async?: {
182
184
  state: "running" | "completed" | "failed";
@@ -208,10 +210,6 @@ function normalizeResultOutput(result: BashResult | BashInteractiveResult): stri
208
210
  return result.output || "";
209
211
  }
210
212
 
211
- function isInteractiveResult(result: BashResult | BashInteractiveResult): result is BashInteractiveResult {
212
- return "timedOut" in result;
213
- }
214
-
215
213
  function normalizeBashEnv(env: Record<string, string> | undefined): Record<string, string> | undefined {
216
214
  if (!env || Object.keys(env).length === 0) return undefined;
217
215
  const normalized: Record<string, string> = {};
@@ -439,12 +437,15 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
439
437
  }
440
438
 
441
439
  /**
442
- * Throw for outcomes that are *not* a completed command: user/timeout
443
- * aborts and a missing exit status. The foreground and bridge callers plus
444
- * the async job manager rely on these throwing so cancellations surface as
445
- * aborts and jobs are recorded as failed. A definite non-zero exit is a
446
- * completed command that failed; #buildCompletedResult surfaces it as an
447
- * error *result* (carrying execution details) rather than a throw.
440
+ * Throw for outcomes that are *not* a completed command: user aborts and a
441
+ * missing exit status. Timeouts are handled separately by
442
+ * #buildCompletedResult, which returns a non-throwing error result with
443
+ * details.timedOut=true so the renderer can show a warning border. The
444
+ * foreground and bridge callers plus the async job manager rely on these
445
+ * throwing so cancellations surface as aborts and jobs are recorded as
446
+ * failed. A definite non-zero exit is a completed command that failed;
447
+ * #buildCompletedResult surfaces it as an error *result* (carrying
448
+ * execution details) rather than a throw.
448
449
  */
449
450
  #throwIfUnfinished(
450
451
  result: BashResult | BashInteractiveResult,
@@ -452,13 +453,14 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
452
453
  outputText: string,
453
454
  ): void {
454
455
  if (result.cancelled) {
455
- // executeBash output already carries a `[Command cancelled]` notice from
456
- // the sink; PTY/bridge interactive output does not, so annotate it here.
456
+ // Local executor output already carries a leading `[Command cancelled]`
457
+ // notice from the sink; PTY/bridge output does not, so annotate only
458
+ // the latter.
457
459
  const out = normalizeResultOutput(result);
458
- const annotated = isInteractiveResult(result) && out ? `${out}\n\n[Command aborted]` : out;
460
+ const annotated = out.startsWith("[Command cancelled]") ? out : out ? `${out}\n\n[Command aborted]` : out;
459
461
  throw new ToolError(annotated || "Command aborted");
460
462
  }
461
- if (isInteractiveResult(result) && result.timedOut) {
463
+ if (result.timedOut === true) {
462
464
  const out = normalizeResultOutput(result);
463
465
  const message =
464
466
  timeoutSec === undefined ? "Command timed out" : `Command timed out after ${timeoutSec} seconds`;
@@ -496,8 +498,12 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
496
498
  if (failedExit) outputLines.push("", formatExitCodeNotice(exitCode));
497
499
  const outputText = outputLines.join("\n");
498
500
 
499
- // Aborts / timeouts / missing-status still propagate as thrown errors.
500
- this.#throwIfUnfinished(result, timeoutSec, outputText);
501
+ // Timeouts are not failures the command ran its course. Return an error
502
+ // result (isError=true for the model) but flag timedOut so the renderer
503
+ // uses a warning border instead of error red. Both interactive and
504
+ // non-interactive results carry an explicit `timedOut` field from the
505
+ // executor/PTY layer.
506
+ const isTimeout = result.timedOut === true;
501
507
 
502
508
  const details: BashToolDetails = {};
503
509
  if (timeoutSec === undefined) {
@@ -517,6 +523,29 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
517
523
  if (failedExit) {
518
524
  details.exitCode = exitCode;
519
525
  }
526
+
527
+ if (isTimeout) {
528
+ details.timedOut = true;
529
+ const message =
530
+ timeoutSec === undefined ? "Command timed out" : `Command timed out after ${timeoutSec} seconds`;
531
+ // executeBash has already emitted this leading sink notice. PTY output
532
+ // has not, so provide the LLM-facing annotation exactly once.
533
+ if (!normalizeResultOutput(result).startsWith(`[${message}]\n`)) {
534
+ outputLines.push("", `[${message}]`);
535
+ }
536
+ const timeoutOutputText = await enforceInlineByteCap(outputLines.join("\n"), {
537
+ saveArtifact: full => saveBashOriginalArtifact(this.session, full),
538
+ });
539
+ return toolResult(details)
540
+ .text(timeoutOutputText)
541
+ .truncationFromSummary(result, { direction: "tail" })
542
+ .error()
543
+ .done();
544
+ }
545
+
546
+ // Non-timeout cancellations and missing exit status still propagate as thrown errors.
547
+ this.#throwIfUnfinished(result, timeoutSec, outputText);
548
+
520
549
  // Final defense at the tool-result boundary: no bash path (client bridge,
521
550
  // head-retention spill, minimizer miss) may emit more than
522
551
  // ~DEFAULT_MAX_BYTES inline. No-op for already-bounded output.
@@ -992,12 +1021,8 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
992
1021
  outputLines: current.output.length > 0 ? current.output.split("\n").length : 0,
993
1022
  outputBytes: current.output.length,
994
1023
  };
995
- return this.#buildCompletedResult(timedOutResult, timeoutSec, {
996
- requestedTimeoutSec,
997
- notices: pendingNotices,
998
- terminalId: handle.terminalId,
999
- wallTimeMs: performance.now() - bridgeWallTimeStart,
1000
- });
1024
+ this.#throwIfUnfinished(timedOutResult, timeoutSec, this.#formatResultOutput(timedOutResult));
1025
+ throw new ToolError("Command timed out");
1001
1026
  }
1002
1027
 
1003
1028
  if (raced.kind === "exit") {
@@ -1102,20 +1127,27 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
1102
1127
  });
1103
1128
  const wallTimeMs = performance.now() - wallTimeStart;
1104
1129
  if (result.cancelled) {
1105
- const out = normalizeResultOutput(result);
1106
- // PTY output carries no cancel/timeout notice of its own; annotate so
1107
- // the model can tell an abort from a plain failure.
1108
- const message = isInteractiveResult(result) && out ? `${out}\n\n[Command aborted]` : out || "Command aborted";
1109
- if (signal?.aborted) {
1110
- throw new ToolAbortError(message);
1130
+ // A cancelled result is either a timeout (the command's deadline fired)
1131
+ // or a user/system abort. Timeouts are handled by #buildCompletedResult
1132
+ // which returns a non-throwing error result with details.timedOut=true
1133
+ // so the renderer can show a warning border instead of error red.
1134
+ // Both interactive and non-interactive results carry an explicit
1135
+ // `timedOut` field from the executor/PTY layer.
1136
+ const isTimeout = result.timedOut === true;
1137
+ if (!isTimeout) {
1138
+ const out = normalizeResultOutput(result);
1139
+ // The local executor already prepends `[Command cancelled]`; PTY
1140
+ // output does not, so preserve one cancellation notice in either case.
1141
+ const message = out.startsWith("[Command cancelled]")
1142
+ ? out
1143
+ : out
1144
+ ? `${out}\n\n[Command aborted]`
1145
+ : "Command aborted";
1146
+ if (signal?.aborted) {
1147
+ throw new ToolAbortError(message);
1148
+ }
1149
+ throw new ToolError(message);
1111
1150
  }
1112
- throw new ToolError(message);
1113
- }
1114
- if (isInteractiveResult(result) && result.timedOut) {
1115
- const out = normalizeResultOutput(result);
1116
- const message =
1117
- timeoutSec === undefined ? "Command timed out" : `Command timed out after ${timeoutSec} seconds`;
1118
- throw new ToolError(out ? `${out}\n\n[${message}]` : message);
1119
1151
  }
1120
1152
  return this.#buildCompletedResult(result, timeoutSec, {
1121
1153
  requestedTimeoutSec,
@@ -1253,6 +1285,8 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
1253
1285
  const isError = result.isError === true;
1254
1286
  const isPartial = options.isPartial === true;
1255
1287
  const success = !isPartial && !isError;
1288
+ const details = result.details;
1289
+ const isTimeout = details?.timedOut === true;
1256
1290
  const header =
1257
1291
  config.showHeader === false
1258
1292
  ? undefined
@@ -1263,12 +1297,11 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
1263
1297
  title: config.resolveTitle(args, options),
1264
1298
  }
1265
1299
  : {
1266
- icon: isPartial ? "pending" : "error",
1300
+ icon: isPartial ? "pending" : isTimeout ? "warning" : "error",
1267
1301
  title: config.resolveTitle(args, options),
1268
1302
  },
1269
1303
  uiTheme,
1270
1304
  );
1271
- const details = result.details;
1272
1305
  const outputBlock = new CachedOutputBlock();
1273
1306
 
1274
1307
  // Per-instance cache for the expensive inner lines computation. Mirrors
@@ -1408,7 +1441,7 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
1408
1441
  const framed = outputBlock.render(
1409
1442
  {
1410
1443
  header,
1411
- state: isPartial ? "pending" : isError ? "error" : "success",
1444
+ state: isPartial ? "pending" : isError ? (isTimeout ? "warning" : "error") : "success",
1412
1445
  sections: [
1413
1446
  {
1414
1447
  // Viewport-sized tail window in every state — streaming and final
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Canonical set of built-in tools that must stay top-level.
3
+ *
4
+ * These are the coding essentials the model always needs directly in its
5
+ * callable schema. `read`/`write` are additionally the `xd://` transport
6
+ * (`read xd://` lists devices, `write xd://<tool>` executes them), so demoting
7
+ * them under xdev makes every mounted device unreachable.
8
+ *
9
+ * Adapter boundaries (extension `registerTool`, SDK custom tools, RPC host
10
+ * tools) default an omitted `loadMode` to `"discoverable"`. A UI-only
11
+ * re-register of a built-in — e.g. wrapping `read`/`write`/`bash`/`edit`/`glob`
12
+ * to customize rendering — would then silently demote it to `discoverable` and,
13
+ * with `tools.xdev` on, unmount it from the top-level schema (issue #5764).
14
+ * {@link defaultLoadModeForToolName} pins these names to `"essential"` when the
15
+ * definition omits `loadMode`, so re-registering a built-in never demotes it.
16
+ */
17
+ import type { ToolLoadMode } from "@oh-my-pi/pi-agent-core";
18
+
19
+ /**
20
+ * Built-in tool names whose classes declare `loadMode = "essential"`. Kept in
21
+ * sync with the tool classes by `essential-tools.test.ts` (drift guard).
22
+ */
23
+ export const ESSENTIAL_BUILTIN_TOOL_NAMES: Record<string, true> = {
24
+ read: true,
25
+ write: true,
26
+ bash: true,
27
+ edit: true,
28
+ glob: true,
29
+ eval: true,
30
+ task: true,
31
+ hub: true,
32
+ learn: true,
33
+ manage_skill: true,
34
+ };
35
+
36
+ /**
37
+ * Resolve a tool's presentation mode at an adapter boundary. An explicit
38
+ * `declared` mode always wins. When omitted, known essential built-in names
39
+ * default to `"essential"` (so a re-register never demotes them); everything
40
+ * else defaults to `"discoverable"`.
41
+ */
42
+ export function defaultLoadModeForToolName(name: string, declared?: ToolLoadMode): ToolLoadMode {
43
+ if (declared) return declared;
44
+ return name in ESSENTIAL_BUILTIN_TOOL_NAMES ? "essential" : "discoverable";
45
+ }
package/src/tools/gh.ts CHANGED
@@ -10,7 +10,7 @@ import type {
10
10
  ToolApprovalDecision,
11
11
  } from "@oh-my-pi/pi-agent-core";
12
12
 
13
- import { getWorktreeDir, hashPath, isEnoent, prompt, untilAborted } from "@oh-my-pi/pi-utils";
13
+ import { getWorktreeDir, hashPath, isEnoent, logger, prompt, untilAborted } from "@oh-my-pi/pi-utils";
14
14
  import { type } from "arktype";
15
15
  import type { Settings } from "../config/settings";
16
16
  import githubDescription from "../prompts/tools/github.md" with { type: "text" };
@@ -239,6 +239,8 @@ const RUN_WATCH_TAIL_DEFAULT = 15;
239
239
  const RUN_WATCH_TAIL_MAX = 200;
240
240
  const REVIEW_COMMENTS_PAGE_SIZE = 100;
241
241
  const RUN_JOBS_PAGE_SIZE = 100;
242
+ const PR_DIFF_FILES_PAGE_SIZE = 100;
243
+ const PR_DIFF_FILES_MAX = 3000;
242
244
  const PR_URL_PATTERN = /^https:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)(?:\/.*)?$/;
243
245
  const ISSUE_URL_PATTERN = /^https:\/\/github\.com\/([^/]+\/[^/]+)\/issues\/(\d+)(?:\/.*)?$/;
244
246
  const RUN_URL_PATTERN = /^https:\/\/github\.com\/([^/]+\/[^/]+)\/actions\/runs\/(\d+)(?:\/.*)?$/;
@@ -2931,6 +2933,160 @@ function parsePrDiffSection(section: string, startOffset: number, endOffset: num
2931
2933
  return file;
2932
2934
  }
2933
2935
 
2936
+ /**
2937
+ * A single entry from `GET /repos/{owner}/{repo}/pulls/{n}/files`. `patch` is
2938
+ * absent for binary files and for individual file diffs GitHub deems too large
2939
+ * to render.
2940
+ */
2941
+ interface GhPrFileApi {
2942
+ filename?: string;
2943
+ previous_filename?: string;
2944
+ status?: string;
2945
+ additions?: number;
2946
+ deletions?: number;
2947
+ patch?: string;
2948
+ }
2949
+
2950
+ interface GhPrApi {
2951
+ changed_files?: number;
2952
+ }
2953
+
2954
+ /**
2955
+ * GitHub rejects the aggregate PR diff endpoint with HTTP 406 once the diff
2956
+ * exceeds 20,000 lines. Detect that specific failure so the caller can fall
2957
+ * back to the per-file endpoint instead of aborting the whole review.
2958
+ */
2959
+ function isPrDiffTooLargeError(err: unknown): boolean {
2960
+ const message = err instanceof Error ? err.message : String(err);
2961
+ return (
2962
+ /\bHTTP 406\b/.test(message) ||
2963
+ /exceeded the maximum number of lines/i.test(message) ||
2964
+ /\btoo_large\b/.test(message)
2965
+ );
2966
+ }
2967
+
2968
+ function formatSyntheticDiffPath(prefix: "a/" | "b/", path: string): string {
2969
+ const prefixedPath = `${prefix}${path}`;
2970
+ if (!/[\u0000-\u001F\s"\\]/.test(prefixedPath)) return prefixedPath;
2971
+
2972
+ let escaped = "";
2973
+ for (const char of prefixedPath) {
2974
+ switch (char) {
2975
+ case "\\":
2976
+ escaped += "\\\\";
2977
+ break;
2978
+ case '"':
2979
+ escaped += '\\"';
2980
+ break;
2981
+ case "\n":
2982
+ escaped += "\\n";
2983
+ break;
2984
+ case "\r":
2985
+ escaped += "\\r";
2986
+ break;
2987
+ case "\t":
2988
+ escaped += "\\t";
2989
+ break;
2990
+ default: {
2991
+ const code = char.charCodeAt(0);
2992
+ escaped += code < 32 ? `\\${code.toString(8).padStart(3, "0")}` : char;
2993
+ }
2994
+ }
2995
+ }
2996
+ return `"${escaped}"`;
2997
+ }
2998
+
2999
+ /**
3000
+ * Reconstruct a `diff --git` section from a single files-API entry. The API's
3001
+ * `patch` field carries only the hunk body, so the `diff --git`/`---`/`+++`
3002
+ * headers are synthesized to match `gh pr diff` output — this keeps
3003
+ * {@link parsePrUnifiedDiff} and the review parser producing identical section
3004
+ * boundaries and byte offsets. Files whose `patch` is omitted (binary or
3005
+ * too-large) stay visible with an explicit marker rather than being dropped.
3006
+ */
3007
+ function buildSyntheticDiffSection(file: GhPrFileApi): string | undefined {
3008
+ const newPath = file.filename;
3009
+ if (!newPath) return undefined;
3010
+ const status = file.status ?? "modified";
3011
+ const oldPath = file.previous_filename ?? newPath;
3012
+ const oldDiffPath = formatSyntheticDiffPath("a/", oldPath);
3013
+ const newDiffPath = formatSyntheticDiffPath("b/", newPath);
3014
+ const lines: string[] = [`diff --git ${oldDiffPath} ${newDiffPath}`];
3015
+ if (status === "added") {
3016
+ lines.push("new file mode 100644");
3017
+ } else if (status === "removed") {
3018
+ lines.push("deleted file mode 100644");
3019
+ } else if (status === "renamed" || file.previous_filename) {
3020
+ lines.push(`rename from ${oldPath}`, `rename to ${newPath}`);
3021
+ }
3022
+ if (typeof file.patch === "string" && file.patch.length > 0) {
3023
+ lines.push(status === "added" ? "--- /dev/null" : `--- ${oldDiffPath}`);
3024
+ lines.push(status === "removed" ? "+++ /dev/null" : `+++ ${newDiffPath}`);
3025
+ lines.push(file.patch);
3026
+ } else {
3027
+ lines.push(
3028
+ `* patch unavailable (binary or too large); additions ${file.additions ?? 0}, deletions ${file.deletions ?? 0}`,
3029
+ );
3030
+ }
3031
+ return lines.join("\n");
3032
+ }
3033
+
3034
+ /**
3035
+ * Fallback PR diff retrieval via the paginated per-file endpoint, used when the
3036
+ * aggregate `gh pr diff` is rejected for exceeding GitHub's 20,000-line limit.
3037
+ * The per-file patches are not subject to that aggregate cap, so even very
3038
+ * large PRs can be reassembled into a synthetic unified diff.
3039
+ */
3040
+ async function fetchPrDiffViaFilesApi(
3041
+ cwd: string,
3042
+ repo: string,
3043
+ number: number,
3044
+ signal: AbortSignal | undefined,
3045
+ ): Promise<string> {
3046
+ const pull = await git.github.json<GhPrApi>(
3047
+ cwd,
3048
+ ["api", "--method", "GET", `/repos/${repo}/pulls/${number}`],
3049
+ signal,
3050
+ { repoProvided: true },
3051
+ );
3052
+ if ((pull.changed_files ?? 0) > PR_DIFF_FILES_MAX) {
3053
+ throw new ToolError(
3054
+ `Pull request changes ${pull.changed_files} files, exceeding GitHub's ${PR_DIFF_FILES_MAX}-file limit for the per-file diff API.`,
3055
+ );
3056
+ }
3057
+
3058
+ const sections: string[] = [];
3059
+ let page = 1;
3060
+ while (true) {
3061
+ const response = await git.github.json<GhPrFileApi[]>(
3062
+ cwd,
3063
+ [
3064
+ "api",
3065
+ "--method",
3066
+ "GET",
3067
+ `/repos/${repo}/pulls/${number}/files`,
3068
+ "-F",
3069
+ `per_page=${PR_DIFF_FILES_PAGE_SIZE}`,
3070
+ "-F",
3071
+ `page=${page}`,
3072
+ ],
3073
+ signal,
3074
+ { repoProvided: true },
3075
+ );
3076
+ for (const file of response) {
3077
+ const section = buildSyntheticDiffSection(file);
3078
+ if (section) sections.push(section);
3079
+ }
3080
+ if (response.length < PR_DIFF_FILES_PAGE_SIZE) {
3081
+ break;
3082
+ }
3083
+ page += 1;
3084
+ }
3085
+ // Trailing newline mirrors `gh pr diff` so downstream parsers splitting on
3086
+ // `^diff --git ` see identical boundaries.
3087
+ return sections.length > 0 ? `${sections.join("\n")}\n` : "";
3088
+ }
3089
+
2934
3090
  async function fetchPrDiffFresh(
2935
3091
  cwd: string,
2936
3092
  repo: string,
@@ -2939,7 +3095,18 @@ async function fetchPrDiffFresh(
2939
3095
  ): Promise<{ rendered: string; sourceUrl: string | undefined; payload: PrDiffPayload }> {
2940
3096
  const args = ["pr", "diff", String(number), "--color", "never"];
2941
3097
  appendRepoFlag(args, repo, String(number));
2942
- const text = await git.github.text(cwd, args, signal, { repoProvided: true, trimOutput: false });
3098
+ let text: string;
3099
+ try {
3100
+ text = await git.github.text(cwd, args, signal, { repoProvided: true, trimOutput: false });
3101
+ } catch (err) {
3102
+ if (!isPrDiffTooLargeError(err)) throw err;
3103
+ logger.debug("gh pr diff exceeded GitHub's aggregate line limit; falling back to per-file API", {
3104
+ repo,
3105
+ number,
3106
+ err: String(err),
3107
+ });
3108
+ text = await fetchPrDiffViaFilesApi(cwd, repo, number, signal);
3109
+ }
2943
3110
  const payload = parsePrUnifiedDiff(text);
2944
3111
  // `rendered` already carries the verbatim diff; blank the payload copy so
2945
3112
  // the cache row stores a potentially huge diff once instead of twice.
@@ -45,7 +45,7 @@ const IMAGE_SYSTEM_INSTRUCTION =
45
45
  "You are an AI image generator. Generate images based on user descriptions. Focus on creating high-quality, visually appealing images that match the user's request.";
46
46
 
47
47
  export type ImageProvider = "antigravity" | "gemini" | "openai" | "openai-codex" | "openrouter" | "xai";
48
- export type ImageProviderPreference = Exclude<ImageProvider, "openai-codex"> | "auto";
48
+ export type ImageProviderPreference = ImageProvider | "auto";
49
49
 
50
50
  interface ImageApiKey {
51
51
  provider: ImageProvider;
@@ -57,8 +57,17 @@ interface ImageApiKey {
57
57
  const COMMON_IMAGE_ASPECT_RATIOS = ["1:1", "3:4", "4:3", "9:16", "16:9"] as const;
58
58
  const XAI_IMAGE_ASPECT_RATIOS = [...COMMON_IMAGE_ASPECT_RATIOS, "3:2", "2:3"] as const;
59
59
  const COMMON_IMAGE_ASPECT_RATIO_SET = new Set<string>(COMMON_IMAGE_ASPECT_RATIOS);
60
- const IMAGE_PROVIDER_PREFERENCES = new Set<string>(["auto", "antigravity", "gemini", "openai", "openrouter", "xai"]);
61
- const AUTO_IMAGE_PROVIDER_ORDER = ["openai", "antigravity", "xai", "openrouter", "gemini"] as const;
60
+ const IMAGE_PROVIDER_CHOICES = [
61
+ "auto",
62
+ "antigravity",
63
+ "gemini",
64
+ "openai",
65
+ "openai-codex",
66
+ "openrouter",
67
+ "xai",
68
+ ] as const;
69
+ const IMAGE_PROVIDER_PREFERENCES = new Set<string>(IMAGE_PROVIDER_CHOICES);
70
+ const AUTO_IMAGE_PROVIDER_ORDER = ["openai", "openai-codex", "antigravity", "xai", "openrouter", "gemini"] as const;
62
71
 
63
72
  const responseModalitySchema = type('"IMAGE" | "TEXT"');
64
73
 
@@ -71,6 +80,10 @@ const inputImageSchema = type({
71
80
  "mime_type?": type("string").describe("mime type"),
72
81
  });
73
82
 
83
+ const imageProviderSchema = type
84
+ .enumerated(...IMAGE_PROVIDER_CHOICES)
85
+ .describe("image provider for this request; overrides the providers.image setting (default: use the setting)");
86
+
74
87
  export const imageGenSchema = type({
75
88
  subject: type("string").describe("main subject"),
76
89
  "action?": type("string").describe("what subject is doing"),
@@ -83,6 +96,7 @@ export const imageGenSchema = type({
83
96
  "aspect_ratio?": aspectRatioSchema,
84
97
  "image_size?": imageSizeSchema,
85
98
  "input?": inputImageSchema.array().describe("input images"),
99
+ "provider?": imageProviderSchema,
86
100
  });
87
101
  export type ImageGenParams = typeof imageGenSchema.infer;
88
102
  export type GeminiResponseModality = typeof responseModalitySchema.infer;
@@ -548,6 +562,49 @@ async function findOpenAIHostedImageCredentials(
548
562
  };
549
563
  }
550
564
 
565
+ // Codex (ChatGPT subscription) chat models that carry OpenAI's hosted
566
+ // `image_generation` tool. Priority: newest general model first, then Codex
567
+ // variants; any available openai-codex hosted-image model is the last resort.
568
+ const CODEX_IMAGE_MODEL_PRIORITY = ["gpt-5.5", "gpt-5.4", "gpt-5.1", "gpt-5", "gpt-5-codex"] as const;
569
+
570
+ function resolveDefaultCodexImageModel(modelRegistry: ModelRegistry): Model | undefined {
571
+ for (const id of CODEX_IMAGE_MODEL_PRIORITY) {
572
+ const model = modelRegistry.find("openai-codex", id);
573
+ if (model && isOpenAIHostedImageModel(model)) return model;
574
+ }
575
+ return modelRegistry.getAll().find(model => model.provider === "openai-codex" && isOpenAIHostedImageModel(model));
576
+ }
577
+
578
+ /**
579
+ * Codex subscription (ChatGPT OAuth) image credentials — engages OpenAI's hosted
580
+ * `image_generation` tool through a CONNECTED Codex account, independent of the
581
+ * active chat model. This is what lets image generation run on a ChatGPT
582
+ * subscription (no metered OPENAI_API_KEY) even when the active model is, e.g.,
583
+ * Claude. The active-model-is-codex case is already served by
584
+ * {@link findOpenAIHostedImageCredentials}, so it is skipped here to avoid a
585
+ * duplicate resolution.
586
+ */
587
+ async function findCodexSubscriptionImageCredentials(
588
+ modelRegistry: ModelRegistry | undefined,
589
+ activeModel: Model | undefined,
590
+ sessionId?: string,
591
+ ): Promise<ImageApiKey | null> {
592
+ if (!modelRegistry) return null;
593
+ if (isOpenAIHostedImageModel(activeModel) && getOpenAIHostedImageProvider(activeModel) === "openai-codex") {
594
+ return null;
595
+ }
596
+ // A Codex subscription credential is an OAuth JWT with an account claim. API
597
+ // keys stored under this provider cannot use the ChatGPT backend and must not
598
+ // prevent fallback providers from being selected.
599
+ const token = await modelRegistry.getApiKeyForProvider("openai-codex", sessionId);
600
+ if (!token || !getCodexAccountId(token)) return null;
601
+ const model = resolveDefaultCodexImageModel(modelRegistry);
602
+ if (!model) return null;
603
+ const apiKey = await modelRegistry.getApiKey(model, sessionId);
604
+ if (!isAuthenticated(apiKey) || !getCodexAccountId(apiKey)) return null;
605
+ return { provider: "openai-codex", apiKey, model };
606
+ }
607
+
551
608
  function activeImageProvider(model: Model | undefined): Exclude<ImageProviderPreference, "auto"> | null {
552
609
  switch (model?.provider) {
553
610
  case "openai":
@@ -567,7 +624,10 @@ function activeImageProvider(model: Model | undefined): Exclude<ImageProviderPre
567
624
  }
568
625
  }
569
626
 
570
- function imageProviderOrder(activeModel: Model | undefined): Array<Exclude<ImageProviderPreference, "auto">> {
627
+ function imageProviderOrder(
628
+ activeModel: Model | undefined,
629
+ preference: ImageProviderPreference = preferredImageProvider,
630
+ ): Array<Exclude<ImageProviderPreference, "auto">> {
571
631
  const providers: Array<Exclude<ImageProviderPreference, "auto">> = [];
572
632
  const added = new Set<Exclude<ImageProviderPreference, "auto">>();
573
633
  const add = (provider: Exclude<ImageProviderPreference, "auto"> | null): void => {
@@ -576,7 +636,7 @@ function imageProviderOrder(activeModel: Model | undefined): Array<Exclude<Image
576
636
  providers.push(provider);
577
637
  };
578
638
 
579
- if (preferredImageProvider !== "auto") add(preferredImageProvider);
639
+ if (preference !== "auto") add(preference);
580
640
  add(activeImageProvider(activeModel));
581
641
  for (const provider of AUTO_IMAGE_PROVIDER_ORDER) add(provider);
582
642
  return providers;
@@ -591,6 +651,8 @@ async function findImageApiKey(
591
651
  switch (provider) {
592
652
  case "openai":
593
653
  return findOpenAIHostedImageCredentials(modelRegistry, activeModel, sessionId);
654
+ case "openai-codex":
655
+ return findCodexSubscriptionImageCredentials(modelRegistry, activeModel, sessionId);
594
656
  case "antigravity":
595
657
  return modelRegistry ? findAntigravityCredentials(modelRegistry, sessionId) : null;
596
658
  case "xai":
@@ -1047,7 +1109,7 @@ export const imageGenTool: CustomTool<typeof imageGenSchema, ImageGenToolDetails
1047
1109
  async execute(_toolCallId, params, _onUpdate, ctx, signal) {
1048
1110
  return untilAborted(signal, async () => {
1049
1111
  const sessionId = ctx.sessionManager.getSessionId();
1050
- const providerOrder = imageProviderOrder(ctx.model);
1112
+ const providerOrder = imageProviderOrder(ctx.model, params.provider ?? preferredImageProvider);
1051
1113
  const cwd = ctx.sessionManager.getCwd();
1052
1114
  const requestSignal = ptree.combineSignals(signal, IMAGE_TIMEOUT);
1053
1115
  const fetchImpl = ctx.fetch ?? fetch;
@@ -1604,7 +1666,7 @@ export const imageGenTool: CustomTool<typeof imageGenSchema, ImageGenToolDetails
1604
1666
 
1605
1667
  if (!foundCredentials) {
1606
1668
  throw new Error(
1607
- "No image API credentials found. Use a GPT Responses/Codex model with OpenAI credentials, login with google-antigravity or xAI Grok OAuth, or set XAI_API_KEY, OPENROUTER_API_KEY, GEMINI_API_KEY, or GOOGLE_API_KEY.",
1669
+ "No image API credentials found. Connect a Codex (ChatGPT) subscription, use a GPT Responses/Codex model with OpenAI credentials, log in with google-antigravity or xAI Grok OAuth, or set OPENAI_API_KEY, XAI_API_KEY, OPENROUTER_API_KEY, GEMINI_API_KEY, or GOOGLE_API_KEY.",
1608
1670
  );
1609
1671
  }
1610
1672
 
@@ -74,6 +74,7 @@ export * from "./bash";
74
74
  export * from "./browser";
75
75
  export * from "./checkpoint";
76
76
  export * from "./debug";
77
+ export * from "./essential-tools";
77
78
  export * from "./eval";
78
79
  export * from "./eval-backends";
79
80
  export * from "./gh";
@@ -568,15 +569,16 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
568
569
  );
569
570
  let tools = baseResults.filter((r): r is Tool => r !== null);
570
571
 
571
- // xd:// mounting: unmount discoverable built-ins from the tools array and
572
- // expose them as virtual device URLs driven through read/write. Active for
573
- // default tool sets when `tools.xdev` is enabled.
574
- const xdevEnabled = requestedTools === undefined && session.settings.get("tools.xdev");
572
+ // Always create the xd:// registry when enabled so SDK assembly can mount
573
+ // discoverable custom/MCP tools later. Explicitly requested built-ins keep
574
+ // their top-level presentation; default tool sets mount discoverable built-ins.
575
+ const xdevEnabled = session.settings.get("tools.xdev");
576
+ const mountBuiltinTools = requestedTools === undefined;
575
577
  if (xdevEnabled) {
576
578
  const mounted: Tool[] = [];
577
579
  const kept: Tool[] = [];
578
580
  for (const tool of tools) {
579
- const mountable = isMountableUnderXdev(tool) && tool.name in BUILTIN_TOOLS;
581
+ const mountable = mountBuiltinTools && isMountableUnderXdev(tool) && tool.name in BUILTIN_TOOLS;
580
582
  (mountable ? mounted : kept).push(tool);
581
583
  }
582
584
  session.xdevRegistry = new XdevRegistry(mounted);