@oh-my-pi/pi-coding-agent 15.0.0 → 15.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 (165) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/examples/extensions/plan-mode.ts +0 -1
  3. package/package.json +10 -10
  4. package/scripts/build-binary.ts +5 -0
  5. package/src/autoresearch/helpers.ts +17 -0
  6. package/src/autoresearch/tools/log-experiment.ts +9 -17
  7. package/src/autoresearch/tools/run-experiment.ts +2 -17
  8. package/src/capability/skill.ts +7 -0
  9. package/src/cli/list-models.ts +1 -1
  10. package/src/cli/shell-cli.ts +3 -13
  11. package/src/cli/update-cli.ts +1 -1
  12. package/src/cli.ts +10 -29
  13. package/src/commands/commit.ts +10 -0
  14. package/src/commit/agentic/tools/propose-changelog.ts +8 -1
  15. package/src/commit/analysis/conventional.ts +8 -66
  16. package/src/commit/map-reduce/reduce-phase.ts +6 -65
  17. package/src/commit/pipeline.ts +2 -2
  18. package/src/commit/shared-llm.ts +89 -0
  19. package/src/config/config-file.ts +210 -0
  20. package/src/config/model-equivalence.ts +8 -11
  21. package/src/config/model-registry.ts +44 -3
  22. package/src/config/model-resolver.ts +1 -4
  23. package/src/config/settings-schema.ts +82 -1
  24. package/src/config/settings.ts +1 -1
  25. package/src/config.ts +3 -219
  26. package/src/discovery/claude-plugins.ts +19 -7
  27. package/src/edit/renderer.ts +7 -1
  28. package/src/eval/js/executor.ts +3 -0
  29. package/src/eval/js/shared/rewrite-imports.ts +2 -2
  30. package/src/eval/py/executor.ts +5 -0
  31. package/src/eval/py/runner.py +42 -11
  32. package/src/eval/py/runtime.ts +1 -0
  33. package/src/exa/factory.ts +2 -2
  34. package/src/exa/mcp-client.ts +74 -1
  35. package/src/exec/bash-executor.ts +5 -1
  36. package/src/export/html/template.generated.ts +1 -1
  37. package/src/export/html/template.js +0 -11
  38. package/src/extensibility/extensions/get-commands-handler.ts +77 -0
  39. package/src/extensibility/extensions/runner.ts +1 -1
  40. package/src/extensibility/extensions/types.ts +89 -223
  41. package/src/extensibility/hooks/types.ts +89 -314
  42. package/src/extensibility/plugins/legacy-pi-compat.ts +48 -31
  43. package/src/extensibility/shared-events.ts +343 -0
  44. package/src/extensibility/skills.ts +9 -0
  45. package/src/goals/index.ts +3 -0
  46. package/src/goals/runtime.ts +500 -0
  47. package/src/goals/state.ts +37 -0
  48. package/src/goals/tools/goal-tool.ts +237 -0
  49. package/src/hashline/anchors.ts +2 -2
  50. package/src/hashline/input.ts +2 -1
  51. package/src/hashline/parser.ts +27 -3
  52. package/src/hindsight/mental-models.ts +1 -1
  53. package/src/internal-urls/agent-protocol.ts +1 -20
  54. package/src/internal-urls/artifact-protocol.ts +1 -19
  55. package/src/internal-urls/docs-index.generated.ts +11 -12
  56. package/src/internal-urls/registry-helpers.ts +25 -0
  57. package/src/internal-urls/router.ts +8 -0
  58. package/src/internal-urls/types.ts +21 -0
  59. package/src/lsp/config.ts +15 -6
  60. package/src/lsp/defaults.json +6 -2
  61. package/src/main.ts +11 -2
  62. package/src/mcp/oauth-flow.ts +20 -0
  63. package/src/modes/acp/acp-agent.ts +327 -95
  64. package/src/modes/components/assistant-message.ts +14 -8
  65. package/src/modes/components/bash-execution.ts +24 -63
  66. package/src/modes/components/custom-message.ts +14 -40
  67. package/src/modes/components/eval-execution.ts +27 -57
  68. package/src/modes/components/execution-shared.ts +102 -0
  69. package/src/modes/components/hook-message.ts +17 -49
  70. package/src/modes/components/mcp-add-wizard.ts +26 -5
  71. package/src/modes/components/message-frame.ts +88 -0
  72. package/src/modes/components/model-selector.ts +1 -1
  73. package/src/modes/components/session-observer-overlay.ts +6 -2
  74. package/src/modes/components/session-selector.ts +1 -1
  75. package/src/modes/components/status-line/segments.ts +93 -8
  76. package/src/modes/components/status-line/types.ts +4 -0
  77. package/src/modes/components/status-line.ts +28 -10
  78. package/src/modes/components/tool-execution.ts +7 -8
  79. package/src/modes/controllers/command-controller-shared.ts +108 -0
  80. package/src/modes/controllers/command-controller.ts +13 -4
  81. package/src/modes/controllers/event-controller.ts +36 -7
  82. package/src/modes/controllers/extension-ui-controller.ts +3 -2
  83. package/src/modes/controllers/input-controller.ts +13 -0
  84. package/src/modes/controllers/mcp-command-controller.ts +56 -61
  85. package/src/modes/controllers/ssh-command-controller.ts +18 -57
  86. package/src/modes/interactive-mode.ts +624 -52
  87. package/src/modes/print-mode.ts +16 -86
  88. package/src/modes/rpc/host-uris.ts +235 -0
  89. package/src/modes/rpc/rpc-mode.ts +41 -88
  90. package/src/modes/rpc/rpc-types.ts +57 -0
  91. package/src/modes/runtime-init.ts +116 -0
  92. package/src/modes/theme/defaults/dark-poimandres.json +3 -0
  93. package/src/modes/theme/defaults/light-poimandres.json +3 -0
  94. package/src/modes/theme/theme.ts +24 -6
  95. package/src/modes/types.ts +14 -3
  96. package/src/modes/utils/context-usage.ts +13 -13
  97. package/src/modes/utils/ui-helpers.ts +10 -3
  98. package/src/plan-mode/approved-plan.ts +35 -1
  99. package/src/prompts/goals/goal-budget-limit.md +16 -0
  100. package/src/prompts/goals/goal-continuation.md +28 -0
  101. package/src/prompts/goals/goal-mode-active.md +23 -0
  102. package/src/prompts/system/plan-mode-active.md +5 -5
  103. package/src/prompts/system/plan-mode-tool-decision-reminder.md +1 -1
  104. package/src/prompts/tools/bash.md +6 -0
  105. package/src/prompts/tools/github.md +4 -4
  106. package/src/prompts/tools/goal.md +13 -0
  107. package/src/prompts/tools/hashline.md +101 -117
  108. package/src/prompts/tools/read.md +55 -36
  109. package/src/prompts/tools/resolve.md +6 -5
  110. package/src/sdk.ts +12 -5
  111. package/src/session/agent-session.ts +428 -106
  112. package/src/session/blob-store.ts +36 -3
  113. package/src/session/messages.ts +67 -2
  114. package/src/session/session-manager.ts +131 -12
  115. package/src/session/session-storage.ts +33 -15
  116. package/src/session/streaming-output.ts +309 -13
  117. package/src/slash-commands/builtin-registry.ts +18 -0
  118. package/src/ssh/ssh-executor.ts +5 -0
  119. package/src/system-prompt.ts +4 -2
  120. package/src/task/discovery.ts +5 -2
  121. package/src/task/executor.ts +19 -8
  122. package/src/task/index.ts +3 -0
  123. package/src/task/render.ts +21 -15
  124. package/src/task/types.ts +4 -0
  125. package/src/tools/ast-edit.ts +21 -120
  126. package/src/tools/ast-grep.ts +21 -119
  127. package/src/tools/bash-command-fixup.ts +47 -0
  128. package/src/tools/bash-interactive.ts +9 -1
  129. package/src/tools/bash.ts +66 -19
  130. package/src/tools/browser/attach.ts +3 -3
  131. package/src/tools/browser/launch.ts +81 -18
  132. package/src/tools/browser/registry.ts +1 -5
  133. package/src/tools/browser/render.ts +2 -2
  134. package/src/tools/browser/tab-supervisor.ts +51 -14
  135. package/src/tools/conflict-detect.ts +15 -4
  136. package/src/tools/eval.ts +12 -2
  137. package/src/tools/find.ts +20 -38
  138. package/src/tools/gh.ts +44 -10
  139. package/src/tools/index.ts +22 -11
  140. package/src/tools/inspect-image.ts +3 -10
  141. package/src/tools/job.ts +16 -7
  142. package/src/tools/output-meta.ts +202 -37
  143. package/src/tools/path-utils.ts +125 -2
  144. package/src/tools/read.ts +548 -237
  145. package/src/tools/render-utils.ts +92 -0
  146. package/src/tools/renderers.ts +2 -0
  147. package/src/tools/resolve.ts +72 -44
  148. package/src/tools/search.ts +120 -186
  149. package/src/tools/ssh.ts +3 -2
  150. package/src/tools/write.ts +64 -9
  151. package/src/utils/file-mentions.ts +1 -1
  152. package/src/utils/image-loading.ts +7 -3
  153. package/src/utils/image-resize.ts +32 -43
  154. package/src/vim/parser.ts +0 -17
  155. package/src/vim/render.ts +1 -1
  156. package/src/vim/types.ts +1 -1
  157. package/src/web/search/providers/anthropic.ts +5 -0
  158. package/src/web/search/providers/exa.ts +3 -0
  159. package/src/web/search/providers/gemini.ts +40 -95
  160. package/src/web/search/providers/jina.ts +5 -2
  161. package/src/web/search/providers/zai.ts +5 -2
  162. package/src/prompts/tools/exit-plan-mode.md +0 -6
  163. package/src/tools/exit-plan-mode.ts +0 -97
  164. package/src/utils/fuzzy.ts +0 -108
  165. package/src/utils/image-convert.ts +0 -27
@@ -1,11 +1,10 @@
1
1
  import type { ImageContent } from "@oh-my-pi/pi-ai";
2
- import { ImageFormat, PhotonImage, SamplingFilter } from "@oh-my-pi/pi-natives";
3
2
 
4
3
  export interface ImageResizeOptions {
5
- maxWidth?: number; // Default: 1568
6
- maxHeight?: number; // Default: 1568
7
- maxBytes?: number; // Default: 500KB
8
- jpegQuality?: number; // Default: 75
4
+ maxWidth?: number;
5
+ maxHeight?: number;
6
+ maxBytes?: number;
7
+ jpegQuality?: number;
9
8
  }
10
9
 
11
10
  export interface ResizedImage {
@@ -24,12 +23,12 @@ export interface ResizedImage {
24
23
  const DEFAULT_MAX_BYTES = 500 * 1024;
25
24
 
26
25
  const DEFAULT_OPTIONS: Required<ImageResizeOptions> = {
27
- // 1568px Anthropic downscales anything larger; OpenAI tiles at 768px;
28
- // sending bigger pixels wastes bandwidth the model never sees.
26
+ // Anthropic's "internal recommended size" Claude internally caps images at
27
+ // 1568px on the longest edge before vision processing.
29
28
  maxWidth: 1568,
30
29
  maxHeight: 1568,
31
30
  maxBytes: DEFAULT_MAX_BYTES,
32
- jpegQuality: 75,
31
+ jpegQuality: 80,
33
32
  };
34
33
 
35
34
  /** Pick the smallest of N encoded buffers. */
@@ -48,43 +47,34 @@ Buffer.prototype.toBase64 = function (this: Buffer) {
48
47
  /**
49
48
  * Resize and recompress an image to fit within the specified max dimensions and file size.
50
49
  *
51
- * Defaults target Anthropic's internal 1568px downscale threshold and produce small
52
- * lossy JPEG output suitable for tool-call payloads (~100–500KB typical).
53
- *
54
50
  * Strategy:
55
- * 1. Fast path if input already fits dimensions AND is at <=25% of byte budget,
56
- * return as-is. Avoids re-encoding tiny icons/diagrams.
57
- * 2. Resize to maxWidth/maxHeight, encode both PNG and JPEG at default quality,
58
- * pick whichever is smaller. PNG wins for line art / few-color UI; JPEG wins
59
- * for photographic content.
60
- * 3. If still too large, JPEG-only quality ladder (PNG quality is a no-op).
61
- * 4. If still too large, progressively reduce dimensions and retry the JPEG ladder.
62
- * 5. Last resort: ship the smallest variant produced.
51
+ * 1. Probe metadata. If already within all limits, return original.
52
+ * 2. Resize to fit max dimensions and encode at high quality across PNG/JPEG/WebP — return smallest.
53
+ * 3. If still too large, walk a lossy JPEG/WebP quality ladder.
54
+ * 4. If still too large, walk a dimension-scale ladder × quality ladder.
55
+ * 5. If still too large, return the smallest variant produced.
63
56
  *
64
- * On any decode failure, returns the original bytes unchanged with wasResized=false.
57
+ * Backed by `Bun.Image`: a chainable native pipeline that runs decode/transform/encode
58
+ * off the JS thread when the terminal (`.bytes()`) is awaited.
65
59
  */
66
60
  export async function resizeImage(img: ImageContent, options?: ImageResizeOptions): Promise<ResizedImage> {
67
61
  const opts = { ...DEFAULT_OPTIONS, ...options };
68
62
  const inputBuffer = Buffer.from(img.data, "base64");
69
63
 
70
64
  try {
71
- const image = await PhotonImage.parse(inputBuffer);
72
-
73
- const originalWidth = image.width;
74
- const originalHeight = image.height;
75
- const format = img.mimeType?.split("/")[1] ?? "png";
65
+ const { width: originalWidth, height: originalHeight, format } = await new Bun.Image(inputBuffer).metadata();
66
+ const sourceMime = img.mimeType ?? `image/${format}`;
76
67
 
77
- // Check if already within all limits (dimensions AND size)
78
- const originalSize = inputBuffer.length;
79
- // Fast path: skip if already within dimensions AND well under budget.
68
+ // Fast path: already within dimensions AND well under budget.
80
69
  // Threshold is 1/4 of budget — if already that compact, don't re-encode.
81
70
  // Avoids wasted work on tiny icons/diagrams while ensuring larger PNGs
82
71
  // still get JPEG-compressed.
72
+ const originalSize = inputBuffer.length;
83
73
  const comfortableSize = opts.maxBytes / 4;
84
74
  if (originalWidth <= opts.maxWidth && originalHeight <= opts.maxHeight && originalSize <= comfortableSize) {
85
75
  return {
86
76
  buffer: inputBuffer,
87
- mimeType: img.mimeType ?? `image/${format}`,
77
+ mimeType: sourceMime,
88
78
  originalWidth,
89
79
  originalHeight,
90
80
  width: originalWidth,
@@ -117,14 +107,11 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
117
107
  height: number,
118
108
  quality: number,
119
109
  ): Promise<{ buffer: Uint8Array; mimeType: string }> {
120
- const resized = await image.resize(width, height, SamplingFilter.Lanczos3);
121
-
122
110
  const [pngBuffer, jpegBuffer, webpBuffer] = await Promise.all([
123
- resized.encode(ImageFormat.PNG, quality),
124
- resized.encode(ImageFormat.JPEG, quality),
125
- resized.encode(ImageFormat.WEBP, quality),
111
+ new Bun.Image(inputBuffer).resize(width, height).png().bytes(),
112
+ new Bun.Image(inputBuffer).resize(width, height).jpeg({ quality }).bytes(),
113
+ new Bun.Image(inputBuffer).resize(width, height).webp({ quality }).bytes(),
126
114
  ]);
127
-
128
115
  return pickSmallest(
129
116
  { buffer: pngBuffer, mimeType: "image/png" },
130
117
  { buffer: jpegBuffer, mimeType: "image/jpeg" },
@@ -140,10 +127,9 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
140
127
  height: number,
141
128
  quality: number,
142
129
  ): Promise<{ buffer: Uint8Array; mimeType: string }> {
143
- const resized = await image.resize(width, height, SamplingFilter.Lanczos3);
144
130
  const [jpegBuffer, webpBuffer] = await Promise.all([
145
- resized.encode(ImageFormat.JPEG, quality),
146
- resized.encode(ImageFormat.WEBP, quality),
131
+ new Bun.Image(inputBuffer).resize(width, height).jpeg({ quality }).bytes(),
132
+ new Bun.Image(inputBuffer).resize(width, height).webp({ quality }).bytes(),
147
133
  ]);
148
134
  return pickSmallest(
149
135
  { buffer: jpegBuffer, mimeType: "image/jpeg" },
@@ -159,7 +145,7 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
159
145
  let finalWidth = targetWidth;
160
146
  let finalHeight = targetHeight;
161
147
 
162
- // First attempt: resize to target, try both PNG and JPEG, pick smaller
148
+ // First attempt: resize to target, try PNG/JPEG/WebP, pick smallest
163
149
  best = await encodeSmallest(targetWidth, targetHeight, opts.jpegQuality);
164
150
 
165
151
  if (best.buffer.length <= opts.maxBytes) {
@@ -264,9 +250,12 @@ export function formatDimensionNote(result: ResizedImage): string | undefined {
264
250
  if (!result.wasResized) {
265
251
  return undefined;
266
252
  }
267
-
253
+ if (!result.originalWidth || !result.originalHeight || !result.width || !result.height) {
254
+ return undefined;
255
+ }
256
+ if (result.width === result.originalWidth && result.height === result.originalHeight) {
257
+ return undefined;
258
+ }
268
259
  const scale = result.originalWidth / result.width;
269
- return `[Image: original ${result.originalWidth}x${result.originalHeight}, displayed at ${result.width}x${
270
- result.height
271
- }. Multiply coordinates by ${scale.toFixed(2)} to map to original image.]`;
260
+ return `[Image: original ${result.originalWidth}x${result.originalHeight}, displayed at ${result.width}x${result.height}. Multiply coordinates by ${scale.toFixed(2)} to map to original image.]`;
272
261
  }
package/src/vim/parser.ts CHANGED
@@ -124,10 +124,6 @@ export function parseKeySequences(sequences: string[]): VimKeyToken[] {
124
124
  return tokens;
125
125
  }
126
126
 
127
- export function tokensToReplay(tokens: readonly VimKeyToken[]): string[] {
128
- return tokens.map(token => token.value);
129
- }
130
-
131
127
  export function replayTokens(values: readonly string[]): VimKeyToken[] {
132
128
  return values.map((value, index) => ({
133
129
  value,
@@ -136,16 +132,3 @@ export function replayTokens(values: readonly string[]): VimKeyToken[] {
136
132
  offset: index,
137
133
  }));
138
134
  }
139
-
140
- export function formatVimError(error: unknown): string {
141
- if (!(error instanceof VimError)) {
142
- return error instanceof Error ? error.message : String(error);
143
- }
144
-
145
- const base = error.message;
146
- if (!error.location) {
147
- return base;
148
- }
149
-
150
- return `${base} (sequence ${error.location.sequenceIndex + 1}, token ${error.location.offset + 1})`;
151
- }
package/src/vim/render.ts CHANGED
@@ -13,7 +13,7 @@ import type {
13
13
 
14
14
  export const VIM_OPEN_VIEWPORT_LINES = 80;
15
15
  export const VIM_DEFAULT_VIEWPORT_LINES = 10;
16
- export const VIM_TAB_DISPLAY = "→";
16
+ const VIM_TAB_DISPLAY = "→";
17
17
  const VIM_INLINE_CURSOR = "▏";
18
18
 
19
19
  const VIM_VIEWPORT_WIDTH = 140;
package/src/vim/types.ts CHANGED
@@ -164,7 +164,7 @@ export function clonePosition(position: Position): Position {
164
164
  return { line: position.line, col: position.col };
165
165
  }
166
166
 
167
- export function comparePositions(left: Position, right: Position): number {
167
+ function comparePositions(left: Position, right: Position): number {
168
168
  if (left.line !== right.line) {
169
169
  return left.line - right.line;
170
170
  }
@@ -38,6 +38,7 @@ export interface AnthropicSearchParams {
38
38
  max_tokens?: number;
39
39
  /** Sampling temperature (0–1). Lower = more focused/factual. */
40
40
  temperature?: number;
41
+ signal?: AbortSignal;
41
42
  }
42
43
 
43
44
  /**
@@ -86,6 +87,7 @@ async function callSearch(
86
87
  systemPrompt?: string,
87
88
  maxTokens?: number,
88
89
  temperature?: number,
90
+ signal?: AbortSignal,
89
91
  ): Promise<AnthropicApiResponse> {
90
92
  const url = buildAnthropicUrl(auth);
91
93
  const headers = buildAnthropicSearchHeaders(auth);
@@ -116,6 +118,7 @@ async function callSearch(
116
118
  method: "POST",
117
119
  headers,
118
120
  body: JSON.stringify(body),
121
+ signal,
119
122
  });
120
123
 
121
124
  if (!response.ok) {
@@ -253,6 +256,7 @@ export async function searchAnthropic(params: AnthropicSearchParams): Promise<Se
253
256
  params.system_prompt,
254
257
  params.max_tokens,
255
258
  params.temperature,
259
+ params.signal,
256
260
  );
257
261
 
258
262
  const result = parseResponse(response);
@@ -281,6 +285,7 @@ export class AnthropicProvider extends SearchProvider {
281
285
  num_results: params.numSearchResults ?? params.limit,
282
286
  max_tokens: params.maxOutputTokens,
283
287
  temperature: params.temperature,
288
+ signal: params.signal,
284
289
  });
285
290
  }
286
291
  }
@@ -29,6 +29,7 @@ export interface ExaSearchParams {
29
29
  exclude_domains?: string[];
30
30
  start_published_date?: string;
31
31
  end_published_date?: string;
32
+ signal?: AbortSignal;
32
33
  }
33
34
 
34
35
  interface ExaSearchResult {
@@ -179,6 +180,7 @@ async function callExaSearch(apiKey: string, params: ExaSearchParams): Promise<E
179
180
  "x-api-key": apiKey,
180
181
  },
181
182
  body: JSON.stringify(body),
183
+ signal: params.signal,
182
184
  });
183
185
 
184
186
  if (!response.ok) {
@@ -259,6 +261,7 @@ export class ExaProvider extends SearchProvider {
259
261
  return searchExa({
260
262
  query: params.query,
261
263
  num_results: params.numSearchResults ?? params.limit,
264
+ signal: params.signal,
262
265
  });
263
266
  }
264
267
  }
@@ -5,15 +5,11 @@
5
5
  * Requires OAuth credentials stored in agent.db for provider "google-gemini-cli" or "google-antigravity".
6
6
  * Returns synthesized answers with citations and source metadata from grounding chunks.
7
7
  */
8
- import {
9
- ANTIGRAVITY_SYSTEM_INSTRUCTION,
10
- extractRetryDelay,
11
- getAntigravityUserAgent,
12
- getGeminiCliHeaders,
13
- } from "@oh-my-pi/pi-ai";
8
+ import { ANTIGRAVITY_SYSTEM_INSTRUCTION, getAntigravityUserAgent, getGeminiCliHeaders } from "@oh-my-pi/pi-ai";
14
9
  import { refreshAntigravityToken } from "@oh-my-pi/pi-ai/utils/oauth/google-antigravity";
15
10
  import { refreshGoogleCloudToken } from "@oh-my-pi/pi-ai/utils/oauth/google-gemini-cli";
16
- import { getAgentDbPath } from "@oh-my-pi/pi-utils";
11
+ import { fetchWithRetry, getAgentDbPath } from "@oh-my-pi/pi-utils";
12
+
17
13
  import { AgentStorage } from "../../../session/agent-storage";
18
14
  import type { SearchCitation, SearchResponse, SearchSource } from "../../../web/search/types";
19
15
  import { SearchProviderError } from "../../../web/search/types";
@@ -43,6 +39,7 @@ export interface GeminiSearchParams extends GeminiToolParams {
43
39
  max_output_tokens?: number;
44
40
  /** Sampling temperature (0–1). Lower = more focused/factual. */
45
41
  temperature?: number;
42
+ signal?: AbortSignal;
46
43
  }
47
44
 
48
45
  export function buildGeminiRequestTools(params: GeminiToolParams): Array<Record<string, Record<string, unknown>>> {
@@ -239,6 +236,7 @@ async function callGeminiSearch(
239
236
  maxOutputTokens?: number,
240
237
  temperature?: number,
241
238
  toolParams: GeminiToolParams = {},
239
+ signal?: AbortSignal,
242
240
  ): Promise<{
243
241
  answer: string;
244
242
  sources: SearchSource[];
@@ -303,98 +301,43 @@ async function callGeminiSearch(
303
301
  }
304
302
  (requestBody.request as Record<string, unknown>).generationConfig = generationConfig;
305
303
  }
306
- let response: Response | undefined;
307
- let rateLimitTimeSpent = 0;
308
- let lastError: Error | undefined;
309
-
310
- for (let endpointIndex = 0; endpointIndex < endpoints.length; endpointIndex++) {
311
- const url = `${endpoints[endpointIndex]}/v1internal:streamGenerateContent?alt=sse`;
312
-
313
- for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
314
- try {
315
- response = await fetch(url, {
316
- method: "POST",
317
- headers: {
318
- Authorization: `Bearer ${auth.accessToken}`,
319
- "Content-Type": "application/json",
320
- Accept: "text/event-stream",
321
- ...headers,
322
- },
323
- body: JSON.stringify(requestBody),
324
- });
325
- } catch (error) {
326
- if (attempt < MAX_RETRIES) {
327
- await Bun.sleep(BASE_DELAY_MS * 2 ** attempt);
328
- continue;
329
- }
330
-
331
- if (auth.isAntigravity && endpointIndex < endpoints.length - 1) {
332
- break;
333
- }
334
-
335
- throw error;
336
- }
337
-
338
- if (response.ok) {
339
- break;
340
- }
341
-
342
- const errorText = await response.text();
343
- const canRefreshAuth =
344
- response.status === 401 ||
345
- response.status === 403 ||
346
- (response.status === 400 &&
347
- /api key not valid|invalid credentials|invalid authentication/i.test(errorText));
348
- if (canRefreshAuth && attempt === 0 && (await refreshGeminiAuth(auth))) {
349
- continue;
350
- }
351
- const isRetryableStatus =
352
- response.status === 429 ||
353
- response.status === 500 ||
354
- response.status === 502 ||
355
- response.status === 503 ||
356
- response.status === 504;
357
-
358
- if (isRetryableStatus && attempt < MAX_RETRIES) {
359
- const serverDelay = extractRetryDelay(errorText, response);
360
- if (response.status === 429) {
361
- if (serverDelay && rateLimitTimeSpent + serverDelay <= RATE_LIMIT_BUDGET_MS) {
362
- rateLimitTimeSpent += serverDelay;
363
- await Bun.sleep(serverDelay);
364
- continue;
365
- }
366
- if (!serverDelay) {
367
- await Bun.sleep(BASE_DELAY_MS * 2 ** attempt);
368
- continue;
369
- }
370
- } else {
371
- await Bun.sleep(serverDelay ?? BASE_DELAY_MS * 2 ** attempt);
372
- continue;
373
- }
374
- }
375
-
376
- lastError = new SearchProviderError(
377
- "gemini",
378
- `Gemini Cloud Code API error (${response.status}): ${errorText}`,
379
- response.status,
380
- );
381
-
382
- if (auth.isAntigravity && isRetryableStatus && endpointIndex < endpoints.length - 1) {
383
- break;
384
- }
385
-
386
- throw lastError;
387
- }
304
+ const buildInit = (): RequestInit => ({
305
+ method: "POST",
306
+ headers: {
307
+ Authorization: `Bearer ${auth.accessToken}`,
308
+ "Content-Type": "application/json",
309
+ Accept: "text/event-stream",
310
+ ...headers,
311
+ },
312
+ body: JSON.stringify(requestBody),
313
+ signal,
314
+ });
315
+ const urlFor = (attempt: number) =>
316
+ `${endpoints[Math.min(attempt, endpoints.length - 1)]}/v1internal:streamGenerateContent?alt=sse`;
317
+
318
+ let response = await fetchWithRetry(urlFor, {
319
+ ...buildInit(),
320
+ maxAttempts: MAX_RETRIES + 1,
321
+ defaultDelayMs: attempt => BASE_DELAY_MS * 2 ** attempt,
322
+ maxDelayMs: RATE_LIMIT_BUDGET_MS,
323
+ });
388
324
 
389
- if (response?.ok) {
390
- break;
325
+ if (!response.ok) {
326
+ const errorText = await response.clone().text();
327
+ const canRefreshAuth =
328
+ response.status === 401 ||
329
+ response.status === 403 ||
330
+ (response.status === 400 && /api key not valid|invalid credentials|invalid authentication/i.test(errorText));
331
+ if (canRefreshAuth && (await refreshGeminiAuth(auth))) {
332
+ response = await fetchWithRetry(urlFor, {
333
+ ...buildInit(),
334
+ maxAttempts: MAX_RETRIES + 1,
335
+ defaultDelayMs: attempt => BASE_DELAY_MS * 2 ** attempt,
336
+ maxDelayMs: RATE_LIMIT_BUDGET_MS,
337
+ });
391
338
  }
392
339
  }
393
340
 
394
- if (!response) {
395
- throw new SearchProviderError("gemini", "Gemini API request failed", 500);
396
- }
397
-
398
341
  if (!response.ok) {
399
342
  const errorText = await response.text();
400
343
  throw new SearchProviderError(
@@ -560,6 +503,7 @@ export async function searchGemini(params: GeminiSearchParams): Promise<SearchRe
560
503
  code_execution: params.code_execution,
561
504
  url_context: params.url_context,
562
505
  },
506
+ params.signal,
563
507
  );
564
508
 
565
509
  let sources = result.sources;
@@ -599,6 +543,7 @@ export class GeminiProvider extends SearchProvider {
599
543
  google_search: params.googleSearch,
600
544
  code_execution: params.codeExecution,
601
545
  url_context: params.urlContext,
546
+ signal: params.signal,
602
547
  });
603
548
  }
604
549
  }
@@ -17,6 +17,7 @@ const JINA_SEARCH_URL = "https://s.jina.ai";
17
17
  export interface JinaSearchParams {
18
18
  query: string;
19
19
  num_results?: number;
20
+ signal?: AbortSignal;
20
21
  }
21
22
 
22
23
  interface JinaSearchResult {
@@ -33,13 +34,14 @@ export function findApiKey(): string | null {
33
34
  }
34
35
 
35
36
  /** Call Jina Reader search API. */
36
- async function callJinaSearch(apiKey: string, query: string): Promise<JinaSearchResponse> {
37
+ async function callJinaSearch(apiKey: string, query: string, signal?: AbortSignal): Promise<JinaSearchResponse> {
37
38
  const requestUrl = `${JINA_SEARCH_URL}/${encodeURIComponent(query)}`;
38
39
  const response = await fetch(requestUrl, {
39
40
  headers: {
40
41
  Accept: "application/json",
41
42
  Authorization: `Bearer ${apiKey}`,
42
43
  },
44
+ signal,
43
45
  });
44
46
 
45
47
  if (!response.ok) {
@@ -58,7 +60,7 @@ export async function searchJina(params: JinaSearchParams): Promise<SearchRespon
58
60
  throw new Error("JINA_API_KEY not found. Set it in environment or .env file.");
59
61
  }
60
62
 
61
- const response = await callJinaSearch(apiKey, params.query);
63
+ const response = await callJinaSearch(apiKey, params.query, params.signal);
62
64
  const sources: SearchSource[] = [];
63
65
 
64
66
  for (const result of response) {
@@ -91,6 +93,7 @@ export class JinaProvider extends SearchProvider {
91
93
  return searchJina({
92
94
  query: params.query,
93
95
  num_results: params.numSearchResults ?? params.limit,
96
+ signal: params.signal,
94
97
  });
95
98
  }
96
99
  }
@@ -20,6 +20,7 @@ const DEFAULT_NUM_RESULTS = 10;
20
20
  export interface ZaiSearchParams {
21
21
  query: string;
22
22
  num_results?: number;
23
+ signal?: AbortSignal;
23
24
  }
24
25
 
25
26
  interface ZaiSearchResult {
@@ -55,7 +56,7 @@ export async function findApiKey(): Promise<string | null> {
55
56
  return findCredential(getEnvApiKey("zai"), "zai");
56
57
  }
57
58
 
58
- async function callZaiTool(apiKey: string, args: Record<string, unknown>): Promise<unknown> {
59
+ async function callZaiTool(apiKey: string, args: Record<string, unknown>, signal?: AbortSignal): Promise<unknown> {
59
60
  const response = await fetch(ZAI_MCP_URL, {
60
61
  method: "POST",
61
62
  headers: {
@@ -72,6 +73,7 @@ async function callZaiTool(apiKey: string, args: Record<string, unknown>): Promi
72
73
  arguments: args,
73
74
  },
74
75
  }),
76
+ signal,
75
77
  });
76
78
 
77
79
  if (!response.ok) {
@@ -157,7 +159,7 @@ async function callZaiSearch(apiKey: string, params: ZaiSearchParams): Promise<u
157
159
  let lastError: unknown;
158
160
  for (let i = 0; i < attempts.length; i++) {
159
161
  try {
160
- return await callZaiTool(apiKey, attempts[i]);
162
+ return await callZaiTool(apiKey, attempts[i], params.signal);
161
163
  } catch (error) {
162
164
  lastError = error;
163
165
  const isLastAttempt = i === attempts.length - 1;
@@ -302,6 +304,7 @@ export class ZaiProvider extends SearchProvider {
302
304
  return searchZai({
303
305
  query: params.query,
304
306
  num_results: params.numSearchResults ?? params.limit,
307
+ signal: params.signal,
305
308
  });
306
309
  }
307
310
  }
@@ -1,6 +0,0 @@
1
- Submits a finalized implementation plan for user approval.
2
-
3
- Write the plan to `local://PLAN.md` first, then call this with `title` (e.g. `WP_MIGRATION_PLAN`); on approval the file is renamed to `local://<title>.md` and full tool access is restored.
4
- - Use only after planning implementation steps; not for pure research.
5
- - NEVER call before the plan file exists.
6
- - NEVER use `ask` to request plan approval — this tool does that.
@@ -1,97 +0,0 @@
1
- import * as fs from "node:fs/promises";
2
- import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
3
- import { isEnoent, prompt } from "@oh-my-pi/pi-utils";
4
- import { type Static, Type } from "@sinclair/typebox";
5
- import exitPlanModeDescription from "../prompts/tools/exit-plan-mode.md" with { type: "text" };
6
- import type { ToolSession } from ".";
7
- import { resolvePlanPath } from "./plan-mode-guard";
8
- import { ToolError } from "./tool-errors";
9
-
10
- const exitPlanModeSchema = Type.Object({
11
- title: Type.String({ description: "final plan title", examples: ["WP_MIGRATION_PLAN"] }),
12
- });
13
-
14
- type ExitPlanModeParams = Static<typeof exitPlanModeSchema>;
15
-
16
- function normalizePlanTitle(title: string): { title: string; fileName: string } {
17
- const trimmed = title.trim();
18
- if (!trimmed) {
19
- throw new ToolError("Title is required and must not be empty.");
20
- }
21
-
22
- if (trimmed.includes("/") || trimmed.includes("\\") || trimmed.includes("..")) {
23
- throw new ToolError("Title must not contain path separators or '..'.");
24
- }
25
-
26
- const withExtension = trimmed.toLowerCase().endsWith(".md") ? trimmed : `${trimmed}.md`;
27
- if (!/^[A-Za-z0-9_-]+\.md$/.test(withExtension)) {
28
- throw new ToolError("Title may only contain letters, numbers, underscores, or hyphens.");
29
- }
30
-
31
- const normalizedTitle = withExtension.slice(0, -3);
32
- return { title: normalizedTitle, fileName: withExtension };
33
- }
34
-
35
- export interface ExitPlanModeDetails {
36
- planFilePath: string;
37
- planExists: boolean;
38
- title: string;
39
- finalPlanFilePath: string;
40
- }
41
-
42
- export class ExitPlanModeTool implements AgentTool<typeof exitPlanModeSchema, ExitPlanModeDetails> {
43
- readonly name = "exit_plan_mode";
44
- readonly label = "ExitPlanMode";
45
- readonly description: string;
46
- readonly parameters = exitPlanModeSchema;
47
- readonly strict = true;
48
- readonly concurrency = "exclusive";
49
- readonly intent = (): string => "present plan";
50
-
51
- constructor(private readonly session: ToolSession) {
52
- this.description = prompt.render(exitPlanModeDescription);
53
- }
54
-
55
- async execute(
56
- _toolCallId: string,
57
- params: ExitPlanModeParams,
58
- _signal?: AbortSignal,
59
- _onUpdate?: AgentToolUpdateCallback<ExitPlanModeDetails>,
60
- _context?: AgentToolContext,
61
- ): Promise<AgentToolResult<ExitPlanModeDetails>> {
62
- const state = this.session.getPlanModeState?.();
63
- if (!state?.enabled) {
64
- throw new ToolError("Plan mode is not active.");
65
- }
66
-
67
- const normalized = normalizePlanTitle(params.title);
68
- const finalPlanFilePath = `local://${normalized.fileName}`;
69
- const resolvedPlanPath = resolvePlanPath(this.session, state.planFilePath);
70
- resolvePlanPath(this.session, finalPlanFilePath);
71
- let planExists = false;
72
- try {
73
- const stat = await fs.stat(resolvedPlanPath);
74
- planExists = stat.isFile();
75
- } catch (error) {
76
- if (!isEnoent(error)) {
77
- throw error;
78
- }
79
- }
80
-
81
- if (!planExists) {
82
- throw new ToolError(
83
- `Plan file not found at ${state.planFilePath}. Write the finalized plan to ${state.planFilePath} before calling exit_plan_mode.`,
84
- );
85
- }
86
-
87
- return {
88
- content: [{ type: "text", text: "Plan ready for approval." }],
89
- details: {
90
- planFilePath: state.planFilePath,
91
- planExists,
92
- title: normalized.title,
93
- finalPlanFilePath,
94
- },
95
- };
96
- }
97
- }