@oh-my-pi/pi-coding-agent 15.1.7 → 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,35 @@
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
+
27
+ ## [15.1.8] - 2026-05-20
28
+
29
+ ### Fixed
30
+
31
+ - Fixed streaming edit previews for `apply_patch` and `hashline` jittering as the model typed `+added` lines. Two root causes addressed: (1) the trailing partial line of the streaming text input is now trimmed at each tick so a half-typed `+added` line no longer flickers; (2) the preview is rendered in the model's input order during streaming instead of re-deriving a unified diff via `Diff.structuredPatch`, whose coalescing previously reshuffled existing `+added` lines downward each time a new `-removed` line arrived. Existing additions now stay put and the preview only grows at the bottom while streaming. A residual trailing `-removed`/hunk-header block whose matching `+added` companion has not yet arrived is also suppressed until the additions land.
32
+ - Fixed Perplexity web search appearing "logged out" roughly an hour after `omp auth login perplexity`. The search provider's `findOAuthToken` was honoring the bogus `expires = login_time + 1h` written by older logins (Perplexity JWTs typically omit `exp` because sessions are server-side) and silently dropping the credential. The loader now decodes the JWT's `exp` claim directly and only skips when the JWT itself is expired; tokens without an `exp` claim are treated as non-expiring.
33
+
5
34
  ## [15.1.7] - 2026-05-19
6
35
 
7
36
  ### 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
  */
@@ -26,6 +26,13 @@ export interface StreamingDiffContext {
26
26
  fuzzyThreshold?: number;
27
27
  allowFuzzy?: boolean;
28
28
  hashlineAutoDropPureInsertDuplicates?: boolean;
29
+ /**
30
+ * True while the tool's arguments are still streaming in. Strategies that
31
+ * accept free-form text input (apply_patch, hashline) trim the trailing
32
+ * partial line so per-character growth of an in-flight `+added` line does
33
+ * not flicker in the preview.
34
+ */
35
+ isStreaming?: boolean;
29
36
  }
30
37
  export interface EditStreamingStrategy<Args = unknown> {
31
38
  /**
@@ -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.7",
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.7",
51
- "@oh-my-pi/pi-agent-core": "15.1.7",
52
- "@oh-my-pi/pi-ai": "15.1.7",
53
- "@oh-my-pi/pi-natives": "15.1.7",
54
- "@oh-my-pi/pi-tui": "15.1.7",
55
- "@oh-my-pi/pi-utils": "15.1.7",
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;
@@ -45,6 +45,13 @@ export interface StreamingDiffContext {
45
45
  fuzzyThreshold?: number;
46
46
  allowFuzzy?: boolean;
47
47
  hashlineAutoDropPureInsertDuplicates?: boolean;
48
+ /**
49
+ * True while the tool's arguments are still streaming in. Strategies that
50
+ * accept free-form text input (apply_patch, hashline) trim the trailing
51
+ * partial line so per-character growth of an in-flight `+added` line does
52
+ * not flicker in the preview.
53
+ */
54
+ isStreaming?: boolean;
48
55
  }
49
56
 
50
57
  export interface EditStreamingStrategy<Args = unknown> {
@@ -274,21 +281,146 @@ interface HashlineArgs {
274
281
  __partialJson?: string;
275
282
  }
276
283
 
284
+ /**
285
+ * While streaming a free-form text payload (apply_patch envelope, hashline
286
+ * input), trim the trailing partial line so per-character growth of an
287
+ * in-flight `+added` line does not cause the diff preview to flicker. The
288
+ * full line will show on the next streaming tick once its `\n` arrives.
289
+ * Returns `text` unchanged when not streaming or when no newline is present.
290
+ */
291
+ function trimTrailingPartialLine(text: string, isStreaming: boolean | undefined): string {
292
+ if (!isStreaming) return text;
293
+ const idx = text.lastIndexOf("\n");
294
+ if (idx === -1) return "";
295
+ return text.slice(0, idx + 1);
296
+ }
297
+
298
+ /**
299
+ * Build a per-file diff preview directly from a partial `apply_patch`
300
+ * envelope by emitting its body lines in *input order*. This bypasses the
301
+ * file-state re-diff (`computePatchDiff` → `Diff.structuredPatch`) whose
302
+ * coalescing reorders the model's `-old +new -old +new` stream into
303
+ * `-old -old +new +new` and visibly shifts existing `+added` lines
304
+ * downward each time a new `-` arrives. The preview therefore grows
305
+ * monotonically at the bottom while streaming and only becomes a real
306
+ * unified diff once the args are complete.
307
+ */
308
+ function buildApplyPatchNaturalOrderPreviews(input: string): PerFileDiffPreview[] | null {
309
+ const lines = input.split("\n");
310
+ const groups = new Map<string, string[]>();
311
+ let currentPath: string | undefined;
312
+ const ensure = (path: string): string[] => {
313
+ let bucket = groups.get(path);
314
+ if (!bucket) {
315
+ bucket = [];
316
+ groups.set(path, bucket);
317
+ }
318
+ return bucket;
319
+ };
320
+ for (const raw of lines) {
321
+ const trimmedEnd = raw.trimEnd();
322
+ if (trimmedEnd === BEGIN_PATCH_MARKER || trimmedEnd === END_PATCH_MARKER || trimmedEnd === ABORT_MARKER) {
323
+ continue;
324
+ }
325
+ if (trimmedEnd.startsWith("*** Add File: ")) {
326
+ currentPath = trimmedEnd.slice("*** Add File: ".length);
327
+ ensure(currentPath);
328
+ continue;
329
+ }
330
+ if (trimmedEnd.startsWith("*** Delete File: ")) {
331
+ currentPath = trimmedEnd.slice("*** Delete File: ".length);
332
+ ensure(currentPath);
333
+ continue;
334
+ }
335
+ if (trimmedEnd.startsWith("*** Update File: ")) {
336
+ currentPath = trimmedEnd.slice("*** Update File: ".length);
337
+ ensure(currentPath);
338
+ continue;
339
+ }
340
+ if (trimmedEnd.startsWith("*** Move to:") || trimmedEnd.startsWith("*** End of File")) {
341
+ continue;
342
+ }
343
+ if (!currentPath) continue;
344
+ // Diff body: keep `-/+/space`-prefixed lines and `@@` hunk headers in
345
+ // input order. parseDiffLine accepts the no-line-number legacy form so
346
+ // the renderer styles them as additions/removals/context naturally.
347
+ if (raw.startsWith("+") || raw.startsWith("-") || raw.startsWith(" ") || raw.startsWith("@@")) {
348
+ ensure(currentPath).push(raw);
349
+ }
350
+ }
351
+ if (groups.size === 0) return null;
352
+ const previews: PerFileDiffPreview[] = [];
353
+ for (const [path, body] of groups) {
354
+ if (body.length === 0) continue;
355
+ previews.push({ path, diff: body.join("\n") });
356
+ }
357
+ return previews.length > 0 ? previews : null;
358
+ }
359
+
360
+ /**
361
+ * Hashline equivalent: emit each section's `~payload` lines as `+added`
362
+ * lines in the order the model typed them. We deliberately omit op headers
363
+ * and removal targets from the streaming preview because their content
364
+ * lives in the file and would require a costly re-apply per tick; the
365
+ * complete unified diff is shown once streaming finishes.
366
+ */
367
+ function buildHashlineNaturalOrderPreviews(
368
+ input: string,
369
+ defaultPath: string | undefined,
370
+ ): PerFileDiffPreview[] | null {
371
+ const lines = input.split("\n");
372
+ const groups = new Map<string, string[]>();
373
+ let currentPath = defaultPath ?? "";
374
+ const ensure = (path: string): string[] => {
375
+ let bucket = groups.get(path);
376
+ if (!bucket) {
377
+ bucket = [];
378
+ groups.set(path, bucket);
379
+ }
380
+ return bucket;
381
+ };
382
+ for (const raw of lines) {
383
+ if (isHashlineEnvelopeMarkerLine(raw)) continue;
384
+ if (isHashlineHeaderLine(raw)) {
385
+ currentPath = raw.trimEnd().slice(1).trim();
386
+ if (currentPath) ensure(currentPath);
387
+ continue;
388
+ }
389
+ if (raw.startsWith("~")) {
390
+ ensure(currentPath).push(`+${raw.slice(1)}`);
391
+ }
392
+ }
393
+ if (groups.size === 0) return null;
394
+ const previews: PerFileDiffPreview[] = [];
395
+ for (const [path, body] of groups) {
396
+ if (body.length === 0) continue;
397
+ previews.push({ path, diff: body.join("\n") });
398
+ }
399
+ return previews.length > 0 ? previews : null;
400
+ }
401
+
277
402
  const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
278
403
  extractCompleteEdits(args) {
279
404
  return args;
280
405
  },
281
406
  async computeDiffPreview(args, ctx) {
282
407
  if (typeof args.input !== "string" || args.input.length === 0) return null;
408
+ const input = trimTrailingPartialLine(args.input, ctx.isStreaming);
409
+ if (input.length === 0) return null;
410
+ if (ctx.isStreaming) {
411
+ // Skip the costly per-tick re-apply and avoid `Diff.structuredPatch`
412
+ // reordering by showing the model's `~payload` lines in input order.
413
+ return buildHashlineNaturalOrderPreviews(input, args.path);
414
+ }
283
415
  ctx.signal.throwIfAborted();
284
416
 
285
417
  let sections: HashlineInputSection[];
286
418
  try {
287
- sections = splitHashlineInputs(args.input, { cwd: ctx.cwd, path: args.path });
419
+ sections = splitHashlineInputs(input, { cwd: ctx.cwd, path: args.path });
288
420
  } catch {
289
421
  // Single-section fallback keeps the original error rendering for the
290
422
  // "haven't typed `@@ PATH` yet" case.
291
- const result = await computeHashlineDiff({ input: args.input, path: args.path }, ctx.cwd, {
423
+ const result = await computeHashlineDiff({ input, path: args.path }, ctx.cwd, {
292
424
  autoDropPureInsertDuplicates: ctx.hashlineAutoDropPureInsertDuplicates,
293
425
  });
294
426
  ctx.signal.throwIfAborted();
@@ -340,12 +472,21 @@ const applyPatchStrategy: EditStreamingStrategy<ApplyPatchArgs> = {
340
472
  },
341
473
  async computeDiffPreview(args, ctx) {
342
474
  if (typeof args.input !== "string" || args.input.length === 0) return null;
475
+ const input = trimTrailingPartialLine(args.input, ctx.isStreaming);
476
+ if (input.length === 0) return null;
477
+ if (ctx.isStreaming) {
478
+ // Render the envelope's diff body in input order so newly streamed
479
+ // `+added` lines append at the bottom instead of being shuffled
480
+ // upward as later `-removed` lines arrive and reorder the unified
481
+ // diff that `Diff.structuredPatch` would otherwise produce.
482
+ return buildApplyPatchNaturalOrderPreviews(input);
483
+ }
343
484
  let entries: ApplyPatchEntry[];
344
485
  try {
345
- entries = expandApplyPatchToEntries({ input: args.input });
486
+ entries = expandApplyPatchToEntries({ input });
346
487
  } catch {
347
488
  try {
348
- entries = expandApplyPatchToPreviewEntries({ input: args.input });
489
+ entries = expandApplyPatchToPreviewEntries({ input });
349
490
  } catch (err) {
350
491
  return [{ path: "", error: err instanceof Error ? err.message : String(err) }];
351
492
  }
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);
@@ -51,6 +51,49 @@ function cloneToolArgs<T>(args: T): T {
51
51
  }
52
52
  }
53
53
 
54
+ /**
55
+ * Drop trailing removal/hunk-header lines that appear in a streaming diff
56
+ * before the matching `+added` lines have arrived. Without this, a partial
57
+ * apply_patch / hashline preview shows `-old` first and then visibly grows
58
+ * the `+new` block beneath it — the "removals first, additions catching up"
59
+ * jitter. Once the next streaming tick brings the additions in, the trailing
60
+ * block reappears alongside them.
61
+ */
62
+ function stripTrailingUnbalancedRemoval(diff: string | undefined): string | undefined {
63
+ if (!diff) return diff;
64
+ const lines = diff.split("\n");
65
+ let lastAddIdx = -1;
66
+ for (let i = lines.length - 1; i >= 0; i--) {
67
+ if (lines[i].startsWith("+")) {
68
+ lastAddIdx = i;
69
+ break;
70
+ }
71
+ }
72
+ let hasTrailingUnbalanced = false;
73
+ for (let i = lastAddIdx + 1; i < lines.length; i++) {
74
+ const line = lines[i];
75
+ if (line.startsWith("-") || line.startsWith("@@")) {
76
+ hasTrailingUnbalanced = true;
77
+ break;
78
+ }
79
+ }
80
+ if (!hasTrailingUnbalanced) return diff;
81
+ if (lastAddIdx === -1) return "";
82
+ return lines.slice(0, lastAddIdx + 1).join("\n");
83
+ }
84
+
85
+ function stabilizeStreamingPreviews(previews: PerFileDiffPreview[]): PerFileDiffPreview[] {
86
+ let changed = false;
87
+ const next = previews.map(preview => {
88
+ if (!preview.diff) return preview;
89
+ const trimmed = stripTrailingUnbalancedRemoval(preview.diff);
90
+ if (trimmed === preview.diff) return preview;
91
+ changed = true;
92
+ return { ...preview, diff: trimmed ?? "" };
93
+ });
94
+ return changed ? next : previews;
95
+ }
96
+
54
97
  function isEditLikeToolName(toolName: string): boolean {
55
98
  return toolName === "edit" || toolName === "apply_patch";
56
99
  }
@@ -222,16 +265,18 @@ export class ToolExecutionComponent extends Container {
222
265
  this.#editDiffAbort = controller;
223
266
 
224
267
  try {
268
+ const isStreaming = !this.#argsComplete;
225
269
  const previews = await strategy.computeDiffPreview(effectiveArgs, {
226
270
  cwd: this.#cwd,
227
271
  signal: controller.signal,
228
272
  fuzzyThreshold: this.#editFuzzyThreshold,
229
273
  allowFuzzy: this.#editAllowFuzzy,
230
274
  hashlineAutoDropPureInsertDuplicates: this.#hashlineAutoDropPureInsertDuplicates,
275
+ isStreaming,
231
276
  });
232
277
  if (controller.signal.aborted) return;
233
278
  if (previews) {
234
- this.#editDiffPreview = previews;
279
+ this.#editDiffPreview = isStreaming ? stabilizeStreamingPreviews(previews) : previews;
235
280
  this.#updateDisplay();
236
281
  this.#ui.requestRender();
237
282
  }
@@ -584,11 +584,17 @@ export class InteractiveMode implements InteractiveModeContext {
584
584
  if (!this.loopModeEnabled || !this.loopPrompt) return;
585
585
  const prompt = this.loopPrompt;
586
586
  const loopAction = settings.get("loop.mode");
587
+ this.#deferLoopAutoSubmit(() => {
588
+ void this.#runLoopIteration(loopAction, prompt);
589
+ });
590
+ }
591
+
592
+ #deferLoopAutoSubmit(callback: () => void): void {
587
593
  // Brief delay so the user has a chance to press Esc between iterations.
588
594
  this.#loopAutoSubmitTimer = setTimeout(() => {
589
595
  this.#loopAutoSubmitTimer = undefined;
590
596
  if (!this.loopModeEnabled || !this.onInputCallback) return;
591
- void this.#runLoopIteration(loopAction, prompt);
597
+ callback();
592
598
  }, 800);
593
599
  }
594
600
 
@@ -641,7 +647,32 @@ export class InteractiveMode implements InteractiveModeContext {
641
647
  }
642
648
  }
643
649
 
650
+ #isLoopAutoSubmitBlocked(): boolean {
651
+ return this.session.isStreaming || this.session.isCompacting;
652
+ }
653
+
654
+ #submitLoopPromptWhenReady(prompt: string): void {
655
+ if (!this.loopModeEnabled || this.loopPrompt !== prompt || !this.onInputCallback) return;
656
+ if (isLoopDurationExpired(this.loopLimit)) {
657
+ this.disableLoopMode("Loop time limit reached. Loop mode disabled.");
658
+ return;
659
+ }
660
+ if (this.#isLoopAutoSubmitBlocked()) {
661
+ this.#deferLoopAutoSubmit(() => this.#submitLoopPromptWhenReady(prompt));
662
+ return;
663
+ }
664
+ this.onInputCallback(this.startPendingSubmission({ text: prompt }));
665
+ }
666
+
644
667
  async #runLoopIteration(action: "prompt" | "compact" | "reset", prompt: string): Promise<void> {
668
+ if (!this.loopModeEnabled || this.loopPrompt !== prompt || !this.onInputCallback) return;
669
+ if (this.#isLoopAutoSubmitBlocked()) {
670
+ this.#deferLoopAutoSubmit(() => {
671
+ void this.#runLoopIteration(action, prompt);
672
+ });
673
+ return;
674
+ }
675
+
645
676
  if (!consumeLoopLimitIteration(this.loopLimit)) {
646
677
  this.disableLoopMode("Loop limit reached. Loop mode disabled.");
647
678
  return;
@@ -652,12 +683,7 @@ export class InteractiveMode implements InteractiveModeContext {
652
683
  } else if (action === "reset") {
653
684
  await this.handleClearCommand();
654
685
  }
655
- if (!this.loopModeEnabled || !this.onInputCallback) return;
656
- if (isLoopDurationExpired(this.loopLimit)) {
657
- this.disableLoopMode("Loop time limit reached. Loop mode disabled.");
658
- return;
659
- }
660
- this.onInputCallback(this.startPendingSubmission({ text: prompt }));
686
+ this.#submitLoopPromptWhenReady(prompt);
661
687
  }
662
688
 
663
689
  disableLoopMode(message = "Loop mode disabled."): void {
@@ -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";
@@ -174,6 +175,25 @@ export function findApiKey(): string | null {
174
175
  return getEnvApiKey("perplexity") ?? null;
175
176
  }
176
177
 
178
+ /**
179
+ * Decode a Perplexity JWT's `exp` claim, in ms. Returns `undefined` when the
180
+ * token has no `exp` (which is the common case — Perplexity sessions are
181
+ * server-side and effectively non-expiring from the client's POV).
182
+ */
183
+ function jwtExpiryMs(token: string): number | undefined {
184
+ const parts = token.split(".");
185
+ if (parts.length !== 3) return undefined;
186
+ const payload = parts[1];
187
+ if (!payload) return undefined;
188
+ try {
189
+ const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as { exp?: unknown };
190
+ if (typeof decoded.exp !== "number" || !Number.isFinite(decoded.exp)) return undefined;
191
+ return decoded.exp * 1000;
192
+ } catch {
193
+ return undefined;
194
+ }
195
+ }
196
+
177
197
  async function findOAuthToken(): Promise<string | null> {
178
198
  const now = Date.now();
179
199
  try {
@@ -183,7 +203,11 @@ async function findOAuthToken(): Promise<string | null> {
183
203
  if (record.credential.type !== "oauth") continue;
184
204
  const credential = record.credential as PerplexityOAuthCredential;
185
205
  if (!credential.access) continue;
186
- if (credential.expires <= now + OAUTH_EXPIRY_BUFFER_MS) continue;
206
+ // Trust the JWT's own `exp` claim if it has one; otherwise treat as
207
+ // non-expiring. The stored `expires` field is unreliable: older logins
208
+ // wrote `loginTime + 1h` even though Perplexity JWTs typically lack `exp`.
209
+ const jwtExpiry = jwtExpiryMs(credential.access);
210
+ if (jwtExpiry !== undefined && jwtExpiry <= now + OAUTH_EXPIRY_BUFFER_MS) continue;
187
211
  return credential.access;
188
212
  }
189
213
  } catch {
@@ -224,7 +248,7 @@ async function callPerplexityApi(
224
248
  "Content-Type": "application/json",
225
249
  },
226
250
  body: JSON.stringify(request),
227
- signal,
251
+ signal: withHardTimeout(signal),
228
252
  });
229
253
 
230
254
  if (!response.ok) {
@@ -341,7 +365,7 @@ async function callPerplexityOAuth(
341
365
  skip_search_enabled: true,
342
366
  },
343
367
  }),
344
- signal: params.signal,
368
+ signal: withHardTimeout(params.signal),
345
369
  });
346
370
 
347
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) {