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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +30 -1
  2. package/dist/types/config/settings-schema.d.ts +10 -0
  3. package/dist/types/eval/py/kernel.d.ts +6 -0
  4. package/dist/types/goals/state.d.ts +1 -1
  5. package/dist/types/goals/tools/goal-tool.d.ts +4 -0
  6. package/dist/types/hashline/parser.d.ts +6 -2
  7. package/dist/types/internal-urls/memory-protocol.d.ts +6 -0
  8. package/dist/types/modes/theme/shimmer.d.ts +27 -0
  9. package/dist/types/slash-commands/helpers/format.d.ts +4 -1
  10. package/dist/types/tools/ast-edit.d.ts +3 -0
  11. package/dist/types/tools/ast-grep.d.ts +3 -0
  12. package/dist/types/tools/find.d.ts +3 -0
  13. package/dist/types/tools/search.d.ts +3 -0
  14. package/dist/types/tui/file-list.d.ts +6 -0
  15. package/dist/types/tui/hyperlink.d.ts +42 -0
  16. package/dist/types/tui/index.d.ts +1 -0
  17. package/dist/types/web/search/providers/utils.d.ts +2 -1
  18. package/package.json +7 -7
  19. package/src/config/settings-schema.ts +12 -0
  20. package/src/config/settings.ts +28 -5
  21. package/src/edit/renderer.ts +5 -3
  22. package/src/eval/py/executor.ts +12 -1
  23. package/src/eval/py/kernel.ts +24 -8
  24. package/src/extensibility/plugins/legacy-pi-compat.ts +2 -2
  25. package/src/goals/runtime.ts +9 -3
  26. package/src/goals/state.ts +1 -1
  27. package/src/goals/tools/goal-tool.ts +12 -2
  28. package/src/hashline/diff.ts +1 -1
  29. package/src/hashline/execute.ts +2 -2
  30. package/src/hashline/parser.ts +87 -12
  31. package/src/internal-urls/memory-protocol.ts +1 -1
  32. package/src/modes/interactive-mode.ts +29 -1
  33. package/src/modes/theme/shimmer.ts +79 -0
  34. package/src/prompts/tools/goal.md +7 -2
  35. package/src/session/agent-session.ts +12 -75
  36. package/src/slash-commands/helpers/format.ts +23 -3
  37. package/src/task/executor.ts +115 -19
  38. package/src/tools/ast-edit.ts +39 -6
  39. package/src/tools/ast-grep.ts +38 -6
  40. package/src/tools/find.ts +13 -2
  41. package/src/tools/read.ts +46 -6
  42. package/src/tools/search.ts +447 -265
  43. package/src/tui/file-list.ts +10 -2
  44. package/src/tui/hyperlink.ts +126 -0
  45. package/src/tui/index.ts +1 -0
  46. package/src/web/search/index.ts +13 -9
  47. package/src/web/search/providers/anthropic.ts +3 -1
  48. package/src/web/search/providers/brave.ts +3 -1
  49. package/src/web/search/providers/codex.ts +3 -1
  50. package/src/web/search/providers/exa.ts +3 -1
  51. package/src/web/search/providers/gemini.ts +3 -1
  52. package/src/web/search/providers/jina.ts +3 -1
  53. package/src/web/search/providers/kagi.ts +5 -1
  54. package/src/web/search/providers/kimi.ts +3 -1
  55. package/src/web/search/providers/parallel.ts +5 -1
  56. package/src/web/search/providers/perplexity.ts +5 -1
  57. package/src/web/search/providers/searxng.ts +3 -1
  58. package/src/web/search/providers/synthetic.ts +3 -1
  59. package/src/web/search/providers/tavily.ts +3 -1
  60. package/src/web/search/providers/utils.ts +33 -1
  61. package/src/web/search/providers/zai.ts +3 -1
@@ -7,6 +7,9 @@ import { renderTreeList } from "./tree-list";
7
7
 
8
8
  export interface FileEntry {
9
9
  path: string;
10
+ /** Absolute filesystem path. When provided together with {@link FileListOptions.hyperlinkFn}, the
11
+ * rendered path text is wrapped in an OSC 8 hyperlink. */
12
+ absPath?: string;
10
13
  isDirectory?: boolean;
11
14
  meta?: string;
12
15
  }
@@ -16,10 +19,13 @@ export interface FileListOptions {
16
19
  expanded?: boolean;
17
20
  maxCollapsed?: number;
18
21
  showIcons?: boolean;
22
+ /** When provided, called with the entry's absolute path and the ANSI-styled display string to
23
+ * optionally wrap the path in an OSC 8 hyperlink. Only invoked when {@link FileEntry.absPath} is set. */
24
+ hyperlinkFn?: (absPath: string, displayText: string) => string;
19
25
  }
20
26
 
21
27
  export function renderFileList(options: FileListOptions, theme: Theme): string[] {
22
- const { files, expanded = false, maxCollapsed = 8, showIcons = true } = options;
28
+ const { files, expanded = false, maxCollapsed = 8, showIcons = true, hyperlinkFn } = options;
23
29
 
24
30
  return renderTreeList(
25
31
  {
@@ -39,7 +45,9 @@ export function renderFileList(options: FileListOptions, theme: Theme): string[]
39
45
  const labelColor = isDirectory ? "accent" : "toolOutput";
40
46
  const meta = entry.meta ? ` ${theme.fg("dim", entry.meta)}` : "";
41
47
  const iconPrefix = icon ? `${icon} ` : "";
42
- return `${iconPrefix}${theme.fg(labelColor, displayPath)}${meta}`;
48
+ const pathStr = theme.fg(labelColor, displayPath);
49
+ const linkedPath = entry.absPath && hyperlinkFn ? hyperlinkFn(entry.absPath, pathStr) : pathStr;
50
+ return `${iconPrefix}${linkedPath}${meta}`;
43
51
  },
44
52
  },
45
53
  theme,
@@ -0,0 +1,126 @@
1
+ /**
2
+ * OSC 8 terminal hyperlink support for file paths.
3
+ *
4
+ * Wraps display text in `ESC ] 8 ; id=HASH ; URI ESC \ TEXT ESC ] 8 ; ; ESC \`
5
+ * sequences when the active terminal supports hyperlinks and the user setting
6
+ * permits it. Falls back to plain text when disabled.
7
+ */
8
+ import { TERMINAL } from "@oh-my-pi/pi-tui";
9
+ import { settings } from "../config/settings";
10
+ import {
11
+ LocalProtocolHandler,
12
+ memoryRootsFromRegistry,
13
+ parseInternalUrl,
14
+ resolveLocalUrlToPath,
15
+ resolveMemoryUrlToPath,
16
+ } from "../internal-urls";
17
+
18
+ const OSC = "\x1b]";
19
+ const ST = "\x1b\\";
20
+
21
+ /** Stable 8-char hex ID derived from a URI — hints terminals to coalesce identical adjacent links. */
22
+ function buildLinkId(uri: string): string {
23
+ let h = 0;
24
+ for (let i = 0; i < uri.length; i++) {
25
+ // FNV-1a-inspired mix — good enough for a UI hint, no deps
26
+ h = (Math.imul(31, h) + uri.charCodeAt(i)) | 0;
27
+ }
28
+ return (h >>> 0).toString(16).padStart(8, "0");
29
+ }
30
+
31
+ /** Build a `file://` URI for an absolute path with optional line/col query params. */
32
+ function buildFileUri(absPath: string, opts?: { line?: number; col?: number }): string {
33
+ // Normalize backslashes for Windows paths before constructing the URL.
34
+ const normalized = absPath.replaceAll("\\", "/");
35
+ const prefix = normalized.startsWith("/") ? "file://" : "file:///";
36
+ // Split on slashes, encode each component, reassemble.
37
+ const encoded = normalized
38
+ .split("/")
39
+ .map(segment => encodeURIComponent(segment))
40
+ .join("/");
41
+ const params: string[] = [];
42
+ if (opts?.line !== undefined) params.push(`line=${opts.line}`);
43
+ if (opts?.col !== undefined) params.push(`col=${opts.col}`);
44
+ const query = params.length > 0 ? `?${params.join("&")}` : "";
45
+ return `${prefix}${encoded}${query}`;
46
+ }
47
+
48
+ /**
49
+ * Returns true when OSC 8 hyperlinks should be emitted.
50
+ *
51
+ * Respects `tui.hyperlinks` setting:
52
+ * - `"off"`: never
53
+ * - `"auto"`: when `process.stdout.isTTY`, `NO_COLOR` is unset, and the detected terminal reports hyperlink support
54
+ * - `"always"`: unconditionally (useful for viewers that support OSC 8 without advertising it)
55
+ */
56
+ export function isHyperlinkEnabled(): boolean {
57
+ const mode = settings.get("tui.hyperlinks");
58
+ if (mode === "off") return false;
59
+ if (mode === "always") return true;
60
+ // auto: respect terminal capabilities and NO_COLOR
61
+ if (Bun.env.NO_COLOR) return false;
62
+ if (!process.stdout.isTTY) return false;
63
+ return TERMINAL.hyperlinks;
64
+ }
65
+
66
+ /**
67
+ * Wrap `displayText` in an OSC 8 hyperlink pointing at the given absolute file path.
68
+ *
69
+ * Returns `displayText` unchanged when hyperlinks are disabled or when
70
+ * the text already contains an OSC 8 sequence (prevents double-wrapping).
71
+ *
72
+ * The caller is responsible for passing an absolute path. Relative paths
73
+ * produce invalid `file://` URIs and are accepted silently to avoid runtime
74
+ * errors in renderer hot paths.
75
+ *
76
+ * @param absPath - Absolute filesystem path
77
+ * @param displayText - Text to render as the hyperlink anchor (may contain ANSI codes)
78
+ * @param opts - Optional line/col position appended as `?line=N&col=M` query params
79
+ */
80
+ export function fileHyperlink(absPath: string, displayText: string, opts?: { line?: number; col?: number }): string {
81
+ if (!isHyperlinkEnabled()) return displayText;
82
+ // Do not double-wrap if the text already embeds an OSC 8 sequence.
83
+ if (displayText.includes("\x1b]8;")) return displayText;
84
+ const uri = buildFileUri(absPath, opts);
85
+ const id = buildLinkId(uri);
86
+ return `${OSC}8;id=${id};${uri}${ST}${displayText}${OSC}8;;${ST}`;
87
+ }
88
+
89
+ /**
90
+ * Synchronously resolve a filesystem-backed internal URL (e.g. `local://foo.md`,
91
+ * `memory://root/notes.md`) to its absolute filesystem path. Returns `undefined`
92
+ * for inputs that aren't fs-backed, aren't resolvable in the current session
93
+ * registry, or fail to parse.
94
+ *
95
+ * Used by renderers to wrap fs-backed internal URLs in OSC 8 hyperlinks even
96
+ * when the resolved path isn't yet available from tool result details (e.g.
97
+ * during the call/streaming phase before a result lands).
98
+ *
99
+ * Async-resolved schemes (`artifact://`, `agent://`, `skill://`, `rule://`,
100
+ * `omp://`) are not handled here — those rely on `details.resolvedPath` set
101
+ * by the read tool's router resolution.
102
+ */
103
+ export function tryResolveInternalUrlSync(input: string): string | undefined {
104
+ try {
105
+ if (input.startsWith("local://")) {
106
+ const opts = LocalProtocolHandler.resolveOptions();
107
+ if (!opts) return undefined;
108
+ return resolveLocalUrlToPath(input, opts);
109
+ }
110
+ if (input.startsWith("memory://")) {
111
+ const url = parseInternalUrl(input);
112
+ const roots = memoryRootsFromRegistry();
113
+ for (const root of roots) {
114
+ try {
115
+ return resolveMemoryUrlToPath(url, root);
116
+ } catch {
117
+ // Try the next root; some sessions may not have this namespace mounted.
118
+ }
119
+ }
120
+ return undefined;
121
+ }
122
+ } catch {
123
+ return undefined;
124
+ }
125
+ return undefined;
126
+ }
package/src/tui/index.ts CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  export * from "./code-cell";
6
6
  export * from "./file-list";
7
+ export * from "./hyperlink";
7
8
  export * from "./output-block";
8
9
  export * from "./status-line";
9
10
  export * from "./tree-list";
@@ -36,10 +36,6 @@ export interface SearchQueryParams extends SearchToolParams {
36
36
  provider?: SearchProviderId | "auto";
37
37
  }
38
38
 
39
- function formatProviderList(providers: SearchProvider[]): string {
40
- return providers.map(provider => provider.label).join(", ");
41
- }
42
-
43
39
  function formatProviderError(error: unknown, provider: SearchProvider): string {
44
40
  if (error instanceof SearchProviderError) {
45
41
  if (error.provider === "anthropic" && error.status === 404) {
@@ -138,9 +134,8 @@ async function executeSearch(
138
134
  };
139
135
  }
140
136
 
141
- let lastError: unknown;
137
+ const failures: Array<{ provider: SearchProvider; error: unknown }> = [];
142
138
  let lastProvider = providers[0];
143
-
144
139
  for (const provider of providers) {
145
140
  lastProvider = provider;
146
141
  try {
@@ -168,14 +163,23 @@ async function executeSearch(
168
163
  // failure and the loop falls through to the next provider (or to the
169
164
  // summary error), masking the cancellation.
170
165
  throwIfAborted(signal);
171
- lastError = error;
166
+ failures.push({ provider, error });
172
167
  }
173
168
  }
174
169
 
175
- const baseMessage = formatProviderError(lastError, lastProvider);
170
+ const lastFailure = failures[failures.length - 1];
171
+ const baseMessage = lastFailure
172
+ ? formatProviderError(lastFailure.error, lastFailure.provider)
173
+ : `Unknown error from ${lastProvider.label}`;
176
174
  const message =
177
175
  providers.length > 1
178
- ? `All web search providers failed (${formatProviderList(providers)}). Last error: ${baseMessage}`
176
+ ? `All web search providers failed: ${failures
177
+ .map(f =>
178
+ f.error instanceof SearchProviderError
179
+ ? f.error.message
180
+ : `${f.provider.id}: ${formatProviderError(f.error, f.provider)}`,
181
+ )
182
+ .join("; ")}`
179
183
  : baseMessage;
180
184
 
181
185
  return {
@@ -24,7 +24,7 @@ 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
+ import { classifyProviderHttpError, withHardTimeout } from "./utils";
28
28
 
29
29
  const DEFAULT_MODEL = "claude-haiku-4-5";
30
30
  const DEFAULT_MAX_TOKENS = 4096;
@@ -123,6 +123,8 @@ async function callSearch(
123
123
 
124
124
  if (!response.ok) {
125
125
  const errorText = await response.text();
126
+ const classified = classifyProviderHttpError("anthropic", response.status, errorText);
127
+ if (classified) throw classified;
126
128
  throw new SearchProviderError(
127
129
  "anthropic",
128
130
  `Anthropic API error (${response.status}): ${errorText}`,
@@ -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, withHardTimeout } from "./utils";
13
+ import { classifyProviderHttpError, 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;
@@ -90,6 +90,8 @@ async function callBraveSearch(
90
90
 
91
91
  if (!response.ok) {
92
92
  const errorText = await response.text();
93
+ const classified = classifyProviderHttpError("brave", response.status, errorText);
94
+ if (classified) throw classified;
93
95
  throw new SearchProviderError("brave", `Brave API error (${response.status}): ${errorText}`, response.status);
94
96
  }
95
97
 
@@ -15,7 +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
+ import { classifyProviderHttpError, withHardTimeout } from "./utils";
19
19
 
20
20
  const CODEX_BASE_URL = "https://chatgpt.com/backend-api";
21
21
  const CODEX_RESPONSES_PATH = "/codex/responses";
@@ -344,6 +344,8 @@ async function callCodexSearch(
344
344
 
345
345
  if (!response.ok) {
346
346
  const errorText = await response.text();
347
+ const classified = classifyProviderHttpError("codex", response.status, errorText);
348
+ if (classified) throw classified;
347
349
  throw new SearchProviderError("codex", `Codex API error (${response.status}): ${errorText}`, response.status);
348
350
  }
349
351
 
@@ -14,7 +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
+ import { classifyProviderHttpError, withHardTimeout } from "./utils";
18
18
 
19
19
  const EXA_API_URL = "https://api.exa.ai/search";
20
20
 
@@ -186,6 +186,8 @@ async function callExaSearch(apiKey: string, params: ExaSearchParams): Promise<E
186
186
 
187
187
  if (!response.ok) {
188
188
  const errorText = await response.text();
189
+ const classified = classifyProviderHttpError("exa", response.status, errorText);
190
+ if (classified) throw classified;
189
191
  throw new SearchProviderError("exa", `Exa API error (${response.status}): ${errorText}`, response.status);
190
192
  }
191
193
 
@@ -15,7 +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
+ import { classifyProviderHttpError, withHardTimeout } from "./utils";
19
19
 
20
20
  const DEFAULT_ENDPOINT = "https://cloudcode-pa.googleapis.com";
21
21
  const ANTIGRAVITY_DAILY_ENDPOINT = "https://daily-cloudcode-pa.googleapis.com";
@@ -341,6 +341,8 @@ async function callGeminiSearch(
341
341
 
342
342
  if (!response.ok) {
343
343
  const errorText = await response.text();
344
+ const classified = classifyProviderHttpError("gemini", response.status, errorText);
345
+ if (classified) throw classified;
344
346
  throw new SearchProviderError(
345
347
  "gemini",
346
348
  `Gemini Cloud Code API error (${response.status}): ${errorText}`,
@@ -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, withHardTimeout } from "./utils";
13
+ import { classifyProviderHttpError, isApiKeyAvailable, withHardTimeout } from "./utils";
14
14
 
15
15
  const JINA_SEARCH_URL = "https://s.jina.ai";
16
16
 
@@ -46,6 +46,8 @@ async function callJinaSearch(apiKey: string, query: string, signal?: AbortSigna
46
46
 
47
47
  if (!response.ok) {
48
48
  const errorText = await response.text();
49
+ const classified = classifyProviderHttpError("jina", response.status, errorText);
50
+ if (classified) throw classified;
49
51
  throw new SearchProviderError("jina", `Jina API error (${response.status}): ${errorText}`, response.status);
50
52
  }
51
53
 
@@ -9,7 +9,7 @@ import { findKagiApiKey, KagiApiError, searchWithKagi } from "../../kagi";
9
9
  import { clampNumResults } from "../utils";
10
10
  import type { SearchParams } from "./base";
11
11
  import { SearchProvider } from "./base";
12
- import { toSearchSources } from "./utils";
12
+ import { classifyProviderHttpError, toSearchSources } from "./utils";
13
13
 
14
14
  const DEFAULT_NUM_RESULTS = 10;
15
15
  const MAX_NUM_RESULTS = 40;
@@ -36,6 +36,10 @@ export async function searchKagi(params: {
36
36
  };
37
37
  } catch (err) {
38
38
  if (err instanceof KagiApiError) {
39
+ if (typeof err.statusCode === "number") {
40
+ const classified = classifyProviderHttpError("kagi", err.statusCode, err.message);
41
+ if (classified) throw classified;
42
+ }
39
43
  throw new SearchProviderError("kagi", err.message, err.statusCode);
40
44
  }
41
45
  throw err;
@@ -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, withHardTimeout } from "./utils";
14
+ import { classifyProviderHttpError, findCredential, isApiKeyAvailable, withHardTimeout } from "./utils";
15
15
 
16
16
  const KIMI_SEARCH_URL = "https://api.kimi.com/coding/v1/search";
17
17
 
@@ -83,6 +83,8 @@ async function callKimiSearch(
83
83
 
84
84
  if (!response.ok) {
85
85
  const errorText = await response.text();
86
+ const classified = classifyProviderHttpError("kimi", response.status, errorText);
87
+ if (classified) throw classified;
86
88
  throw new SearchProviderError(
87
89
  "kimi",
88
90
  `Kimi search API error (${response.status}): ${errorText}`,
@@ -4,7 +4,7 @@ import { findParallelApiKey, ParallelApiError, searchWithParallel } from "../../
4
4
  import { clampNumResults } from "../utils";
5
5
  import type { SearchParams } from "./base";
6
6
  import { SearchProvider } from "./base";
7
- import { toSearchSources } from "./utils";
7
+ import { classifyProviderHttpError, toSearchSources } from "./utils";
8
8
 
9
9
  const DEFAULT_NUM_RESULTS = 10;
10
10
  const MAX_NUM_RESULTS = 40;
@@ -30,6 +30,10 @@ export async function searchParallel(params: {
30
30
  };
31
31
  } catch (err) {
32
32
  if (err instanceof ParallelApiError) {
33
+ if (typeof err.statusCode === "number") {
34
+ const classified = classifyProviderHttpError("parallel", err.statusCode, err.message);
35
+ if (classified) throw classified;
36
+ }
33
37
  throw new SearchProviderError("parallel", err.message, err.statusCode);
34
38
  }
35
39
  throw err;
@@ -22,7 +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
+ import { classifyProviderHttpError, withHardTimeout } from "./utils";
26
26
 
27
27
  const PERPLEXITY_API_URL = "https://api.perplexity.ai/chat/completions";
28
28
  const PERPLEXITY_OAUTH_ASK_URL = "https://www.perplexity.ai/rest/sse/perplexity_ask";
@@ -253,6 +253,8 @@ async function callPerplexityApi(
253
253
 
254
254
  if (!response.ok) {
255
255
  const errorText = await response.text();
256
+ const classified = classifyProviderHttpError("perplexity", response.status, errorText);
257
+ if (classified) throw classified;
256
258
  throw new SearchProviderError(
257
259
  "perplexity",
258
260
  `Perplexity API error (${response.status}): ${errorText}`,
@@ -370,6 +372,8 @@ async function callPerplexityOAuth(
370
372
 
371
373
  if (!response.ok) {
372
374
  const errorText = await response.text();
375
+ const classified = classifyProviderHttpError("perplexity", response.status, errorText);
376
+ if (classified) throw classified;
373
377
  throw new SearchProviderError(
374
378
  "perplexity",
375
379
  `Perplexity OAuth API error (${response.status}): ${errorText}`,
@@ -31,7 +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
+ import { classifyProviderHttpError, withHardTimeout } from "./utils";
35
35
 
36
36
  const DEFAULT_NUM_RESULTS = 10;
37
37
  const MAX_NUM_RESULTS = 20;
@@ -217,6 +217,8 @@ async function callSearXNGSearch(
217
217
 
218
218
  if (!response.ok) {
219
219
  const errorText = await response.text();
220
+ const classified = classifyProviderHttpError("searxng", response.status, errorText);
221
+ if (classified) throw classified;
220
222
  throw new SearchProviderError("searxng", `SearXNG API error (${response.status}): ${errorText}`, response.status);
221
223
  }
222
224
 
@@ -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, withHardTimeout } from "./utils";
13
+ import { classifyProviderHttpError, findCredential, isApiKeyAvailable, withHardTimeout } from "./utils";
14
14
 
15
15
  const SYNTHETIC_SEARCH_URL = "https://api.synthetic.new/v2/search";
16
16
 
@@ -48,6 +48,8 @@ async function callSyntheticSearch(
48
48
 
49
49
  if (!response.ok) {
50
50
  const errorText = await response.text();
51
+ const classified = classifyProviderHttpError("synthetic", response.status, errorText);
52
+ if (classified) throw classified;
51
53
  throw new SearchProviderError(
52
54
  "synthetic",
53
55
  `Synthetic API error (${response.status}): ${errorText}`,
@@ -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, withHardTimeout } from "./utils";
13
+ import { classifyProviderHttpError, 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;
@@ -97,6 +97,8 @@ async function callTavilySearch(apiKey: string, params: TavilySearchParams): Pro
97
97
 
98
98
  if (!response.ok) {
99
99
  const errorText = await response.text();
100
+ const classified = classifyProviderHttpError("tavily", response.status, errorText);
101
+ if (classified) throw classified;
100
102
  let message = errorText.trim();
101
103
  if (message.length === 0) {
102
104
  message = response.statusText;
@@ -1,6 +1,6 @@
1
1
  import { getAgentDbPath } from "@oh-my-pi/pi-utils";
2
2
  import { AgentStorage } from "../../../session/agent-storage";
3
- import type { SearchSource } from "../../../web/search/types";
3
+ import { SearchProviderError, type SearchProviderId, type SearchSource } from "../../../web/search/types";
4
4
  import { dateToAgeSeconds } from "../utils";
5
5
 
6
6
  /**
@@ -100,3 +100,35 @@ export function toSearchSources(
100
100
  ageSeconds: dateToAgeSeconds(source.publishedDate),
101
101
  }));
102
102
  }
103
+
104
+ /**
105
+ * Quota/auth signals across providers. Telemetry on 15.1.7/15.1.8 showed users
106
+ * hitting credit-exhaustion and 401/402/403 responses that were surfaced as
107
+ * raw HTTP error text. Map those into compact, provider-tagged messages so
108
+ * the orchestrator can chain-advance cleanly and the final summary stays
109
+ * legible when every provider rejects the request.
110
+ *
111
+ * Returns `null` when the response does not match a known quota/auth signal,
112
+ * leaving the caller to throw its provider-specific fallback error.
113
+ */
114
+ const CREDIT_BODY_PATTERN = /credits?\s*(?:exhausted|exceeded)|quota|insufficient/i;
115
+
116
+ export function classifyProviderHttpError(
117
+ provider: SearchProviderId,
118
+ status: number,
119
+ body: string,
120
+ ): SearchProviderError | null {
121
+ if (CREDIT_BODY_PATTERN.test(body)) {
122
+ return new SearchProviderError(provider, `${provider}: credits exhausted`, status);
123
+ }
124
+ if (status === 402) {
125
+ return new SearchProviderError(provider, `${provider}: 402 credits exhausted`, status);
126
+ }
127
+ if (status === 401) {
128
+ return new SearchProviderError(provider, `${provider}: 401 unauthorized`, status);
129
+ }
130
+ if (status === 403) {
131
+ return new SearchProviderError(provider, `${provider}: 403 forbidden`, status);
132
+ }
133
+ return null;
134
+ }
@@ -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, withHardTimeout } from "./utils";
14
+ import { classifyProviderHttpError, 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";
@@ -78,6 +78,8 @@ async function callZaiTool(apiKey: string, args: Record<string, unknown>, signal
78
78
 
79
79
  if (!response.ok) {
80
80
  const errorText = await response.text();
81
+ const classified = classifyProviderHttpError("zai", response.status, errorText);
82
+ if (classified) throw classified;
81
83
  throw new SearchProviderError("zai", `Z.AI MCP error (${response.status}): ${errorText}`, response.status);
82
84
  }
83
85