@oh-my-pi/pi-coding-agent 15.9.67 → 15.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/CHANGELOG.md +63 -1
  2. package/dist/types/cli/args.d.ts +1 -1
  3. package/dist/types/cli/gallery-cli.d.ts +43 -0
  4. package/dist/types/cli/gallery-fixtures/agentic.d.ts +2 -0
  5. package/dist/types/cli/gallery-fixtures/codeintel.d.ts +3 -0
  6. package/dist/types/cli/gallery-fixtures/edit.d.ts +3 -0
  7. package/dist/types/cli/gallery-fixtures/fs.d.ts +2 -0
  8. package/dist/types/cli/gallery-fixtures/index.d.ts +4 -0
  9. package/dist/types/cli/gallery-fixtures/interaction.d.ts +3 -0
  10. package/dist/types/cli/gallery-fixtures/memory.d.ts +2 -0
  11. package/dist/types/cli/gallery-fixtures/misc.d.ts +3 -0
  12. package/dist/types/cli/gallery-fixtures/search.d.ts +3 -0
  13. package/dist/types/cli/gallery-fixtures/shell.d.ts +3 -0
  14. package/dist/types/cli/gallery-fixtures/types.d.ts +44 -0
  15. package/dist/types/cli/gallery-fixtures/web.d.ts +2 -0
  16. package/dist/types/cli/gallery-screenshot.d.ts +35 -0
  17. package/dist/types/commands/gallery.d.ts +47 -0
  18. package/dist/types/config/keybindings.d.ts +6 -1
  19. package/dist/types/config/model-id-affixes.d.ts +2 -0
  20. package/dist/types/config/model-registry.d.ts +8 -1
  21. package/dist/types/config/settings-schema.d.ts +32 -6
  22. package/dist/types/extensibility/plugins/marketplace-auto-update.d.ts +8 -0
  23. package/dist/types/lsp/types.d.ts +10 -0
  24. package/dist/types/main.d.ts +3 -2
  25. package/dist/types/memory-backend/index.d.ts +2 -1
  26. package/dist/types/memory-backend/resolve.d.ts +1 -1
  27. package/dist/types/memory-backend/types.d.ts +1 -1
  28. package/dist/types/modes/components/custom-editor.d.ts +2 -1
  29. package/dist/types/modes/components/tool-execution.d.ts +18 -0
  30. package/dist/types/modes/controllers/selector-controller.d.ts +1 -1
  31. package/dist/types/modes/index.d.ts +5 -4
  32. package/dist/types/modes/interactive-mode.d.ts +1 -1
  33. package/dist/types/modes/setup-version.d.ts +11 -0
  34. package/dist/types/modes/setup-wizard/index.d.ts +2 -1
  35. package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +2 -1
  36. package/dist/types/modes/types.d.ts +1 -1
  37. package/dist/types/sdk.d.ts +1 -1
  38. package/dist/types/task/executor.d.ts +7 -0
  39. package/dist/types/telemetry-export.d.ts +1 -1
  40. package/dist/types/tools/eval-render.d.ts +1 -8
  41. package/dist/types/tools/fetch.d.ts +15 -7
  42. package/dist/types/tools/render-utils.d.ts +8 -0
  43. package/dist/types/tools/renderers.d.ts +16 -2
  44. package/dist/types/tools/search.d.ts +1 -1
  45. package/dist/types/tools/write.d.ts +2 -0
  46. package/dist/types/web/scrapers/github.d.ts +22 -0
  47. package/dist/types/web/search/providers/perplexity.d.ts +8 -1
  48. package/dist/types/web/search/types.d.ts +1 -1
  49. package/package.json +9 -9
  50. package/scripts/dev-launch +42 -0
  51. package/scripts/dev-launch-preload.ts +19 -0
  52. package/src/cli/args.ts +2 -2
  53. package/src/cli/gallery-cli.ts +223 -0
  54. package/src/cli/gallery-fixtures/agentic.ts +292 -0
  55. package/src/cli/gallery-fixtures/codeintel.ts +188 -0
  56. package/src/cli/gallery-fixtures/edit.ts +194 -0
  57. package/src/cli/gallery-fixtures/fs.ts +153 -0
  58. package/src/cli/gallery-fixtures/index.ts +40 -0
  59. package/src/cli/gallery-fixtures/interaction.ts +49 -0
  60. package/src/cli/gallery-fixtures/memory.ts +81 -0
  61. package/src/cli/gallery-fixtures/misc.ts +221 -0
  62. package/src/cli/gallery-fixtures/search.ts +213 -0
  63. package/src/cli/gallery-fixtures/shell.ts +167 -0
  64. package/src/cli/gallery-fixtures/types.ts +41 -0
  65. package/src/cli/gallery-fixtures/web.ts +158 -0
  66. package/src/cli/gallery-screenshot.ts +279 -0
  67. package/src/cli-commands.ts +1 -0
  68. package/src/commands/gallery.ts +52 -0
  69. package/src/commands/launch.ts +1 -1
  70. package/src/config/keybindings.ts +15 -6
  71. package/src/config/model-equivalence.ts +35 -12
  72. package/src/config/model-id-affixes.ts +39 -22
  73. package/src/config/model-registry.ts +16 -16
  74. package/src/config/settings-schema.ts +18 -5
  75. package/src/config/settings.ts +11 -0
  76. package/src/dap/client.ts +14 -16
  77. package/src/edit/renderer.ts +36 -48
  78. package/src/eval/__tests__/agent-bridge.test.ts +75 -32
  79. package/src/eval/agent-bridge.ts +34 -7
  80. package/src/extensibility/extensions/runner.ts +1 -0
  81. package/src/extensibility/plugins/doctor.ts +0 -1
  82. package/src/extensibility/plugins/marketplace-auto-update.ts +49 -0
  83. package/src/goals/tools/goal-tool.ts +2 -2
  84. package/src/internal-urls/docs-index.generated.ts +5 -5
  85. package/src/lsp/client.ts +104 -55
  86. package/src/lsp/types.ts +10 -0
  87. package/src/main.ts +44 -49
  88. package/src/memory-backend/index.ts +13 -1
  89. package/src/memory-backend/resolve.ts +3 -5
  90. package/src/memory-backend/types.ts +1 -1
  91. package/src/modes/components/custom-editor.ts +10 -1
  92. package/src/modes/components/status-line.ts +3 -5
  93. package/src/modes/components/tool-execution.ts +61 -16
  94. package/src/modes/controllers/command-controller.ts +13 -2
  95. package/src/modes/controllers/input-controller.ts +11 -3
  96. package/src/modes/controllers/selector-controller.ts +2 -2
  97. package/src/modes/index.ts +5 -4
  98. package/src/modes/interactive-mode.ts +17 -3
  99. package/src/modes/setup-version.ts +11 -0
  100. package/src/modes/setup-wizard/index.ts +3 -2
  101. package/src/modes/setup-wizard/scenes/web-search.ts +3 -2
  102. package/src/modes/types.ts +1 -1
  103. package/src/modes/utils/context-usage.ts +10 -6
  104. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  105. package/src/sdk.ts +21 -23
  106. package/src/session/agent-session.ts +7 -7
  107. package/src/slash-commands/builtin-registry.ts +1 -1
  108. package/src/slash-commands/helpers/usage-report.ts +2 -0
  109. package/src/task/executor.ts +20 -2
  110. package/src/task/render.ts +1 -2
  111. package/src/telemetry-export.ts +25 -7
  112. package/src/tools/eval-backends.ts +6 -17
  113. package/src/tools/eval-render.ts +21 -18
  114. package/src/tools/eval.ts +5 -4
  115. package/src/tools/fetch.ts +94 -84
  116. package/src/tools/render-utils.ts +17 -3
  117. package/src/tools/renderers.ts +16 -1
  118. package/src/tools/report-tool-issue.ts +1 -1
  119. package/src/tools/search.ts +173 -81
  120. package/src/tools/todo.ts +20 -7
  121. package/src/tools/write.ts +22 -1
  122. package/src/web/scrapers/github.ts +255 -3
  123. package/src/web/scrapers/youtube.ts +3 -2
  124. package/src/web/search/providers/perplexity.ts +199 -51
  125. package/src/web/search/render.ts +39 -54
  126. package/src/web/search/types.ts +5 -1
  127. package/dist/types/eval/__tests__/shared-executors.test.d.ts +0 -1
  128. package/src/eval/__tests__/shared-executors.test.ts +0 -609
@@ -3059,13 +3059,26 @@ export const SETTINGS_SCHEMA = {
3059
3059
  ],
3060
3060
  },
3061
3061
  },
3062
- "providers.parallelFetch": {
3063
- type: "boolean",
3064
- default: true,
3062
+ "providers.fetch": {
3063
+ type: "enum",
3064
+ values: ["auto", "native", "trafilatura", "lynx", "parallel", "jina"] as const,
3065
+ default: "auto",
3065
3066
  ui: {
3066
3067
  tab: "providers",
3067
- label: "Parallel Fetch",
3068
- description: "Use Parallel extract API for URL fetching when credentials are available",
3068
+ label: "Fetch Provider",
3069
+ description: "Reader backend priority for the fetch/read URL tool",
3070
+ options: [
3071
+ {
3072
+ value: "auto",
3073
+ label: "Auto",
3074
+ description: "Priority: native > trafilatura > lynx > parallel > jina",
3075
+ },
3076
+ { value: "native", label: "Native", description: "In-process HTML→Markdown converter (always available)" },
3077
+ { value: "trafilatura", label: "Trafilatura", description: "Auto-installs via uv/pip" },
3078
+ { value: "lynx", label: "Lynx", description: "Requires lynx system package" },
3079
+ { value: "parallel", label: "Parallel", description: "Requires PARALLEL_API_KEY" },
3080
+ { value: "jina", label: "Jina", description: "Uses r.jina.ai reader (JINA_API_KEY optional)" },
3081
+ ],
3069
3082
  },
3070
3083
  },
3071
3084
  "provider.appendOnlyContext": {
@@ -712,6 +712,17 @@ export class Settings {
712
712
  }
713
713
  }
714
714
 
715
+ // providers.parallelFetch (boolean) replaced by the providers.fetch reader
716
+ // priority enum. The new default ("auto") supersedes both old values —
717
+ // Parallel is now a deep fallback in the auto chain rather than the first
718
+ // choice — so drop the legacy key (flat and nested) and let the enum
719
+ // default apply.
720
+ const providersObj = raw.providers as Record<string, unknown> | undefined;
721
+ if (providersObj && "parallelFetch" in providersObj) {
722
+ delete providersObj.parallelFetch;
723
+ }
724
+ delete raw["providers.parallelFetch"];
725
+
715
726
  // Map legacy `memories.enabled` boolean to the explicit `memory.backend`
716
727
  // enum if the latter hasn't been set yet. Idempotent: subsequent
717
728
  // migrations are no-ops once memory.backend is materialised.
package/src/dap/client.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { logger, ptree } from "@oh-my-pi/pi-utils";
1
+ import * as fs from "node:fs/promises";
2
+ import { isEnoent, logger, ptree } from "@oh-my-pi/pi-utils";
2
3
  import { NON_INTERACTIVE_ENV } from "../exec/non-interactive-env";
3
4
  import { ToolAbortError } from "../tools/tool-errors";
4
5
  import type {
@@ -165,19 +166,7 @@ export class DapClient {
165
166
  detached: true,
166
167
  });
167
168
 
168
- // Wait for the socket file to appear (dlv needs to start listening)
169
- await waitForCondition(
170
- () => {
171
- try {
172
- Bun.file(socketPath).size;
173
- return true;
174
- } catch {
175
- return false;
176
- }
177
- },
178
- 10_000,
179
- proc,
180
- );
169
+ await waitForCondition(() => isUnixSocketReady(socketPath), 10_000, proc);
181
170
 
182
171
  const { readable, writeSink, socket } = await connectSocket({ unix: socketPath });
183
172
  const client = new DapClient(adapter, cwd, proc, { readable, writeSink, socket });
@@ -553,15 +542,24 @@ export class DapClient {
553
542
  }
554
543
  }
555
544
 
545
+ async function isUnixSocketReady(socketPath: string): Promise<boolean> {
546
+ try {
547
+ return (await fs.stat(socketPath)).isSocket();
548
+ } catch (error) {
549
+ if (isEnoent(error)) return false;
550
+ throw error;
551
+ }
552
+ }
553
+
556
554
  /** Poll a condition until it returns true, or timeout/process exit. */
557
555
  async function waitForCondition(
558
- check: () => boolean,
556
+ check: () => boolean | Promise<boolean>,
559
557
  timeoutMs: number,
560
558
  proc: { exitCode: number | null },
561
559
  ): Promise<void> {
562
560
  const deadline = Date.now() + timeoutMs;
563
561
  while (Date.now() < deadline) {
564
- if (check()) return;
562
+ if (await check()) return;
565
563
  if (proc.exitCode !== null) {
566
564
  throw new Error("Adapter process exited before socket was ready");
567
565
  }
@@ -179,11 +179,6 @@ function countEditFiles(edits: EditRenderEntry[]): number {
179
179
  return new Set(edits.map(edit => filePathFromEditEntry(edit.path)).filter(Boolean)).size;
180
180
  }
181
181
 
182
- function countLines(text: string): number {
183
- if (!text) return 0;
184
- return text.split("\n").length;
185
- }
186
-
187
182
  function getOperationTitle(op: Operation | undefined): string {
188
183
  return op === "create" ? "Create" : op === "delete" ? "Delete" : "Edit";
189
184
  }
@@ -233,19 +228,22 @@ function renderPlainTextPreview(text: string, uiTheme: Theme, filePath?: string)
233
228
  return preview.trimEnd();
234
229
  }
235
230
 
236
- function formatStreamingDiff(diff: string, rawPath: string, uiTheme: Theme, label = "streaming"): string {
231
+ function formatStreamingDiff(
232
+ diff: string,
233
+ rawPath: string,
234
+ uiTheme: Theme,
235
+ expanded: boolean,
236
+ label = "streaming",
237
+ ): string {
237
238
  if (!diff) return "";
238
- // "Cursor" tail window: pin the last EDIT_STREAMING_PREVIEW_LINES rows to the
239
- // bottom of the diff so freshly streamed changes stay on screen, and accept
240
- // the trailing rows "from the back" once the diff outgrows the window. The
241
- // whole-file diff is recomputed on every streamed chunk and its Myers
242
- // alignment is not monotonic in payload length, so a hunk-aware window that
243
- // kept whole change segments gained and lost rows tick to tick — the box
244
- // stuttered, and the earlier high-water fix traded that for a half-empty
245
- // rectangle. A strict fixed-height window keeps the box steady and always
246
- // full of real diff context instead of blank padding.
239
+ // Collapsed uses a "Cursor" tail window: pin the last
240
+ // EDIT_STREAMING_PREVIEW_LINES rows to the bottom so freshly streamed changes
241
+ // stay on screen. The whole-file diff is recomputed on every streamed chunk
242
+ // and its Myers alignment is not monotonic in payload length, so a hunk-aware
243
+ // window stutters as rows move between hunks. Expanded deliberately lifts that
244
+ // cap for the approval-time full view.
247
245
  const allLines = diff.replace(/\n+$/u, "").split("\n");
248
- const hiddenLines = Math.max(0, allLines.length - EDIT_STREAMING_PREVIEW_LINES);
246
+ const hiddenLines = expanded ? 0 : Math.max(0, allLines.length - EDIT_STREAMING_PREVIEW_LINES);
249
247
  const visible = hiddenLines > 0 ? allLines.slice(hiddenLines) : allLines;
250
248
  let text = "\n\n";
251
249
  if (hiddenLines > 0) {
@@ -256,19 +254,11 @@ function formatStreamingDiff(diff: string, rawPath: string, uiTheme: Theme, labe
256
254
  text += `${uiTheme.fg("dim", `… (${remainder.join(", ")} above)`)}\n`;
257
255
  }
258
256
  text += renderDiffColored(visible.join("\n"), { filePath: rawPath });
259
- text += uiTheme.fg("dim", `\n(${label})`);
257
+ if (!expanded || label !== "preview") text += uiTheme.fg("dim", `\n(${label})`);
260
258
  return text;
261
259
  }
262
260
 
263
- function formatMetadataLine(lineCount: number | null, language: string | undefined, uiTheme: Theme): string {
264
- const icon = uiTheme.getLangIcon(language);
265
- if (lineCount !== null) {
266
- return uiTheme.fg("dim", `${icon} ${lineCount} lines`);
267
- }
268
- return uiTheme.fg("dim", `${icon}`);
269
- }
270
-
271
- function formatMultiFileStreamingDiff(previews: PerFileDiffPreview[], uiTheme: Theme): string {
261
+ function formatMultiFileStreamingDiff(previews: PerFileDiffPreview[], uiTheme: Theme, expanded: boolean): string {
272
262
  const parts: string[] = [];
273
263
  for (const preview of previews) {
274
264
  if (!preview.diff && !preview.error) continue;
@@ -278,7 +268,7 @@ function formatMultiFileStreamingDiff(previews: PerFileDiffPreview[], uiTheme: T
278
268
  continue;
279
269
  }
280
270
  if (preview.diff) {
281
- parts.push(`${header}${formatStreamingDiff(preview.diff, preview.path, uiTheme, "preview")}`);
271
+ parts.push(`${header}${formatStreamingDiff(preview.diff, preview.path, uiTheme, expanded, "preview")}`);
282
272
  }
283
273
  }
284
274
  return parts.join("");
@@ -289,16 +279,17 @@ function getCallPreview(
289
279
  rawPath: string,
290
280
  uiTheme: Theme,
291
281
  renderContext: EditRenderContext | undefined,
282
+ expanded: boolean,
292
283
  ): string {
293
284
  const multi = renderContext?.perFileDiffPreview;
294
285
  if (multi && multi.length > 1 && multi.some(p => p.diff || p.error)) {
295
- return formatMultiFileStreamingDiff(multi, uiTheme);
286
+ return formatMultiFileStreamingDiff(multi, uiTheme, expanded);
296
287
  }
297
288
  if (args.previewDiff) {
298
- return formatStreamingDiff(args.previewDiff, rawPath, uiTheme, "preview");
289
+ return formatStreamingDiff(args.previewDiff, rawPath, uiTheme, expanded, "preview");
299
290
  }
300
291
  if (args.diff && args.op) {
301
- return formatStreamingDiff(args.diff, rawPath, uiTheme);
292
+ return formatStreamingDiff(args.diff, rawPath, uiTheme, expanded);
302
293
  }
303
294
  if (args.diff) {
304
295
  return renderPlainTextPreview(args.diff, uiTheme, rawPath);
@@ -383,6 +374,13 @@ function getApplyPatchRenderSummary(
383
374
  }
384
375
  }
385
376
 
377
+ function formatDiffStatsSuffix(diff: string, uiTheme: Theme): string {
378
+ const { added, removed, hunks } = getDiffStats(diff);
379
+ const stats = formatDiffStats(added, removed, hunks, uiTheme);
380
+ if (!stats) return "";
381
+ return ` ${uiTheme.fg("dim", uiTheme.format.bracketLeft)}${stats}${uiTheme.fg("dim", uiTheme.format.bracketRight)}`;
382
+ }
383
+
386
384
  function renderDiffSection(
387
385
  diff: string,
388
386
  rawPath: string,
@@ -390,15 +388,6 @@ function renderDiffSection(
390
388
  uiTheme: Theme,
391
389
  renderDiffFn: (t: string, o?: { filePath?: string }) => string,
392
390
  ): string {
393
- let text = "";
394
- const diffStats = getDiffStats(diff);
395
- text += `\n${uiTheme.fg("dim", uiTheme.format.bracketLeft)}${formatDiffStats(
396
- diffStats.added,
397
- diffStats.removed,
398
- diffStats.hunks,
399
- uiTheme,
400
- )}${uiTheme.fg("dim", uiTheme.format.bracketRight)}`;
401
-
402
391
  const {
403
392
  text: truncatedDiff,
404
393
  hiddenHunks,
@@ -407,7 +396,7 @@ function renderDiffSection(
407
396
  ? { text: diff, hiddenHunks: 0, hiddenLines: 0 }
408
397
  : truncateDiffByHunk(diff, PREVIEW_LIMITS.DIFF_COLLAPSED_HUNKS, PREVIEW_LIMITS.DIFF_COLLAPSED_LINES);
409
398
 
410
- text += `\n\n${renderDiffFn(truncatedDiff, { filePath: rawPath })}`;
399
+ let text = `\n${renderDiffFn(truncatedDiff, { filePath: rawPath })}`;
411
400
  if (!expanded && (hiddenHunks > 0 || hiddenLines > 0)) {
412
401
  const remainder: string[] = [];
413
402
  if (hiddenHunks > 0) remainder.push(`${hiddenHunks} more hunks`);
@@ -481,7 +470,7 @@ export const editToolRenderer = {
481
470
  if (fileCount > 1) {
482
471
  text += uiTheme.fg("dim", ` (+${fileCount - 1} more)`);
483
472
  }
484
- text += getCallPreview(editArgs, rawPath, uiTheme, renderContext);
473
+ text += getCallPreview(editArgs, rawPath, uiTheme, renderContext, options.expanded);
485
474
  if (applyPatchSummary?.error) {
486
475
  text += `\n\n${uiTheme.fg("error", truncateToWidth(replaceTabs(applyPatchSummary.error, rawPath), CALL_TEXT_PREVIEW_WIDTH))}`;
487
476
  }
@@ -528,11 +517,6 @@ function renderSingleFileResult(
528
517
  "";
529
518
  const op = args?.op || firstEdit?.op || details?.op;
530
519
  const rename = args?.rename || firstEdit?.rename || firstEdit?.move || details?.move;
531
- const { language } = formatEditDescription(rawPath, uiTheme, { rename });
532
-
533
- const editTextSource = args?.newText ?? args?.oldText ?? args?.diff ?? args?.patch;
534
- const metadataLineCount = editTextSource ? countLines(editTextSource) : null;
535
- const metadataLine = op !== "delete" ? `\n${formatMetadataLine(metadataLineCount, language, uiTheme)}` : "";
536
520
 
537
521
  const displayErrorText = isError && details && "displayErrorText" in details ? details.displayErrorText : undefined;
538
522
  const errorText = isError
@@ -556,6 +540,11 @@ function renderSingleFileResult(
556
540
  (details && !isError ? details.firstChangedLine : undefined);
557
541
  const { description } = formatEditDescription(rawPath, uiTheme, { rename, firstChangedLine });
558
542
 
543
+ // Change stats ride inline on the header next to the path rather than a separate row.
544
+ const previewDiff = editDiffPreview && !("error" in editDiffPreview) ? editDiffPreview.diff : undefined;
545
+ const headerDiff = isError ? undefined : details?.diff || previewDiff;
546
+ const statsSuffix = headerDiff ? formatDiffStatsSuffix(headerDiff, uiTheme) : "";
547
+
559
548
  const header = renderStatusLine(
560
549
  {
561
550
  icon: isError ? "error" : "success",
@@ -564,8 +553,7 @@ function renderSingleFileResult(
564
553
  },
565
554
  uiTheme,
566
555
  );
567
- let text = header;
568
- text += metadataLine;
556
+ let text = header + statsSuffix;
569
557
 
570
558
  if (isError) {
571
559
  if (errorText) {
@@ -231,6 +231,57 @@ describe("runEvalAgent", () => {
231
231
  });
232
232
  await expect(runEvalAgent({ prompt: "fail" }, { session: makeSession() })).rejects.toThrow("boom");
233
233
  });
234
+
235
+ // Regression: a runtime-limit abort returns exitCode=1, stderr="", error=undefined,
236
+ // aborted=true, abortReason="Subagent runtime limit exceeded (...)". The previous
237
+ // failure-message coalesce stopped at the empty `stderr` (since `??` only skips
238
+ // nullish values) and shipped an empty error through the bridge — Python then
239
+ // surfaced the generic `bridge call '__agent__' failed`. See #2006.
240
+ it("surfaces abortReason for aborts that leave stderr empty", async () => {
241
+ mockAgents();
242
+ const runSpy = vi.spyOn(taskExecutor, "runSubprocess");
243
+ runSpy.mockImplementationOnce(async options =>
244
+ singleResult(options, {
245
+ exitCode: 1,
246
+ output: "",
247
+ stderr: "",
248
+ error: undefined,
249
+ aborted: true,
250
+ abortReason: "Subagent runtime limit exceeded (task.maxRuntimeMs=900000)",
251
+ }),
252
+ );
253
+ runSpy.mockImplementationOnce(async options =>
254
+ singleResult(options, {
255
+ exitCode: 1,
256
+ output: "",
257
+ stderr: " ",
258
+ error: " ",
259
+ aborted: true,
260
+ abortReason: "Cancelled by caller",
261
+ }),
262
+ );
263
+ runSpy.mockImplementationOnce(async options =>
264
+ singleResult(options, {
265
+ exitCode: 1,
266
+ output: "",
267
+ stderr: "",
268
+ error: undefined,
269
+ }),
270
+ );
271
+
272
+ await expect(runEvalAgent({ prompt: "slow" }, { session: makeSession() })).rejects.toThrow(
273
+ "Subagent runtime limit exceeded (task.maxRuntimeMs=900000)",
274
+ );
275
+ // Whitespace-only stderr/error must not mask abortReason either.
276
+ await expect(runEvalAgent({ prompt: "cancelled" }, { session: makeSession() })).rejects.toThrow(
277
+ "Cancelled by caller",
278
+ );
279
+ // Last resort: still produce a non-empty message even when nothing useful is set,
280
+ // so Python never falls back to `bridge call '__agent__' failed`.
281
+ await expect(runEvalAgent({ prompt: "blank" }, { session: makeSession() })).rejects.toThrow(
282
+ "agent() subagent 'task' failed.",
283
+ );
284
+ });
234
285
  });
235
286
 
236
287
  describe("agent() through eval runtimes", () => {
@@ -326,18 +377,6 @@ describe("agent() through eval runtimes", () => {
326
377
  singleResult(options, { output: "hello from python" }),
327
378
  );
328
379
 
329
- const probe = await executePython('print("probe")', {
330
- cwd: tempDir.path(),
331
- sessionId: `${sessionId}:probe`,
332
- sessionFile,
333
- kernelMode: "per-call",
334
- });
335
- if (probe.exitCode === undefined && probe.cancelled) {
336
- expect(probe.output).toBe("");
337
- return;
338
- }
339
- expect(probe.exitCode).toBe(0);
340
-
341
380
  const result = await executePython('print(agent("hi"))', {
342
381
  cwd: tempDir.path(),
343
382
  sessionId,
@@ -345,6 +384,10 @@ describe("agent() through eval runtimes", () => {
345
384
  kernelMode: "per-call",
346
385
  toolSession: session,
347
386
  });
387
+ if (result.exitCode === undefined && result.cancelled) {
388
+ expect(result.output).toBe("");
389
+ return; // kernel unavailable in this environment
390
+ }
348
391
 
349
392
  expect(result.exitCode).toBe(0);
350
393
  expect(result.output.trim()).toBe("hello from python");
@@ -373,22 +416,14 @@ describe("agent() through eval runtimes", () => {
373
416
  }
374
417
  });
375
418
 
376
- const probe = await executePython('print("probe")', {
377
- cwd: tempDir.path(),
378
- sessionId: `${sessionId}:probe`,
379
- sessionFile,
380
- kernelMode: "per-call",
381
- });
382
- if (probe.exitCode === undefined && probe.cancelled) {
383
- expect(probe.output).toBe("");
384
- return;
385
- }
386
- expect(probe.exitCode).toBe(0);
387
-
388
419
  const result = await executePython(
389
420
  'import json\nprint(json.dumps(parallel([lambda n=n: agent(n) for n in ["a", "b", "c", "d"]])))',
390
421
  { cwd: tempDir.path(), sessionId, sessionFile, kernelMode: "per-call", toolSession: session },
391
422
  );
423
+ if (result.exitCode === undefined && result.cancelled) {
424
+ expect(result.output).toBe("");
425
+ return; // kernel unavailable in this environment
426
+ }
392
427
 
393
428
  expect(result.exitCode).toBe(0);
394
429
  expect(JSON.parse(result.output.trim())).toEqual(["a", "b", "c", "d"]);
@@ -412,7 +447,14 @@ describe("agent() through eval runtimes", () => {
412
447
  // The host must respond the instant the cell aborts so the kernel can
413
448
  // unwind via KeyboardInterrupt instead of being hard-killed (which used to
414
449
  // surface "[kernel] Python kernel shutdown" and lose all session state).
450
+ let inFlight = 0;
451
+ let markSaturated: (() => void) | undefined;
452
+ const saturated = new Promise<void>(resolve => {
453
+ markSaturated = resolve;
454
+ });
415
455
  vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => {
456
+ // task.maxConcurrency=6 → six bridge calls block at once; signal then.
457
+ if (++inFlight >= 6) markSaturated?.();
416
458
  await Bun.sleep(9000); // deliberately ignores options.signal
417
459
  return singleResult(options, { output: options.assignment ?? "" });
418
460
  });
@@ -432,8 +474,9 @@ describe("agent() through eval runtimes", () => {
432
474
  expect(seed.exitCode).toBe(0);
433
475
 
434
476
  const ac = new AbortController();
435
- // Abort ~1s in, after the worker threads are blocked in their bridge calls.
436
- setTimeout(() => ac.abort(new Error("external interrupt")), 1000);
477
+ // Abort the instant all six worker threads are confirmed blocked in their
478
+ // bridge calls (condition-driven) instead of waiting a fixed wall second.
479
+ void saturated.then(() => ac.abort(new Error("external interrupt")));
437
480
 
438
481
  const start = Date.now();
439
482
  const result = await executePython(
@@ -568,12 +611,12 @@ describe("agent() through eval runtimes", () => {
568
611
  // of its own. The bridge pause must make that delegated time invisible to
569
612
  // the watchdog.
570
613
  vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => {
571
- await Bun.sleep(200);
614
+ await Bun.sleep(40);
572
615
  return singleResult(options, { output: "done" });
573
616
  });
574
617
 
575
618
  const ops: string[] = [];
576
- using idle = new IdleTimeout(60);
619
+ using idle = new IdleTimeout(20);
577
620
  const result = await runEvalAgent(
578
621
  { prompt: "investigate" },
579
622
  {
@@ -591,7 +634,7 @@ describe("agent() through eval runtimes", () => {
591
634
  expect(ops).toEqual([EVAL_TIMEOUT_PAUSE_OP, EVAL_TIMEOUT_RESUME_OP]);
592
635
  expect(idle.signal.aborted).toBe(false);
593
636
 
594
- await Bun.sleep(90);
637
+ await Bun.sleep(60);
595
638
  expect(idle.signal.aborted).toBe(true);
596
639
  });
597
640
 
@@ -604,7 +647,7 @@ describe("agent() through eval runtimes", () => {
604
647
  // They render as status, but timeout accounting is controlled only by the
605
648
  // bridge pause/resume events.
606
649
  vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => {
607
- for (let i = 0; i < 40; i++) {
650
+ for (let i = 0; i < 20; i++) {
608
651
  options.onProgress?.({
609
652
  index: options.index,
610
653
  id: options.id,
@@ -621,13 +664,13 @@ describe("agent() through eval runtimes", () => {
621
664
  cost: 0,
622
665
  durationMs: i * 10,
623
666
  });
624
- await Bun.sleep(10);
667
+ await Bun.sleep(5);
625
668
  }
626
669
  return singleResult(options, { output: "done" });
627
670
  });
628
671
 
629
672
  const ops: string[] = [];
630
- using idle = new IdleTimeout(80);
673
+ using idle = new IdleTimeout(40);
631
674
  const result = await runEvalAgent(
632
675
  { prompt: "investigate" },
633
676
  {
@@ -13,7 +13,7 @@ import subagentUserPromptTemplate from "../prompts/system/subagent-user-prompt.m
13
13
  import * as taskDiscovery from "../task/discovery";
14
14
  import * as taskExecutor from "../task/executor";
15
15
  import { AgentOutputManager } from "../task/output-manager";
16
- import type { AgentDefinition, AgentProgress } from "../task/types";
16
+ import type { AgentDefinition, AgentProgress, SingleResult } from "../task/types";
17
17
  import type { ToolSession } from "../tools";
18
18
  import { ToolError } from "../tools/tool-errors";
19
19
  import { withBridgeTimeoutPause } from "./bridge-timeout";
@@ -173,6 +173,26 @@ function emitProgressStatus(emitStatus: ((event: JsStatusEvent) => void) | undef
173
173
  });
174
174
  }
175
175
 
176
+ /**
177
+ * Coalesce a subagent failure into a non-empty, human-meaningful error message.
178
+ *
179
+ * When the executor aborts a subagent (runtime limit, parent cancellation, …)
180
+ * the actionable explanation lives on `abortReason`, while `error`/`stderr`
181
+ * are routinely empty strings. Plain `??` coalescing stops at the empty string
182
+ * and ships an empty error through the bridge — Python then surfaces only the
183
+ * generic `bridge call '__agent__' failed`. See #2006.
184
+ */
185
+ function buildSubagentFailureMessage(agentName: string, result: SingleResult): string {
186
+ const abortReason = trimToUndefined(result.abortReason);
187
+ if (result.aborted && abortReason) return abortReason;
188
+ return (
189
+ trimToUndefined(result.error) ??
190
+ trimToUndefined(result.stderr) ??
191
+ abortReason ??
192
+ `agent() subagent '${agentName}' failed.`
193
+ );
194
+ }
195
+
176
196
  /**
177
197
  * Run a single subagent on behalf of an eval cell's `agent()` call.
178
198
  */
@@ -225,7 +245,6 @@ export async function runEvalAgent(args: unknown, options: EvalAgentBridgeOption
225
245
  getSessionId: options.session.getSessionId ?? (() => null),
226
246
  };
227
247
  const parentArtifactManager = options.session.getArtifactManager?.() ?? undefined;
228
- const parentEvalSessionId = options.session.getEvalSessionId?.() ?? undefined;
229
248
  const mcpManager = options.session.mcpManager ?? MCPManager.instance();
230
249
  const { sessionFile, artifactsDir, contextFile } = await getArtifacts(options.session);
231
250
  const outputManager = getOutputManager(options.session);
@@ -260,6 +279,12 @@ export async function runEvalAgent(args: unknown, options: EvalAgentBridgeOption
260
279
  authStorage: options.session.authStorage,
261
280
  modelRegistry: options.session.modelRegistry,
262
281
  settings: options.session.settings,
282
+ // Eval `agent()` subagents are never wall-clock capped: the parent
283
+ // cell's idle watchdog is suspended for the whole bridge call
284
+ // (withBridgeTimeoutPause), so a long-running phase/recovery workflow
285
+ // must not be killed by `task.maxRuntimeMs`. Force the limit off
286
+ // regardless of the inherited session setting.
287
+ maxRuntimeMs: 0,
263
288
  mcpManager,
264
289
  contextFiles,
265
290
  skills: availableSkills,
@@ -271,14 +296,16 @@ export async function runEvalAgent(args: unknown, options: EvalAgentBridgeOption
271
296
  parentHindsightSessionState: options.session.getHindsightSessionState?.(),
272
297
  parentMnemopiSessionState: options.session.getMnemopiSessionState?.(),
273
298
  parentTelemetry: options.session.getTelemetry?.(),
274
- parentEvalSessionId,
299
+ // Deliberately omit parentEvalSessionId: the parent's Python kernel is
300
+ // blocked on this bridge call, so sharing the eval session would deadlock
301
+ // (subagent queues behind the parent's in-flight execution, parent waits
302
+ // for subagent → circular). Each bridge-spawned subagent gets its own
303
+ // eval session with an independent kernel.
275
304
  }),
276
305
  );
277
306
 
278
- if (result.exitCode !== 0 || result.error) {
279
- const failureMessage =
280
- result.error ?? result.stderr ?? result.abortReason ?? `agent() subagent '${agentName}' failed.`;
281
- throw new ToolError(failureMessage);
307
+ if (result.exitCode !== 0 || result.error || result.aborted) {
308
+ throw new ToolError(buildSubagentFailureMessage(agentName, result));
282
309
  }
283
310
 
284
311
  options.session.recordEvalSubagentUsage?.(result.usage?.output ?? 0);
@@ -354,6 +354,7 @@ export class ExtensionRunner {
354
354
  "ctrl+o": true,
355
355
  "ctrl+t": true,
356
356
  "ctrl+g": true,
357
+ "alt+m": true,
357
358
  // Default chord for `app.message.followUp` (Windows Terminal can't deliver Ctrl+Enter; #1903).
358
359
  "ctrl+q": true,
359
360
  "shift+tab": true,
@@ -25,7 +25,6 @@ export async function runDoctorChecks(): Promise<DoctorCheck[]> {
25
25
  const apiKeys = [
26
26
  { name: "ANTHROPIC_API_KEY", description: "Anthropic API" },
27
27
  { name: "OPENAI_API_KEY", description: "OpenAI API" },
28
- { name: "PERPLEXITY_API_KEY", description: "Perplexity search" },
29
28
  { name: "EXA_API_KEY", description: "Exa search" },
30
29
  ];
31
30
 
@@ -0,0 +1,49 @@
1
+ import { getProjectDir, logger } from "@oh-my-pi/pi-utils";
2
+
3
+ type MarketplaceAutoUpdateMode = "off" | "notify" | "auto";
4
+
5
+ interface MarketplaceAutoUpdateOptions {
6
+ autoUpdate: MarketplaceAutoUpdateMode;
7
+ resolveActiveProjectRegistryPath: (cwd: string) => Promise<string | null>;
8
+ clearPluginRootsCache: () => void;
9
+ }
10
+
11
+ export function scheduleMarketplaceAutoUpdate(options: MarketplaceAutoUpdateOptions): void {
12
+ if (options.autoUpdate === "off") {
13
+ return;
14
+ }
15
+
16
+ void runMarketplaceAutoUpdate(options);
17
+ }
18
+
19
+ async function runMarketplaceAutoUpdate(options: MarketplaceAutoUpdateOptions): Promise<void> {
20
+ try {
21
+ // Startup perf: marketplace manager pulls scraper/fetch/cache code; keep it out of the initial TUI graph.
22
+ const {
23
+ MarketplaceManager,
24
+ getInstalledPluginsRegistryPath,
25
+ getMarketplacesCacheDir,
26
+ getMarketplacesRegistryPath,
27
+ getPluginsCacheDir,
28
+ } = await import("./marketplace");
29
+ const mgr = new MarketplaceManager({
30
+ marketplacesRegistryPath: getMarketplacesRegistryPath(),
31
+ installedRegistryPath: getInstalledPluginsRegistryPath(),
32
+ projectInstalledRegistryPath: (await options.resolveActiveProjectRegistryPath(getProjectDir())) ?? undefined,
33
+ marketplacesCacheDir: getMarketplacesCacheDir(),
34
+ pluginsCacheDir: getPluginsCacheDir(),
35
+ clearPluginRootsCache: options.clearPluginRootsCache,
36
+ });
37
+ await mgr.refreshStaleMarketplaces();
38
+ const updates = await mgr.checkForUpdates();
39
+ if (updates.length === 0) return;
40
+ if (options.autoUpdate === "auto") {
41
+ await mgr.upgradeAllPlugins();
42
+ logger.debug(`Auto-upgraded ${updates.length} marketplace plugin(s)`);
43
+ } else {
44
+ logger.debug(`${updates.length} marketplace plugin update(s) available — /marketplace upgrade`);
45
+ }
46
+ } catch {
47
+ // Silently ignore — network failure, corrupt data, offline.
48
+ }
49
+ }
@@ -8,7 +8,7 @@ import type { Theme, ThemeColor } from "../../modes/theme/theme";
8
8
  import goalDescription from "../../prompts/tools/goal.md" with { type: "text" };
9
9
  import { formatDuration } from "../../slash-commands/helpers/format";
10
10
  import type { ToolSession } from "../../tools";
11
- import { formatErrorMessage, TRUNCATE_LENGTHS } from "../../tools/render-utils";
11
+ import { formatErrorDetail, TRUNCATE_LENGTHS } from "../../tools/render-utils";
12
12
  import { ToolError } from "../../tools/tool-errors";
13
13
  import { renderStatusLine, truncateToWidth } from "../../tui";
14
14
  import { completionBudgetReport, remainingTokens } from "../runtime";
@@ -190,7 +190,7 @@ export const goalToolRenderer = {
190
190
 
191
191
  if (result.isError) {
192
192
  const header = renderStatusLine({ icon: "error", title: "Goal", description }, uiTheme);
193
- const body = formatErrorMessage(fallbackText || "Goal tool failed", uiTheme);
193
+ const body = formatErrorDetail(fallbackText || "Goal tool failed", uiTheme);
194
194
  return new Text([header, body].join("\n"), 0, 0);
195
195
  }
196
196