@oh-my-pi/pi-coding-agent 16.1.15 → 16.1.16

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 (85) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/cli.js +3725 -4029
  3. package/dist/types/cli/args.d.ts +2 -5
  4. package/dist/types/cli/flag-tables.d.ts +2 -2
  5. package/dist/types/cli/session-picker.d.ts +4 -2
  6. package/dist/types/commands/launch.d.ts +1 -1
  7. package/dist/types/config/settings-schema.d.ts +12 -1
  8. package/dist/types/eval/agent-bridge.d.ts +19 -0
  9. package/dist/types/eval/js/shared/helpers.d.ts +1 -13
  10. package/dist/types/eval/js/shared/types.d.ts +1 -1
  11. package/dist/types/eval/js/worker-protocol.d.ts +1 -1
  12. package/dist/types/eval/py/executor.d.ts +1 -1
  13. package/dist/types/internal-urls/local-protocol.d.ts +18 -1
  14. package/dist/types/main.d.ts +2 -0
  15. package/dist/types/modes/components/plugin-settings.d.ts +5 -0
  16. package/dist/types/modes/components/session-selector.d.ts +25 -0
  17. package/dist/types/task/isolation-runner.d.ts +128 -0
  18. package/dist/types/task/worktree.d.ts +14 -1
  19. package/dist/types/thinking.d.ts +15 -0
  20. package/dist/types/tools/eval-render.d.ts +3 -0
  21. package/dist/types/tools/eval.d.ts +11 -17
  22. package/dist/types/tools/todo.d.ts +26 -28
  23. package/dist/types/tui/output-block.d.ts +8 -0
  24. package/dist/types/utils/image-resize.d.ts +2 -0
  25. package/dist/types/web/search/providers/exa.d.ts +2 -0
  26. package/package.json +12 -12
  27. package/scripts/build-binary.ts +18 -4
  28. package/src/cli/args.ts +4 -5
  29. package/src/cli/flag-tables.ts +3 -3
  30. package/src/cli/gallery-fixtures/interaction.ts +6 -9
  31. package/src/cli/gallery-fixtures/shell.ts +15 -23
  32. package/src/cli/session-picker.ts +17 -3
  33. package/src/commands/launch.ts +3 -3
  34. package/src/config/settings-schema.ts +13 -1
  35. package/src/edit/renderer.ts +34 -12
  36. package/src/eval/__tests__/agent-bridge.test.ts +462 -3
  37. package/src/eval/__tests__/helpers-local-roots.test.ts +2 -5
  38. package/src/eval/__tests__/julia-prelude.test.ts +1 -30
  39. package/src/eval/__tests__/prelude-agent.test.ts +42 -8
  40. package/src/eval/agent-bridge.ts +301 -71
  41. package/src/eval/jl/prelude.jl +32 -227
  42. package/src/eval/jl/runner.jl +38 -12
  43. package/src/eval/js/shared/helpers.ts +1 -114
  44. package/src/eval/js/shared/prelude.txt +13 -27
  45. package/src/eval/js/shared/runtime.ts +0 -6
  46. package/src/eval/js/shared/types.ts +1 -1
  47. package/src/eval/js/worker-protocol.ts +1 -1
  48. package/src/eval/py/__tests__/prelude.test.ts +13 -0
  49. package/src/eval/py/executor.ts +1 -1
  50. package/src/eval/py/prelude.py +47 -105
  51. package/src/eval/py/runner.py +0 -6
  52. package/src/eval/rb/prelude.rb +21 -189
  53. package/src/eval/rb/runner.rb +116 -9
  54. package/src/export/html/tool-views.generated.js +29 -29
  55. package/src/internal-urls/docs-index.generated.txt +1 -1
  56. package/src/internal-urls/local-protocol.ts +100 -53
  57. package/src/main.ts +15 -4
  58. package/src/modes/acp/acp-event-mapper.ts +7 -2
  59. package/src/modes/components/plugin-settings.ts +7 -1
  60. package/src/modes/components/session-selector.ts +143 -29
  61. package/src/modes/controllers/command-controller.ts +5 -0
  62. package/src/modes/rpc/rpc-mode.ts +6 -0
  63. package/src/modes/utils/copy-targets.ts +7 -2
  64. package/src/prompts/system/system-prompt.md +3 -3
  65. package/src/prompts/system/workflow-notice.md +3 -3
  66. package/src/prompts/tools/bash.md +16 -0
  67. package/src/prompts/tools/eval.md +19 -19
  68. package/src/prompts/tools/todo.md +1 -1
  69. package/src/session/agent-session.ts +231 -50
  70. package/src/task/index.ts +61 -207
  71. package/src/task/isolation-runner.ts +354 -0
  72. package/src/task/worktree.ts +46 -9
  73. package/src/thinking.ts +20 -0
  74. package/src/tools/ask.ts +44 -38
  75. package/src/tools/bash.ts +9 -2
  76. package/src/tools/browser/tab-worker.ts +1 -1
  77. package/src/tools/eval-render.ts +34 -27
  78. package/src/tools/eval.ts +100 -103
  79. package/src/tools/index.ts +8 -1
  80. package/src/tools/read.ts +136 -60
  81. package/src/tools/todo.ts +60 -64
  82. package/src/tui/code-cell.ts +1 -1
  83. package/src/tui/output-block.ts +11 -0
  84. package/src/utils/image-resize.ts +30 -0
  85. package/src/web/search/providers/exa.ts +85 -1
package/src/tools/todo.ts CHANGED
@@ -52,21 +52,18 @@ const InitListEntry = type({
52
52
  items: type("string").describe("task content").array().atLeastLength(1).describe("tasks for this phase"),
53
53
  });
54
54
 
55
- const TodoOpEntry = type({
55
+ const todoSchema = type({
56
56
  op: TodoOp,
57
57
  "list?": InitListEntry.array().describe("phased task list (init)"),
58
58
  "task?": type("string").describe("task content"),
59
59
  "phase?": type("string").describe("phase name"),
60
60
  "items?": type("string").describe("task content").array().atLeastLength(1).describe("tasks to append"),
61
- });
62
-
63
- const todoSchema = type({
64
- ops: TodoOpEntry.array().atLeastLength(1).describe("ordered todo operations"),
65
- }).describe("apply ordered todo operations");
61
+ }).describe("apply a single todo operation");
66
62
 
67
63
  type TodoParams = TodoSchema;
68
64
  type TodoSchema = typeof todoSchema.infer;
69
- type TodoOpEntryValue = TodoParams["ops"][number];
65
+ /** A single todo op entry (the params object itself). */
66
+ type TodoOpEntryValue = TodoParams;
70
67
 
71
68
  // =============================================================================
72
69
  // State helpers
@@ -402,10 +399,7 @@ function applyEntry(phases: TodoPhase[], entry: TodoOpEntryValue, errors: string
402
399
 
403
400
  function applyParams(phases: TodoPhase[], params: TodoParams): { phases: TodoPhase[]; errors: string[] } {
404
401
  const errors: string[] = [];
405
- let next = phases;
406
- for (const entry of params.ops) {
407
- next = applyEntry(next, entry, errors);
408
- }
402
+ const next = applyEntry(phases, params, errors);
409
403
  normalizeInProgressTask(next);
410
404
  return { phases: next, errors };
411
405
  }
@@ -413,9 +407,15 @@ function applyParams(phases: TodoPhase[], params: TodoParams): { phases: TodoPha
413
407
  /** Apply an array of `todo`-style ops to existing phases. Used by /todo slash command. */
414
408
  export function applyOpsToPhases(
415
409
  currentPhases: TodoPhase[],
416
- ops: TodoParams["ops"],
410
+ ops: TodoParams[],
417
411
  ): { phases: TodoPhase[]; errors: string[] } {
418
- return applyParams(clonePhases(currentPhases), { ops });
412
+ const errors: string[] = [];
413
+ let next = clonePhases(currentPhases);
414
+ for (const op of ops) {
415
+ next = applyEntry(next, op, errors);
416
+ }
417
+ normalizeInProgressTask(next);
418
+ return { phases: next, errors };
419
419
  }
420
420
 
421
421
  // =============================================================================
@@ -572,64 +572,44 @@ export class TodoTool implements AgentTool<typeof todoSchema, TodoToolDetails> {
572
572
  {
573
573
  caption: "Initial setup (multi-phase)",
574
574
  call: {
575
- ops: [
576
- {
577
- op: "init",
578
- list: [
579
- { phase: "Foundation", items: ["Scaffold crate", "Wire workspace"] },
580
- { phase: "Auth", items: ["Port credential store", "Wire OAuth providers"] },
581
- { phase: "Verification", items: ["Run cargo test"] },
582
- ],
583
- },
575
+ op: "init",
576
+ list: [
577
+ { phase: "Foundation", items: ["Scaffold crate", "Wire workspace"] },
578
+ { phase: "Auth", items: ["Port credential store", "Wire OAuth providers"] },
579
+ { phase: "Verification", items: ["Run cargo test"] },
584
580
  ],
585
581
  },
586
582
  },
587
583
  {
588
584
  caption: "View current state (read-only)",
589
- call: {
590
- ops: [{ op: "view" }],
591
- },
585
+ call: { op: "view" },
592
586
  },
593
587
  {
594
588
  caption: "Initial setup (single phase)",
595
589
  call: {
596
- ops: [
597
- {
598
- op: "init",
599
- list: [{ phase: "Implementation", items: ["Apply fix", "Run tests"] }],
600
- },
601
- ],
590
+ op: "init",
591
+ list: [{ phase: "Implementation", items: ["Apply fix", "Run tests"] }],
602
592
  },
603
593
  },
604
594
  {
605
595
  caption: "Complete one task",
606
- call: {
607
- ops: [{ op: "done", task: "Wire workspace" }],
608
- },
596
+ call: { op: "done", task: "Wire workspace" },
609
597
  },
610
598
  {
611
599
  caption: "Complete a whole phase",
612
- call: {
613
- ops: [{ op: "done", phase: "Auth" }],
614
- },
600
+ call: { op: "done", phase: "Auth" },
615
601
  },
616
602
  {
617
603
  caption: "Remove all tasks",
618
- call: {
619
- ops: [{ op: "rm" }],
620
- },
604
+ call: { op: "rm" },
621
605
  },
622
606
  {
623
607
  caption: "Drop one task",
624
- call: {
625
- ops: [{ op: "drop", task: "Run cargo test" }],
626
- },
608
+ call: { op: "drop", task: "Run cargo test" },
627
609
  },
628
610
  {
629
611
  caption: "Append tasks to a phase",
630
- call: {
631
- ops: [{ op: "append", phase: "Auth", items: ["Handle retries", "Run tests"] }],
632
- },
612
+ call: { op: "append", phase: "Auth", items: ["Handle retries", "Run tests"] },
633
613
  },
634
614
  ];
635
615
  readonly loadMode = "discoverable";
@@ -646,7 +626,7 @@ export class TodoTool implements AgentTool<typeof todoSchema, TodoToolDetails> {
646
626
  ): Promise<AgentToolResult<TodoToolDetails>> {
647
627
  const previousPhases = clonePhases(this.session.getTodoPhases?.() ?? []);
648
628
  // Pure-view calls are reads: no normalization, no state write.
649
- const readOnly = params.ops.every(entry => entry.op === "view");
629
+ const readOnly = params.op === "view";
650
630
  const { phases: updated, errors } = readOnly
651
631
  ? { phases: previousPhases, errors: [] as string[] }
652
632
  : applyParams(clonePhases(previousPhases), params);
@@ -673,15 +653,32 @@ export class TodoTool implements AgentTool<typeof todoSchema, TodoToolDetails> {
673
653
  // TUI Renderer
674
654
  // =============================================================================
675
655
 
676
- type TodoRenderArgs = {
677
- ops?: Array<{
678
- op?: string;
679
- task?: string;
680
- phase?: string;
681
- items?: string[];
682
- }>;
656
+ type TodoRenderOp = {
657
+ op?: string;
658
+ task?: string;
659
+ phase?: string;
660
+ items?: string[];
661
+ };
662
+
663
+ /** New single-op shape `{op,...}`; legacy `{ops:[...]}` still seen in old transcripts. */
664
+ type TodoRenderArgs = TodoRenderOp & {
665
+ ops?: TodoRenderOp[];
683
666
  };
684
667
 
668
+ /**
669
+ * Normalize streaming/legacy render args to a flat op list. Accepts the new
670
+ * top-level `{op,...}` shape (returned as a one-element list), the legacy
671
+ * `{ops:[...]}` batch from old transcripts/collab-web, and partially-parsed
672
+ * streaming deltas (non-array `ops`, non-object entries) without crashing.
673
+ */
674
+ function normalizeTodoArg(args: TodoRenderArgs | undefined): TodoRenderOp[] {
675
+ if (!args || typeof args !== "object") return [];
676
+ if (Array.isArray(args.ops)) {
677
+ return args.ops.filter((entry): entry is TodoRenderOp => !!entry && typeof entry === "object");
678
+ }
679
+ return typeof args.op === "string" ? [args] : [];
680
+ }
681
+
685
682
  // =============================================================================
686
683
  // Phase numbering (display-only)
687
684
  // =============================================================================
@@ -794,7 +791,7 @@ function computeTouchedPhases(
794
791
  for (const transition of completedTasks) touched.add(transition.phase);
795
792
  // Phases explicitly named by the ops that ran. `init` replaces the whole
796
793
  // list, so the entire plan is fresh and every phase counts as touched.
797
- const ops = Array.isArray(args?.ops) ? args.ops : [];
794
+ const ops = normalizeTodoArg(args);
798
795
  for (const op of ops) {
799
796
  if (!op || typeof op !== "object") continue;
800
797
  if (op.op === "init") {
@@ -823,18 +820,17 @@ function formatPhaseSummary(phase: TodoPhase, oneBasedIndex: number, uiTheme: Th
823
820
 
824
821
  export const todoToolRenderer = {
825
822
  renderCall(args: TodoRenderArgs, options: RenderResultOptions, uiTheme: Theme): Component {
826
- // `args` here is the raw partially-parsed JSON from the streaming
827
- // tool-call delta and may not satisfy `TodoRenderArgs` at runtime:
828
- // `parseStreamingJson` can hand back `{ ops: "[" }` mid-delta, or
829
- // entries that are `null` / strings before fields stream. Guard
830
- // against non-array `ops` and non-object entries so a malformed
831
- // delta never breaks the TUI render loop (#2005).
832
- const opsList = Array.isArray(args?.ops) ? args.ops : [];
823
+ // `args` is the raw partially-parsed JSON from the streaming tool-call
824
+ // delta and may not satisfy `TodoRenderArgs` at runtime:
825
+ // `parseStreamingJson` can hand back `{ op: 1 }` mid-delta, or a legacy
826
+ // `{ ops: "[" }` shape before fields stream. `normalizeTodoArg` guards
827
+ // both the new single-op and legacy batch shapes so a malformed delta
828
+ // never breaks the TUI render loop (#2005).
829
+ const opsList = normalizeTodoArg(args);
833
830
  const ops =
834
831
  opsList.length === 0
835
832
  ? ["update"]
836
- : opsList.map(entry => {
837
- const e = entry && typeof entry === "object" ? entry : ({} as NonNullable<typeof entry>);
833
+ : opsList.map(e => {
838
834
  const parts = [e.op ?? "update"];
839
835
  if (e.task) parts.push(e.task);
840
836
  if (e.phase) parts.push(e.phase);
@@ -80,7 +80,7 @@ function formatHeader(options: CodeCellOptions, theme: Theme): { title: string;
80
80
  parts.push(icon);
81
81
  }
82
82
  }
83
- if (index !== undefined && total !== undefined) {
83
+ if (index !== undefined && total !== undefined && total > 1) {
84
84
  parts.push(theme.fg("accent", `[${index + 1}/${total}]`));
85
85
  }
86
86
  if (title) {
@@ -46,6 +46,17 @@ function normalizeContentPaddingLeft(value: number | undefined): number {
46
46
  return Math.max(0, Math.floor(value));
47
47
  }
48
48
 
49
+ /**
50
+ * Inner content width that {@link renderOutputBlock} wraps its body to, for a
51
+ * given outer `width`: both vertical borders (1 cell each) plus the left
52
+ * content padding. Renderers that size a tail window MUST budget visual rows
53
+ * against this, not the outer width — otherwise the block re-wraps their lines
54
+ * into more rows than they counted and the box overflows its intended height.
55
+ */
56
+ export function outputBlockContentWidth(width: number, contentPaddingLeft?: number): number {
57
+ return Math.max(1, width - 2 - normalizeContentPaddingLeft(contentPaddingLeft));
58
+ }
59
+
49
60
  export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): string[] {
50
61
  const { header, headerMeta, state, sections = [], width, applyBg = true } = options;
51
62
  const h = theme.boxRound.horizontal;
@@ -3,6 +3,8 @@ import type { ImageContent } from "@oh-my-pi/pi-ai";
3
3
  export interface ImageResizeOptions {
4
4
  maxWidth?: number;
5
5
  maxHeight?: number;
6
+ /** Smallest allowed edge length (px). Inputs below this are scaled up. */
7
+ minDimension?: number;
6
8
  maxBytes?: number;
7
9
  jpegQuality?: number;
8
10
  excludeWebP?: boolean;
@@ -23,6 +25,13 @@ export interface ResizedImage {
23
25
  // binding constraint once images are downsized to 1568px (Anthropic's internal threshold).
24
26
  const DEFAULT_MAX_BYTES = 500 * 1024;
25
27
 
28
+ // Smallest edge length (px) vision backends reliably accept. They tile images into
29
+ // fixed patches (Anthropic uses 28px) and reject degenerate sub-patch images — e.g.
30
+ // the 1x1 PNG an empty chart render emits — with a hard 400 ("Could not process
31
+ // image") that can poison the whole request. 200px is the smallest size Anthropic
32
+ // documents as valid (200x200 = 64 visual tokens); undersized images are scaled up.
33
+ const DEFAULT_MIN_DIMENSION = 200;
34
+
26
35
  const DEFAULT_OPTIONS: Required<Omit<ImageResizeOptions, "excludeWebP">> = {
27
36
  // Anthropic's "internal recommended size" — Claude internally caps images at
28
37
  // 1568px on the longest edge before vision processing.
@@ -30,6 +39,7 @@ const DEFAULT_OPTIONS: Required<Omit<ImageResizeOptions, "excludeWebP">> = {
30
39
  maxHeight: 1568,
31
40
  maxBytes: DEFAULT_MAX_BYTES,
32
41
  jpegQuality: 80,
42
+ minDimension: DEFAULT_MIN_DIMENSION,
33
43
  };
34
44
 
35
45
  /**
@@ -87,7 +97,12 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
87
97
  // still get JPEG-compressed.
88
98
  const originalSize = inputBuffer.length;
89
99
  const comfortableSize = opts.maxBytes / 4;
100
+ // Clamp the floor to the caps so an unusually small max can't demand an
101
+ // impossible "≥ min and ≤ max" target.
102
+ const minDimension = Math.min(opts.minDimension, opts.maxWidth, opts.maxHeight);
90
103
  if (
104
+ originalWidth >= minDimension &&
105
+ originalHeight >= minDimension &&
91
106
  originalWidth <= opts.maxWidth &&
92
107
  originalHeight <= opts.maxHeight &&
93
108
  originalSize <= comfortableSize &&
@@ -120,6 +135,21 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
120
135
  targetHeight = opts.maxHeight;
121
136
  }
122
137
 
138
+ // Lift undersized inputs up to the minimum. A uniform scale covers the
139
+ // common case (icons, the 1x1 chart) without distortion; an aspect ratio
140
+ // too extreme to satisfy both floor and cap falls back to stretching the
141
+ // lagging edge up to the floor via the default fit:"fill" resize.
142
+ if (targetWidth < minDimension || targetHeight < minDimension) {
143
+ const shortEdge = Math.min(targetWidth, targetHeight);
144
+ const upscale = Math.min(minDimension / shortEdge, opts.maxWidth / targetWidth, opts.maxHeight / targetHeight);
145
+ if (upscale > 1) {
146
+ targetWidth = Math.round(targetWidth * upscale);
147
+ targetHeight = Math.round(targetHeight * upscale);
148
+ }
149
+ targetWidth = Math.min(opts.maxWidth, Math.max(minDimension, targetWidth));
150
+ targetHeight = Math.min(opts.maxHeight, Math.max(minDimension, targetHeight));
151
+ }
152
+
123
153
  // First-attempt encoder: try PNG and JPEG (+ WebP if not excluded) — return smallest.
124
154
  // PNG wins for line art / few-color UI; JPEG wins for photographic content;
125
155
  // WebP usually beats JPEG by 25–35% but is disabled when OMP_NO_WEBP is set
@@ -7,7 +7,7 @@
7
7
  * them into a combined `answer` string on the SearchResponse.
8
8
  */
9
9
  import { type ApiKey, type AuthStorage, type FetchImpl, getEnvApiKey, withAuth } from "@oh-my-pi/pi-ai";
10
- import { settings } from "../../../config/settings";
10
+ import { getDefault, settings } from "../../../config/settings";
11
11
  import { findApiKey, isSearchResponse } from "../../../exa/mcp-client";
12
12
  import { parseSSE } from "../../../mcp/json-rpc";
13
13
  import type { SearchResponse, SearchSource } from "../../../web/search/types";
@@ -18,6 +18,88 @@ import { SearchProvider } from "./base";
18
18
  import { classifyProviderHttpError, withHardTimeout } from "./utils";
19
19
 
20
20
  const EXA_API_URL = "https://api.exa.ai/search";
21
+ const DEFAULT_EXA_SEARCH_DELAY_MS = getDefault("exa.searchDelayMs");
22
+
23
+ let nextExaSearchRequestAt = 0;
24
+ let exaSearchThrottle = Promise.resolve();
25
+
26
+ function configuredExaSearchDelayMs(): number {
27
+ try {
28
+ const delayMs = settings.get("exa.searchDelayMs");
29
+ return Number.isFinite(delayMs) && delayMs > 0 ? Math.floor(delayMs) : 0;
30
+ } catch {
31
+ return DEFAULT_EXA_SEARCH_DELAY_MS;
32
+ }
33
+ }
34
+
35
+ function rejectWithAbortReason(reject: (reason?: unknown) => void, signal: AbortSignal): void {
36
+ try {
37
+ signal.throwIfAborted();
38
+ reject(new DOMException("The operation was aborted.", "AbortError"));
39
+ } catch (error) {
40
+ reject(error);
41
+ }
42
+ }
43
+
44
+ function abortableSleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
45
+ if (ms <= 0) return Promise.resolve();
46
+ signal?.throwIfAborted();
47
+ const { promise, resolve, reject } = Promise.withResolvers<void>();
48
+ let timer: NodeJS.Timeout | undefined;
49
+ const cleanup = (): void => {
50
+ if (timer) {
51
+ clearTimeout(timer);
52
+ timer = undefined;
53
+ }
54
+ signal?.removeEventListener("abort", onAbort);
55
+ };
56
+ const onAbort = (): void => {
57
+ cleanup();
58
+ if (signal) rejectWithAbortReason(reject, signal);
59
+ };
60
+ timer = setTimeout(() => {
61
+ cleanup();
62
+ resolve();
63
+ }, ms);
64
+ signal?.addEventListener("abort", onAbort, { once: true });
65
+ if (signal?.aborted) onAbort();
66
+ return promise;
67
+ }
68
+
69
+ function waitUntilDoneOrAborted<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
70
+ if (!signal) return promise;
71
+ signal.throwIfAborted();
72
+ const { promise: aborted, reject } = Promise.withResolvers<never>();
73
+ const onAbort = (): void => rejectWithAbortReason(reject, signal);
74
+ signal.addEventListener("abort", onAbort, { once: true });
75
+ return Promise.race([promise, aborted]).finally(() => {
76
+ signal.removeEventListener("abort", onAbort);
77
+ });
78
+ }
79
+
80
+ async function waitForExaSearchSlot(signal: AbortSignal | undefined): Promise<void> {
81
+ const delayMs = configuredExaSearchDelayMs();
82
+ if (delayMs <= 0) return;
83
+
84
+ const prior = exaSearchThrottle.catch(() => {});
85
+ const queued = prior.then(async () => {
86
+ signal?.throwIfAborted();
87
+ const waitMs = Math.max(0, nextExaSearchRequestAt - Date.now());
88
+ if (waitMs > 0) {
89
+ await abortableSleep(waitMs, signal);
90
+ }
91
+ signal?.throwIfAborted();
92
+ nextExaSearchRequestAt = Date.now() + delayMs;
93
+ });
94
+ exaSearchThrottle = queued.catch(() => {});
95
+ await waitUntilDoneOrAborted(queued, signal);
96
+ }
97
+
98
+ /** Reset Exa request pacing state for isolated provider tests. */
99
+ export function resetExaSearchThrottleForTest(): void {
100
+ nextExaSearchRequestAt = 0;
101
+ exaSearchThrottle = Promise.resolve();
102
+ }
21
103
 
22
104
  type ExaSearchType = "neural" | "fast" | "auto" | "deep";
23
105
 
@@ -224,6 +306,7 @@ async function callExaSearch(apiKey: string, params: ExaSearchParams): Promise<E
224
306
  const body = buildExaRequestBody(params);
225
307
 
226
308
  const fetchImpl = params.fetch ?? fetch;
309
+ await waitForExaSearchSlot(params.signal);
227
310
  const response = await fetchImpl(EXA_API_URL, {
228
311
  method: "POST",
229
312
  headers: {
@@ -260,6 +343,7 @@ async function callExaMcpSearch(params: ExaSearchParams): Promise<ExaSearchRespo
260
343
  if (apiKey) query.set("exaApiKey", apiKey);
261
344
  query.set("tools", "web_search_exa");
262
345
  const fetchImpl = params.fetch ?? fetch;
346
+ await waitForExaSearchSlot(params.signal);
263
347
  const response = await fetchImpl(`https://mcp.exa.ai/mcp?${query.toString()}`, {
264
348
  method: "POST",
265
349
  headers: {