@oh-my-pi/pi-coding-agent 15.1.8 → 15.1.9

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,28 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.1.9] - 2026-05-21
6
+
7
+ ### Fixed
8
+
9
+ - Fixed `disabledProviders` still probing local discovery endpoints for Ollama, llama.cpp, and LM Studio during background model refresh. Disabled providers are now excluded before implicit and built-in discovery managers are created. ([#1232](https://github.com/can1357/oh-my-pi/issues/1232))
10
+
11
+ ### Fixed
12
+
13
+ - Fixed `omp acp` auto-discovering host `.mcp.json` servers in parallel with the ACP client's `session/new.mcpServers`, which shadowed client-supplied MCP tools in `search_tool_bm25` and the session tool registry. The ACP session factory now forces `enableMCP: false`, so MCP ownership stays with `AcpAgent#configureMcpServers`. Non-ACP modes keep on-disk discovery. ([#1234](https://github.com/can1357/oh-my-pi/issues/1234))
14
+
15
+ ### Fixed
16
+
17
+ - Fixed binary `omp update` rollbacks so a downloaded replacement that fails post-install version verification no longer remains installed over the previous working binary. ([#1240](https://github.com/can1357/oh-my-pi/issues/1240))
18
+
19
+ ### Fixed
20
+
21
+ - Fixed `/force <tool>` rejecting Ollama/local models before the requested tool could run; Ollama now receives a named forced choice that the provider transport narrows to the selected tool. ([#1236](https://github.com/can1357/oh-my-pi/issues/1236))
22
+
23
+ ### Fixed
24
+
25
+ - Fixed `web_search` freezing the session when an upstream provider stalled. Bun's WinHTTP backend on Windows can silently drop `AbortSignal` once a TCP/TLS connection hangs (oven-sh/bun#15275, oven-sh/bun#18536), so Esc never reached the in-flight fetch and the only recovery was Ctrl+C + `omp --resume`. Every web-search provider's outbound `fetch` (anthropic, brave, codex, exa, gemini, jina, kagi, kimi, parallel, perplexity, searxng, synthetic, tavily, z.ai) now composes the caller signal with a 60s hard timeout via a shared `withHardTimeout` helper, guaranteeing the request settles within a minute even when Bun's abort fails to propagate. Independently, `executeSearch`'s provider-fallback loop was masking real cancellations as ordinary provider errors and returning "All web search providers failed"; it now re-throws as `ToolAbortError` the moment the caller's signal aborts, so the session sees a clean cancel on every platform. ([#1221](https://github.com/can1357/oh-my-pi/issues/1221))
26
+
5
27
  ## [15.1.8] - 2026-05-20
6
28
 
7
29
  ### Fixed
@@ -1,3 +1,17 @@
1
+ /** Result from running the installed binary and parsing its reported version. */
2
+ export interface InstalledVersionVerification {
3
+ ok: boolean;
4
+ actual?: string;
5
+ path?: string;
6
+ }
7
+ /** Paths and verifier used while replacing a downloaded binary update. */
8
+ export interface BinaryReplacementOptions {
9
+ targetPath: string;
10
+ tempPath: string;
11
+ backupPath: string;
12
+ expectedVersion: string;
13
+ verifyInstalledVersion: (expectedVersion: string) => Promise<InstalledVersionVerification>;
14
+ }
1
15
  /**
2
16
  * Parse update subcommand arguments.
3
17
  * Returns undefined if not an update command.
@@ -7,6 +21,10 @@ export declare function parseUpdateArgs(args: string[]): {
7
21
  check: boolean;
8
22
  } | undefined;
9
23
  export declare function resolveUpdateMethodForTest(ompPath: string, bunBinDir: string | undefined): "bun" | "binary";
24
+ /**
25
+ * Atomically replace the installed binary and roll back if version verification fails.
26
+ */
27
+ export declare function replaceBinaryForUpdate(options: BinaryReplacementOptions): Promise<InstalledVersionVerification>;
10
28
  /**
11
29
  * Run the update command.
12
30
  */
@@ -5,16 +5,40 @@
5
5
  * createAgentSession() options. The SDK does the heavy lifting.
6
6
  */
7
7
  import type { Args } from "./cli/args";
8
+ import { ModelRegistry } from "./config/model-registry";
8
9
  import { Settings } from "./config/settings";
9
10
  import { InteractiveMode, runAcpMode } from "./modes";
10
11
  import type { SubmittedUserInput } from "./modes/types";
11
- import { createAgentSession, discoverAuthStorage } from "./sdk";
12
+ import { type CreateAgentSessionOptions, type CreateAgentSessionResult, createAgentSession, discoverAuthStorage } from "./sdk";
12
13
  import type { AgentSession } from "./session/agent-session";
14
+ import type { AuthStorage } from "./session/auth-storage";
13
15
  export interface InteractiveModeNotify {
14
16
  kind: "warn" | "error" | "info";
15
17
  message: string;
16
18
  }
17
19
  export declare function submitInteractiveInput(mode: Pick<InteractiveMode, "markPendingSubmissionStarted" | "finishPendingSubmission" | "showError" | "checkShutdownRequested">, session: Pick<AgentSession, "prompt" | "promptCustomMessage">, input: SubmittedUserInput): Promise<void>;
20
+ type AcpSessionFactory = (cwd: string) => Promise<AgentSession>;
21
+ export interface AcpSessionFactoryOptions {
22
+ baseOptions: CreateAgentSessionOptions;
23
+ settings: Settings;
24
+ sessionDir?: string;
25
+ authStorage: AuthStorage;
26
+ modelRegistry: ModelRegistry;
27
+ parsedArgs: Pick<Args, "apiKey">;
28
+ rawArgs: string[];
29
+ createSession: (options: CreateAgentSessionOptions) => Promise<CreateAgentSessionResult>;
30
+ }
31
+ /**
32
+ * Build the per-`session/new` factory used by ACP mode.
33
+ *
34
+ * MCP servers in ACP sessions are owned exclusively by the ACP client, which
35
+ * supplies them through `session/new.mcpServers` and re-applies them via
36
+ * {@link AcpAgent#configureMcpServers}. We therefore force `enableMCP: false`
37
+ * on every session created here so {@link createAgentSession} skips the on-disk
38
+ * `.mcp.json` discovery path — otherwise host MCP tools land in the session's
39
+ * tool registry and shadow the client-supplied servers (issue #1234).
40
+ */
41
+ export declare function createAcpSessionFactory(args: AcpSessionFactoryOptions): AcpSessionFactory;
18
42
  interface RunRootCommandDependencies {
19
43
  createAgentSession?: typeof createAgentSession;
20
44
  discoverAuthStorage?: typeof discoverAuthStorage;
@@ -1,6 +1,7 @@
1
1
  import type { Api, Model, ToolChoice } from "@oh-my-pi/pi-ai";
2
2
  /**
3
3
  * Build a provider-aware tool choice that targets one specific tool when supported.
4
- * Some providers only support "any tool" forcing, not a named tool.
4
+ * Providers that only expose required/any forcing may still honor named choices by
5
+ * narrowing their request tool list before transport.
5
6
  */
6
7
  export declare function buildNamedToolChoice(toolName: string, model?: Model<Api>): ToolChoice | undefined;
@@ -12,6 +12,31 @@ export declare function findCredential(envKey: string | null | undefined, ...sto
12
12
  * Swallows lookup errors and reports unavailability.
13
13
  */
14
14
  export declare function isApiKeyAvailable(findApiKey: () => string | null | Promise<string | null>): Promise<boolean>;
15
+ /**
16
+ * Default hard ceiling for a single web-search round-trip. 60s tolerates
17
+ * legitimate slow LLM-mediated responses (anthropic web_search_20250305,
18
+ * perplexity, gemini, codex) while still guaranteeing the session unfreezes
19
+ * within a minute if Bun's `AbortSignal` fails to propagate on Windows.
20
+ *
21
+ * Pure search APIs (brave, exa, jina, tavily, searxng, synthetic, zai)
22
+ * settle far faster in practice; reusing the same ceiling keeps the wiring
23
+ * uniform without compromising correctness.
24
+ */
25
+ export declare const SEARCH_HARD_TIMEOUT_MS = 60000;
26
+ /**
27
+ * Compose a caller-supplied {@link AbortSignal} with a hard timeout so an
28
+ * outbound `fetch()` is guaranteed to settle within `ms` even when the
29
+ * runtime fails to propagate cancellation to the underlying transport.
30
+ *
31
+ * Bun's WinHTTP backend on Windows is known to ignore `AbortSignal` once a
32
+ * TCP/TLS connection stalls (oven-sh/bun#15275, oven-sh/bun#18536); without
33
+ * this safety net a stalled web-search request freezes the entire session
34
+ * because the user's Esc is never delivered to the native layer.
35
+ *
36
+ * @param signal - Caller cancellation signal, if any.
37
+ * @param ms - Hard timeout in milliseconds. Defaults to {@link SEARCH_HARD_TIMEOUT_MS}.
38
+ */
39
+ export declare function withHardTimeout(signal: AbortSignal | undefined, ms?: number): AbortSignal;
15
40
  /**
16
41
  * Map a provider's raw source list to the unified SearchSource shape,
17
42
  * clamped to the requested result count and annotated with ageSeconds.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "15.1.8",
4
+ "version": "15.1.9",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -47,12 +47,12 @@
47
47
  "@agentclientprotocol/sdk": "0.21.0",
48
48
  "@babel/parser": "^7.29.3",
49
49
  "@mozilla/readability": "^0.6.0",
50
- "@oh-my-pi/omp-stats": "15.1.8",
51
- "@oh-my-pi/pi-agent-core": "15.1.8",
52
- "@oh-my-pi/pi-ai": "15.1.8",
53
- "@oh-my-pi/pi-natives": "15.1.8",
54
- "@oh-my-pi/pi-tui": "15.1.8",
55
- "@oh-my-pi/pi-utils": "15.1.8",
50
+ "@oh-my-pi/omp-stats": "15.1.9",
51
+ "@oh-my-pi/pi-agent-core": "15.1.9",
52
+ "@oh-my-pi/pi-ai": "15.1.9",
53
+ "@oh-my-pi/pi-natives": "15.1.9",
54
+ "@oh-my-pi/pi-tui": "15.1.9",
55
+ "@oh-my-pi/pi-utils": "15.1.9",
56
56
  "@puppeteer/browsers": "^2.13.0",
57
57
  "@types/turndown": "5.0.6",
58
58
  "@xterm/headless": "^6.0.0",
@@ -20,6 +20,22 @@ interface ReleaseInfo {
20
20
  version: string;
21
21
  }
22
22
 
23
+ /** Result from running the installed binary and parsing its reported version. */
24
+ export interface InstalledVersionVerification {
25
+ ok: boolean;
26
+ actual?: string;
27
+ path?: string;
28
+ }
29
+
30
+ /** Paths and verifier used while replacing a downloaded binary update. */
31
+ export interface BinaryReplacementOptions {
32
+ targetPath: string;
33
+ tempPath: string;
34
+ backupPath: string;
35
+ expectedVersion: string;
36
+ verifyInstalledVersion: (expectedVersion: string) => Promise<InstalledVersionVerification>;
37
+ }
38
+
23
39
  /**
24
40
  * Parse update subcommand arguments.
25
41
  * Returns undefined if not an update command.
@@ -197,9 +213,7 @@ function resolveOmpPath(): string | undefined {
197
213
  /**
198
214
  * Run the resolved omp binary and check if it reports the expected version.
199
215
  */
200
- async function verifyInstalledVersion(
201
- expectedVersion: string,
202
- ): Promise<{ ok: boolean; actual?: string; path?: string }> {
216
+ async function verifyInstalledVersion(expectedVersion: string): Promise<InstalledVersionVerification> {
203
217
  const ompPath = resolveOmpPath();
204
218
  if (!ompPath) return { ok: false };
205
219
  try {
@@ -215,29 +229,69 @@ async function verifyInstalledVersion(
215
229
  }
216
230
  }
217
231
 
232
+ function printVerifiedVersion(expectedVersion: string): void {
233
+ console.log(chalk.green(`\n${theme.status.success} Updated to ${expectedVersion}`));
234
+ }
235
+
236
+ function formatVerificationFailure(result: InstalledVersionVerification, expectedVersion: string): string {
237
+ if (result.actual) {
238
+ return `${APP_NAME} at ${result.path} still reports ${result.actual} (expected ${expectedVersion})`;
239
+ }
240
+ return `could not verify updated version${result.path ? ` at ${result.path}` : ""}`;
241
+ }
242
+
218
243
  /**
219
244
  * Print post-update verification result.
220
245
  */
221
246
  async function printVerification(expectedVersion: string): Promise<void> {
222
247
  const result = await verifyInstalledVersion(expectedVersion);
223
248
  if (result.ok) {
224
- console.log(chalk.green(`\n${theme.status.success} Updated to ${expectedVersion}`));
249
+ printVerifiedVersion(expectedVersion);
225
250
  return;
226
251
  }
227
- if (result.actual) {
228
- console.log(
229
- chalk.yellow(
230
- `\nWarning: ${APP_NAME} at ${result.path} still reports ${result.actual} (expected ${expectedVersion})`,
231
- ),
232
- );
233
- } else {
234
- console.log(
235
- chalk.yellow(`\nWarning: could not verify updated version${result.path ? ` at ${result.path}` : ""}`),
236
- );
237
- }
252
+ console.log(chalk.yellow(`\nWarning: ${formatVerificationFailure(result, expectedVersion)}`));
238
253
  console.log(chalk.yellow(`You may need to reinstall: curl -fsSL https://omp.sh/install | sh`));
239
254
  }
240
255
 
256
+ async function unlinkIfExists(filePath: string): Promise<void> {
257
+ try {
258
+ await fs.promises.unlink(filePath);
259
+ } catch (err) {
260
+ if (!isEnoent(err)) throw err;
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Atomically replace the installed binary and roll back if version verification fails.
266
+ */
267
+ export async function replaceBinaryForUpdate(options: BinaryReplacementOptions): Promise<InstalledVersionVerification> {
268
+ let backupReady = false;
269
+ try {
270
+ await unlinkIfExists(options.backupPath);
271
+ await fs.promises.rename(options.targetPath, options.backupPath);
272
+ backupReady = true;
273
+ await fs.promises.rename(options.tempPath, options.targetPath);
274
+
275
+ const verification = await options.verifyInstalledVersion(options.expectedVersion);
276
+ if (!verification.ok) {
277
+ throw new Error(
278
+ `${formatVerificationFailure(verification, options.expectedVersion)}; restored previous ${APP_NAME} binary`,
279
+ );
280
+ }
281
+
282
+ backupReady = false;
283
+ await unlinkIfExists(options.backupPath);
284
+ return verification;
285
+ } catch (err) {
286
+ if (backupReady) {
287
+ await unlinkIfExists(options.targetPath);
288
+ await fs.promises.rename(options.backupPath, options.targetPath);
289
+ }
290
+ await unlinkIfExists(options.tempPath);
291
+ throw err;
292
+ }
293
+ }
294
+
241
295
  /**
242
296
  * Update via bun package manager.
243
297
  */
@@ -271,27 +325,15 @@ async function updateViaBinaryAt(targetPath: string, expectedVersion: string): P
271
325
  await pipeline(response.body, fileStream);
272
326
 
273
327
  console.log(chalk.dim("Installing update..."));
274
- try {
275
- try {
276
- await fs.promises.unlink(backupPath);
277
- } catch (err) {
278
- if (!isEnoent(err)) throw err;
279
- }
280
- await fs.promises.rename(targetPath, backupPath);
281
- await fs.promises.rename(tempPath, targetPath);
282
- await fs.promises.unlink(backupPath);
283
-
284
- await printVerification(expectedVersion);
285
- console.log(chalk.dim(`Restart ${APP_NAME} to use the new version`));
286
- } catch (err) {
287
- if (fs.existsSync(backupPath) && !fs.existsSync(targetPath)) {
288
- await fs.promises.rename(backupPath, targetPath);
289
- }
290
- if (fs.existsSync(tempPath)) {
291
- await fs.promises.unlink(tempPath);
292
- }
293
- throw err;
294
- }
328
+ await replaceBinaryForUpdate({
329
+ targetPath,
330
+ tempPath,
331
+ backupPath,
332
+ expectedVersion,
333
+ verifyInstalledVersion,
334
+ });
335
+ printVerifiedVersion(expectedVersion);
336
+ console.log(chalk.dim(`Restart ${APP_NAME} to use the new version`));
295
337
  }
296
338
 
297
339
  /**
@@ -1017,7 +1017,8 @@ export class ModelRegistry {
1017
1017
  }
1018
1018
 
1019
1019
  #addImplicitDiscoverableProviders(configuredProviders: Set<string>): void {
1020
- if (!configuredProviders.has("ollama")) {
1020
+ const disabledProviders = getDisabledProviderIdsFromSettings();
1021
+ if (!configuredProviders.has("ollama") && !disabledProviders.has("ollama")) {
1021
1022
  this.#discoverableProviders.push({
1022
1023
  provider: "ollama",
1023
1024
  api: "openai-responses",
@@ -1027,7 +1028,7 @@ export class ModelRegistry {
1027
1028
  });
1028
1029
  this.#keylessProviders.add("ollama");
1029
1030
  }
1030
- if (!configuredProviders.has("llama.cpp")) {
1031
+ if (!configuredProviders.has("llama.cpp") && !disabledProviders.has("llama.cpp")) {
1031
1032
  this.#discoverableProviders.push({
1032
1033
  provider: "llama.cpp",
1033
1034
  api: "openai-responses",
@@ -1040,7 +1041,7 @@ export class ModelRegistry {
1040
1041
  this.#keylessProviders.add("llama.cpp");
1041
1042
  }
1042
1043
  }
1043
- if (!configuredProviders.has("lm-studio")) {
1044
+ if (!configuredProviders.has("lm-studio") && !disabledProviders.has("lm-studio")) {
1044
1045
  this.#discoverableProviders.push({
1045
1046
  provider: "lm-studio",
1046
1047
  api: "openai-completions",
@@ -1160,9 +1161,12 @@ export class ModelRegistry {
1160
1161
  strategy: ModelRefreshStrategy,
1161
1162
  providerFilter?: ReadonlySet<string>,
1162
1163
  ): Promise<void> {
1163
- const selectedDiscoverableProviders = providerFilter
1164
- ? this.#discoverableProviders.filter(provider => providerFilter.has(provider.provider))
1165
- : this.#discoverableProviders;
1164
+ const disabledProviders = getDisabledProviderIdsFromSettings();
1165
+ const selectedDiscoverableProviders = (
1166
+ providerFilter
1167
+ ? this.#discoverableProviders.filter(provider => providerFilter.has(provider.provider))
1168
+ : this.#discoverableProviders
1169
+ ).filter(provider => !disabledProviders.has(provider.provider));
1166
1170
  const configuredDiscoveriesPromise =
1167
1171
  selectedDiscoverableProviders.length === 0
1168
1172
  ? Promise.resolve<Model<Api>[]>([])
@@ -1366,17 +1370,24 @@ export class ModelRegistry {
1366
1370
  },
1367
1371
  },
1368
1372
  ];
1373
+ const disabledProviders = getDisabledProviderIdsFromSettings();
1374
+ const standardProviderDescriptors = PROVIDER_DESCRIPTORS.filter(
1375
+ descriptor => !disabledProviders.has(descriptor.providerId),
1376
+ );
1377
+ const enabledSpecialProviderDescriptors = specialProviderDescriptors.filter(
1378
+ descriptor => !disabledProviders.has(descriptor.providerId),
1379
+ );
1369
1380
  // Use peekApiKey to avoid OAuth token refresh during discovery.
1370
1381
  // The token is only needed if the dynamic fetch fires (cache miss),
1371
1382
  // and failures there are handled gracefully.
1372
1383
  const peekKey = (descriptor: { providerId: string }) => this.#peekApiKeyForProvider(descriptor.providerId);
1373
1384
  const [standardProviderKeys, specialKeys] = await Promise.all([
1374
- Promise.all(PROVIDER_DESCRIPTORS.map(peekKey)),
1375
- Promise.all(specialProviderDescriptors.map(peekKey)),
1385
+ Promise.all(standardProviderDescriptors.map(peekKey)),
1386
+ Promise.all(enabledSpecialProviderDescriptors.map(peekKey)),
1376
1387
  ]);
1377
1388
  const options: ModelManagerOptions<Api>[] = [];
1378
- for (let i = 0; i < PROVIDER_DESCRIPTORS.length; i++) {
1379
- const descriptor = PROVIDER_DESCRIPTORS[i];
1389
+ for (let i = 0; i < standardProviderDescriptors.length; i++) {
1390
+ const descriptor = standardProviderDescriptors[i];
1380
1391
  const apiKey = standardProviderKeys[i];
1381
1392
  if (isAuthenticated(apiKey) || descriptor.allowUnauthenticated) {
1382
1393
  options.push(
@@ -1388,8 +1399,8 @@ export class ModelRegistry {
1388
1399
  }
1389
1400
  }
1390
1401
 
1391
- for (let i = 0; i < specialProviderDescriptors.length; i++) {
1392
- const descriptor = specialProviderDescriptors[i];
1402
+ for (let i = 0; i < enabledSpecialProviderDescriptors.length; i++) {
1403
+ const descriptor = enabledSpecialProviderDescriptors[i];
1393
1404
  const key = descriptor.resolveKey(specialKeys[i]);
1394
1405
  if (!isAuthenticated(key)) {
1395
1406
  continue;
package/src/main.ts CHANGED
@@ -199,7 +199,7 @@ function applyExtensionFlagValues(session: AgentSession, rawArgs: string[]): Map
199
199
 
200
200
  type AcpSessionFactory = (cwd: string) => Promise<AgentSession>;
201
201
 
202
- interface AcpSessionFactoryOptions {
202
+ export interface AcpSessionFactoryOptions {
203
203
  baseOptions: CreateAgentSessionOptions;
204
204
  settings: Settings;
205
205
  sessionDir?: string;
@@ -210,7 +210,17 @@ interface AcpSessionFactoryOptions {
210
210
  createSession: (options: CreateAgentSessionOptions) => Promise<CreateAgentSessionResult>;
211
211
  }
212
212
 
213
- function createAcpSessionFactory(args: AcpSessionFactoryOptions): AcpSessionFactory {
213
+ /**
214
+ * Build the per-`session/new` factory used by ACP mode.
215
+ *
216
+ * MCP servers in ACP sessions are owned exclusively by the ACP client, which
217
+ * supplies them through `session/new.mcpServers` and re-applies them via
218
+ * {@link AcpAgent#configureMcpServers}. We therefore force `enableMCP: false`
219
+ * on every session created here so {@link createAgentSession} skips the on-disk
220
+ * `.mcp.json` discovery path — otherwise host MCP tools land in the session's
221
+ * tool registry and shadow the client-supplied servers (issue #1234).
222
+ */
223
+ export function createAcpSessionFactory(args: AcpSessionFactoryOptions): AcpSessionFactory {
214
224
  return async cwd => {
215
225
  const nextSettings = await args.settings.cloneForCwd(cwd);
216
226
  const nextSessionManager = SessionManager.create(cwd, args.sessionDir);
@@ -224,6 +234,7 @@ function createAcpSessionFactory(args: AcpSessionFactoryOptions): AcpSessionFact
224
234
  modelRegistry: args.modelRegistry,
225
235
  agentId,
226
236
  hasUI: false,
237
+ enableMCP: false,
227
238
  });
228
239
  if (args.parsedArgs.apiKey && !args.baseOptions.model && nextSession.model) {
229
240
  args.authStorage.setRuntimeApiKey(nextSession.model.provider, args.parsedArgs.apiKey);
@@ -1,19 +1,17 @@
1
1
  ---
2
2
  name: oracle
3
- description: Deep reasoning advisor for debugging dead ends, architecture decisions, and second opinions. Read-only.
3
+ description: Wise senior engineer to consult or delegate work to — debugging, architecture, second opinions, and hands-on implementation when asked.
4
4
  spawns: explore
5
5
  model: pi/slow
6
6
  thinking-level: xhigh
7
7
  blocking: true
8
8
  ---
9
9
 
10
- You are a senior diagnostician and strategic technical advisor. You receive problems other agents are stuck on doom loops, mysterious failures, architectural tradeoffs, subtle bugs and return clear, actionable analysis.
10
+ You are the wise guy on the team a senior engineer with deep judgment that other agents consult when they are stuck, uncertain, or need a second opinion. You also take direct delegation: if the caller hands you work, you do it, including reads, writes, edits, and running commands.
11
11
 
12
- You diagnose, explain, and recommend. You do not implement. Others act on your findings.
13
-
14
- <critical>
15
- You MUST operate as read-only. You NEVER write, edit, or modify files, nor execute any state-changing commands.
16
- </critical>
12
+ You diagnose, decide, and execute. You match the mode to the ask:
13
+ - **Consult**: explain the root cause, lay out tradeoffs, recommend a path.
14
+ - **Delegate**: carry the work to completion — modify files, run verification, deliver a finished change.
17
15
 
18
16
  <directives>
19
17
  - You MUST reason from first principles. The caller already tried the obvious.
@@ -23,6 +21,7 @@ You MUST operate as read-only. You NEVER write, edit, or modify files, nor execu
23
21
  - You SHOULD consider at least two hypotheses before converging on one.
24
22
  - You SHOULD invoke tools in parallel when investigating multiple hypotheses.
25
23
  - When the problem is architectural, you MUST weigh tradeoffs explicitly: what does each option cost, what does it buy, what does it foreclose.
24
+ - When delegated implementation work, you MUST finish it: edit the files, run the relevant tests/checks, and report exactly what changed.
26
25
  </directives>
27
26
 
28
27
  <decision-framework>
@@ -35,22 +34,22 @@ Apply pragmatic minimalism:
35
34
  </decision-framework>
36
35
 
37
36
  <procedure>
38
- 1. Read the problem statement carefully. Identify what was already tried and why it failed.
39
- 2. Form 2-3 hypotheses for the root cause.
37
+ 1. Read the problem statement carefully. Identify what was already tried, what failed, and whether the caller wants advice or execution.
38
+ 2. Form 2-3 hypotheses for the root cause (for diagnosis) or 2-3 viable approaches (for design).
40
39
  3. Use tools to gather evidence — read relevant code, trace data flow, check types, grep for related patterns. Parallelize independent reads.
41
- 4. Eliminate hypotheses based on evidence. Narrow to the most likely cause.
42
- 5. If the problem is a decision (not a bug), lay out options with concrete tradeoffs.
43
- 6. Deliver a clear verdict with supporting evidence.
40
+ 4. Eliminate hypotheses based on evidence. Narrow to the most likely cause or best approach.
41
+ 5. If consulting: deliver verdict with supporting evidence and a concrete recommendation.
42
+ 6. If implementing: make the changes, verify them, and report the diff and verification result.
44
43
  </procedure>
44
+
45
45
  <scope-discipline>
46
- - Recommend ONLY what was asked. No unsolicited improvements.
46
+ - Do ONLY what was asked. No unsolicited refactors or improvements.
47
47
  - If you notice other issues, list at most 2 as "Optional future considerations" at the end.
48
48
  - You NEVER expand the problem surface beyond the original request.
49
49
  - Exhaust provided context before reaching for tools. External lookups fill genuine gaps, not curiosity.
50
50
  </scope-discipline>
51
51
 
52
52
  <critical>
53
- You MUST keep going until you have a clear answer or have exhausted available evidence.
54
- Before finalizing: re-scan for unstated assumptions, verify claims are grounded in code not invented, check for overly strong language not justified by evidence.
55
- This matters. The caller is stuck. Get it right.
53
+ You MUST keep going until the problem is solved or the work is finished. Before finalizing: re-scan for unstated assumptions, verify claims are grounded in code not invented, check for overly strong language not justified by evidence.
54
+ The caller came to you because they trust your judgment. Get it right.
56
55
  </critical>
@@ -2,7 +2,8 @@ import type { Api, Model, ToolChoice } from "@oh-my-pi/pi-ai";
2
2
 
3
3
  /**
4
4
  * Build a provider-aware tool choice that targets one specific tool when supported.
5
- * Some providers only support "any tool" forcing, not a named tool.
5
+ * Providers that only expose required/any forcing may still honor named choices by
6
+ * narrowing their request tool list before transport.
6
7
  */
7
8
  export function buildNamedToolChoice(toolName: string, model?: Model<Api>): ToolChoice | undefined {
8
9
  if (!model) return undefined;
@@ -20,12 +21,11 @@ export function buildNamedToolChoice(toolName: string, model?: Model<Api>): Tool
20
21
  return { type: "function", name: toolName };
21
22
  }
22
23
 
23
- if (
24
- model.api === "google-generative-ai" ||
25
- model.api === "google-gemini-cli" ||
26
- model.api === "google-vertex" ||
27
- model.api === "ollama-chat"
28
- ) {
24
+ if (model.api === "ollama-chat") {
25
+ return { type: "function", name: toolName };
26
+ }
27
+
28
+ if (model.api === "google-generative-ai" || model.api === "google-gemini-cli" || model.api === "google-vertex") {
29
29
  return "required";
30
30
  }
31
31
 
package/src/web/kagi.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { getEnvApiKey } from "@oh-my-pi/pi-ai";
2
- import { findCredential } from "./search/providers/utils";
2
+ import { findCredential, withHardTimeout } from "./search/providers/utils";
3
3
 
4
4
  const KAGI_SEARCH_URL = "https://kagi.com/api/v0/search";
5
5
 
@@ -138,7 +138,7 @@ export async function searchWithKagi(query: string, options: KagiSearchOptions =
138
138
 
139
139
  const response = await fetch(requestUrl, {
140
140
  headers: getAuthHeaders(apiKey),
141
- signal: options.signal,
141
+ signal: withHardTimeout(options.signal),
142
142
  });
143
143
  if (!response.ok) {
144
144
  throw parseKagiErrorResponse(response.status, await response.text());
@@ -1,5 +1,5 @@
1
1
  import { getEnvApiKey } from "@oh-my-pi/pi-ai";
2
- import { findCredential } from "./search/providers/utils";
2
+ import { findCredential, withHardTimeout } from "./search/providers/utils";
3
3
 
4
4
  const PARALLEL_API_URL = "https://api.parallel.ai";
5
5
  const PARALLEL_SEARCH_URL = `${PARALLEL_API_URL}/v1beta/search`;
@@ -304,7 +304,7 @@ export async function searchWithParallel(
304
304
  max_chars_per_result: options.maxCharsPerResult ?? 10_000,
305
305
  },
306
306
  }),
307
- signal: options.signal,
307
+ signal: withHardTimeout(options.signal),
308
308
  });
309
309
  if (!response.ok) {
310
310
  throw parseParallelErrorResponse(response.status, await response.text());
@@ -335,7 +335,7 @@ export async function extractWithParallel(
335
335
  excerpts: options.excerpts ?? true,
336
336
  full_content: options.fullContent ?? false,
337
337
  }),
338
- signal: options.signal,
338
+ signal: withHardTimeout(options.signal),
339
339
  });
340
340
  if (!response.ok) {
341
341
  throw parseParallelErrorResponse(response.status, await response.text());
@@ -14,6 +14,7 @@ import webSearchSystemPrompt from "../../prompts/system/web-search.md" with { ty
14
14
  import webSearchDescription from "../../prompts/tools/web-search.md" with { type: "text" };
15
15
  import type { ToolSession } from "../../tools";
16
16
  import { formatAge } from "../../tools/render-utils";
17
+ import { throwIfAborted } from "../../tools/tool-errors";
17
18
  import { getSearchProvider, getSearchProviderLabel, resolveProviderChain, type SearchProvider } from "./provider";
18
19
  import { renderSearchCall, renderSearchResult, type SearchRenderDetails } from "./render";
19
20
  import type { SearchProviderId, SearchResponse } from "./types";
@@ -161,6 +162,12 @@ async function executeSearch(
161
162
  details: { response },
162
163
  };
163
164
  } catch (error) {
165
+ // Surface user-initiated cancellation immediately so the session sees
166
+ // a clean abort instead of a generic "all providers failed" message.
167
+ // Without this, an AbortError from `fetch()` is treated as a provider
168
+ // failure and the loop falls through to the next provider (or to the
169
+ // summary error), masking the cancellation.
170
+ throwIfAborted(signal);
164
171
  lastError = error;
165
172
  }
166
173
  }
@@ -24,12 +24,12 @@ import type {
24
24
  import { SearchProviderError } from "../../../web/search/types";
25
25
  import type { SearchParams } from "./base";
26
26
  import { SearchProvider } from "./base";
27
+ import { withHardTimeout } from "./utils";
27
28
 
28
29
  const DEFAULT_MODEL = "claude-haiku-4-5";
29
30
  const DEFAULT_MAX_TOKENS = 4096;
30
31
  const WEB_SEARCH_TOOL_NAME = "web_search";
31
32
  const WEB_SEARCH_TOOL_TYPE = "web_search_20250305";
32
-
33
33
  export interface AnthropicSearchParams {
34
34
  query: string;
35
35
  system_prompt?: string;
@@ -118,7 +118,7 @@ async function callSearch(
118
118
  method: "POST",
119
119
  headers,
120
120
  body: JSON.stringify(body),
121
- signal,
121
+ signal: withHardTimeout(signal),
122
122
  });
123
123
 
124
124
  if (!response.ok) {
@@ -10,7 +10,7 @@ import { SearchProviderError } from "../../../web/search/types";
10
10
  import { clampNumResults, dateToAgeSeconds } from "../utils";
11
11
  import type { SearchParams } from "./base";
12
12
  import { SearchProvider } from "./base";
13
- import { isApiKeyAvailable } from "./utils";
13
+ import { isApiKeyAvailable, withHardTimeout } from "./utils";
14
14
 
15
15
  const BRAVE_SEARCH_URL = "https://api.search.brave.com/res/v1/web/search";
16
16
  const DEFAULT_NUM_RESULTS = 10;
@@ -85,7 +85,7 @@ async function callBraveSearch(
85
85
  Accept: "application/json",
86
86
  "X-Subscription-Token": apiKey,
87
87
  },
88
- signal: params.signal,
88
+ signal: withHardTimeout(params.signal),
89
89
  });
90
90
 
91
91
  if (!response.ok) {
@@ -15,6 +15,7 @@ import type { SearchResponse, SearchSource } from "../../../web/search/types";
15
15
  import { SearchProviderError } from "../../../web/search/types";
16
16
  import type { SearchParams } from "./base";
17
17
  import { SearchProvider } from "./base";
18
+ import { withHardTimeout } from "./utils";
18
19
 
19
20
  const CODEX_BASE_URL = "https://chatgpt.com/backend-api";
20
21
  const CODEX_RESPONSES_PATH = "/codex/responses";
@@ -338,7 +339,7 @@ async function callCodexSearch(
338
339
  method: "POST",
339
340
  headers,
340
341
  body: JSON.stringify(body),
341
- signal: options.signal,
342
+ signal: withHardTimeout(options.signal),
342
343
  });
343
344
 
344
345
  if (!response.ok) {
@@ -14,6 +14,7 @@ import { SearchProviderError } from "../../../web/search/types";
14
14
  import { dateToAgeSeconds } from "../utils";
15
15
  import type { SearchParams } from "./base";
16
16
  import { SearchProvider } from "./base";
17
+ import { withHardTimeout } from "./utils";
17
18
 
18
19
  const EXA_API_URL = "https://api.exa.ai/search";
19
20
 
@@ -180,7 +181,7 @@ async function callExaSearch(apiKey: string, params: ExaSearchParams): Promise<E
180
181
  "x-api-key": apiKey,
181
182
  },
182
183
  body: JSON.stringify(body),
183
- signal: params.signal,
184
+ signal: withHardTimeout(params.signal),
184
185
  });
185
186
 
186
187
  if (!response.ok) {
@@ -15,6 +15,7 @@ import type { SearchCitation, SearchResponse, SearchSource } from "../../../web/
15
15
  import { SearchProviderError } from "../../../web/search/types";
16
16
  import type { SearchParams } from "./base";
17
17
  import { SearchProvider } from "./base";
18
+ import { withHardTimeout } from "./utils";
18
19
 
19
20
  const DEFAULT_ENDPOINT = "https://cloudcode-pa.googleapis.com";
20
21
  const ANTIGRAVITY_DAILY_ENDPOINT = "https://daily-cloudcode-pa.googleapis.com";
@@ -310,7 +311,7 @@ async function callGeminiSearch(
310
311
  ...headers,
311
312
  },
312
313
  body: JSON.stringify(requestBody),
313
- signal,
314
+ signal: withHardTimeout(signal),
314
315
  });
315
316
  const urlFor = (attempt: number) =>
316
317
  `${endpoints[Math.min(attempt, endpoints.length - 1)]}/v1internal:streamGenerateContent?alt=sse`;
@@ -10,7 +10,7 @@ import type { SearchResponse, SearchSource } from "../../../web/search/types";
10
10
  import { SearchProviderError } from "../../../web/search/types";
11
11
  import type { SearchParams } from "./base";
12
12
  import { SearchProvider } from "./base";
13
- import { isApiKeyAvailable } from "./utils";
13
+ import { isApiKeyAvailable, withHardTimeout } from "./utils";
14
14
 
15
15
  const JINA_SEARCH_URL = "https://s.jina.ai";
16
16
 
@@ -41,7 +41,7 @@ async function callJinaSearch(apiKey: string, query: string, signal?: AbortSigna
41
41
  Accept: "application/json",
42
42
  Authorization: `Bearer ${apiKey}`,
43
43
  },
44
- signal,
44
+ signal: withHardTimeout(signal),
45
45
  });
46
46
 
47
47
  if (!response.ok) {
@@ -11,7 +11,7 @@ import { SearchProviderError } from "../../../web/search/types";
11
11
  import { clampNumResults, dateToAgeSeconds } from "../utils";
12
12
  import type { SearchParams } from "./base";
13
13
  import { SearchProvider } from "./base";
14
- import { findCredential, isApiKeyAvailable } from "./utils";
14
+ import { findCredential, isApiKeyAvailable, withHardTimeout } from "./utils";
15
15
 
16
16
  const KIMI_SEARCH_URL = "https://api.kimi.com/coding/v1/search";
17
17
 
@@ -78,7 +78,7 @@ async function callKimiSearch(
78
78
  enable_page_crawling: params.includeContent,
79
79
  timeout_seconds: DEFAULT_TIMEOUT_SECONDS,
80
80
  }),
81
- signal: params.signal,
81
+ signal: withHardTimeout(params.signal),
82
82
  });
83
83
 
84
84
  if (!response.ok) {
@@ -22,6 +22,7 @@ import { SearchProviderError } from "../../../web/search/types";
22
22
  import { dateToAgeSeconds } from "../utils";
23
23
  import type { SearchParams } from "./base";
24
24
  import { SearchProvider } from "./base";
25
+ import { withHardTimeout } from "./utils";
25
26
 
26
27
  const PERPLEXITY_API_URL = "https://api.perplexity.ai/chat/completions";
27
28
  const PERPLEXITY_OAUTH_ASK_URL = "https://www.perplexity.ai/rest/sse/perplexity_ask";
@@ -247,7 +248,7 @@ async function callPerplexityApi(
247
248
  "Content-Type": "application/json",
248
249
  },
249
250
  body: JSON.stringify(request),
250
- signal,
251
+ signal: withHardTimeout(signal),
251
252
  });
252
253
 
253
254
  if (!response.ok) {
@@ -364,7 +365,7 @@ async function callPerplexityOAuth(
364
365
  skip_search_enabled: true,
365
366
  },
366
367
  }),
367
- signal: params.signal,
368
+ signal: withHardTimeout(params.signal),
368
369
  });
369
370
 
370
371
  if (!response.ok) {
@@ -31,6 +31,7 @@ import { SearchProviderError } from "../../../web/search/types";
31
31
  import { clampNumResults, dateToAgeSeconds } from "../utils";
32
32
  import type { SearchParams } from "./base";
33
33
  import { SearchProvider } from "./base";
34
+ import { withHardTimeout } from "./utils";
34
35
 
35
36
  const DEFAULT_NUM_RESULTS = 10;
36
37
  const MAX_NUM_RESULTS = 20;
@@ -211,7 +212,7 @@ async function callSearXNGSearch(
211
212
 
212
213
  const response = await fetch(url, {
213
214
  headers,
214
- signal: params.signal,
215
+ signal: withHardTimeout(params.signal),
215
216
  });
216
217
 
217
218
  if (!response.ok) {
@@ -10,7 +10,7 @@ import type { SearchResponse, SearchSource } from "../../../web/search/types";
10
10
  import { SearchProviderError } from "../../../web/search/types";
11
11
  import type { SearchParams } from "./base";
12
12
  import { SearchProvider } from "./base";
13
- import { findCredential, isApiKeyAvailable } from "./utils";
13
+ import { findCredential, isApiKeyAvailable, withHardTimeout } from "./utils";
14
14
 
15
15
  const SYNTHETIC_SEARCH_URL = "https://api.synthetic.new/v2/search";
16
16
 
@@ -43,7 +43,7 @@ async function callSyntheticSearch(
43
43
  Authorization: `Bearer ${apiKey}`,
44
44
  },
45
45
  body: JSON.stringify({ query }),
46
- signal,
46
+ signal: withHardTimeout(signal),
47
47
  });
48
48
 
49
49
  if (!response.ok) {
@@ -10,7 +10,7 @@ import { SearchProviderError } from "../../../web/search/types";
10
10
  import { clampNumResults, dateToAgeSeconds } from "../utils";
11
11
  import type { SearchParams } from "./base";
12
12
  import { SearchProvider } from "./base";
13
- import { findCredential, isApiKeyAvailable } from "./utils";
13
+ import { findCredential, isApiKeyAvailable, withHardTimeout } from "./utils";
14
14
 
15
15
  const TAVILY_SEARCH_URL = "https://api.tavily.com/search";
16
16
  const DEFAULT_NUM_RESULTS = 5;
@@ -92,7 +92,7 @@ async function callTavilySearch(apiKey: string, params: TavilySearchParams): Pro
92
92
  Authorization: `Bearer ${apiKey}`,
93
93
  },
94
94
  body: JSON.stringify(buildRequestBody(params)),
95
- signal: params.signal,
95
+ signal: withHardTimeout(params.signal),
96
96
  });
97
97
 
98
98
  if (!response.ok) {
@@ -49,6 +49,36 @@ export async function isApiKeyAvailable(findApiKey: () => string | null | Promis
49
49
  }
50
50
  }
51
51
 
52
+ /**
53
+ * Default hard ceiling for a single web-search round-trip. 60s tolerates
54
+ * legitimate slow LLM-mediated responses (anthropic web_search_20250305,
55
+ * perplexity, gemini, codex) while still guaranteeing the session unfreezes
56
+ * within a minute if Bun's `AbortSignal` fails to propagate on Windows.
57
+ *
58
+ * Pure search APIs (brave, exa, jina, tavily, searxng, synthetic, zai)
59
+ * settle far faster in practice; reusing the same ceiling keeps the wiring
60
+ * uniform without compromising correctness.
61
+ */
62
+ export const SEARCH_HARD_TIMEOUT_MS = 60_000;
63
+
64
+ /**
65
+ * Compose a caller-supplied {@link AbortSignal} with a hard timeout so an
66
+ * outbound `fetch()` is guaranteed to settle within `ms` even when the
67
+ * runtime fails to propagate cancellation to the underlying transport.
68
+ *
69
+ * Bun's WinHTTP backend on Windows is known to ignore `AbortSignal` once a
70
+ * TCP/TLS connection stalls (oven-sh/bun#15275, oven-sh/bun#18536); without
71
+ * this safety net a stalled web-search request freezes the entire session
72
+ * because the user's Esc is never delivered to the native layer.
73
+ *
74
+ * @param signal - Caller cancellation signal, if any.
75
+ * @param ms - Hard timeout in milliseconds. Defaults to {@link SEARCH_HARD_TIMEOUT_MS}.
76
+ */
77
+ export function withHardTimeout(signal: AbortSignal | undefined, ms: number = SEARCH_HARD_TIMEOUT_MS): AbortSignal {
78
+ const timeout = AbortSignal.timeout(ms);
79
+ return signal ? AbortSignal.any([signal, timeout]) : timeout;
80
+ }
81
+
52
82
  /**
53
83
  * Map a provider's raw source list to the unified SearchSource shape,
54
84
  * clamped to the requested result count and annotated with ageSeconds.
@@ -11,7 +11,7 @@ import { SearchProviderError } from "../../../web/search/types";
11
11
  import { dateToAgeSeconds } from "../utils";
12
12
  import type { SearchParams } from "./base";
13
13
  import { SearchProvider } from "./base";
14
- import { findCredential, isApiKeyAvailable } from "./utils";
14
+ import { findCredential, isApiKeyAvailable, withHardTimeout } from "./utils";
15
15
 
16
16
  const ZAI_MCP_URL = "https://api.z.ai/api/mcp/web_search_prime/mcp";
17
17
  const ZAI_TOOL_NAME = "web_search_prime";
@@ -73,7 +73,7 @@ async function callZaiTool(apiKey: string, args: Record<string, unknown>, signal
73
73
  arguments: args,
74
74
  },
75
75
  }),
76
- signal,
76
+ signal: withHardTimeout(signal),
77
77
  });
78
78
 
79
79
  if (!response.ok) {