@oh-my-pi/pi-coding-agent 15.10.7 → 15.10.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.
Files changed (80) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/dist/types/config/model-registry.d.ts +4 -2
  3. package/dist/types/config/model-resolver.d.ts +2 -0
  4. package/dist/types/config/settings-schema.d.ts +9 -0
  5. package/dist/types/extensibility/custom-tools/loader.d.ts +22 -3
  6. package/dist/types/extensibility/custom-tools/types.d.ts +3 -1
  7. package/dist/types/extensibility/extensions/index.d.ts +1 -1
  8. package/dist/types/extensibility/extensions/loader.d.ts +17 -1
  9. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +8 -0
  10. package/dist/types/mcp/oauth-discovery.d.ts +4 -1
  11. package/dist/types/mcp/oauth-flow.d.ts +6 -1
  12. package/dist/types/mcp/transports/stdio.d.ts +12 -0
  13. package/dist/types/modes/components/custom-editor.d.ts +3 -2
  14. package/dist/types/sdk.d.ts +42 -2
  15. package/dist/types/task/executor.d.ts +16 -0
  16. package/dist/types/tools/fetch.d.ts +2 -1
  17. package/dist/types/tools/index.d.ts +20 -1
  18. package/dist/types/tools/report-tool-issue.d.ts +5 -0
  19. package/dist/types/tui/hyperlink.d.ts +8 -0
  20. package/dist/types/web/kagi.d.ts +2 -1
  21. package/dist/types/web/parallel.d.ts +3 -0
  22. package/dist/types/web/search/providers/anthropic.d.ts +2 -1
  23. package/dist/types/web/search/providers/base.d.ts +2 -1
  24. package/dist/types/web/search/providers/brave.d.ts +2 -1
  25. package/dist/types/web/search/providers/codex.d.ts +2 -1
  26. package/dist/types/web/search/providers/exa.d.ts +2 -1
  27. package/dist/types/web/search/providers/gemini.d.ts +2 -1
  28. package/dist/types/web/search/providers/jina.d.ts +7 -2
  29. package/dist/types/web/search/providers/kagi.d.ts +7 -2
  30. package/dist/types/web/search/providers/kimi.d.ts +7 -2
  31. package/dist/types/web/search/providers/parallel.d.ts +2 -1
  32. package/dist/types/web/search/providers/perplexity.d.ts +2 -1
  33. package/dist/types/web/search/providers/searxng.d.ts +2 -1
  34. package/dist/types/web/search/providers/synthetic.d.ts +7 -3
  35. package/dist/types/web/search/providers/tavily.d.ts +2 -1
  36. package/dist/types/web/search/providers/zai.d.ts +2 -1
  37. package/package.json +9 -9
  38. package/src/config/model-registry.ts +13 -7
  39. package/src/config/model-resolver.ts +57 -2
  40. package/src/config/settings-schema.ts +6 -0
  41. package/src/extensibility/custom-tools/loader.ts +43 -19
  42. package/src/extensibility/custom-tools/types.ts +3 -1
  43. package/src/extensibility/extensions/index.ts +1 -0
  44. package/src/extensibility/extensions/loader.ts +29 -6
  45. package/src/extensibility/plugins/legacy-pi-compat.ts +30 -6
  46. package/src/internal-urls/docs-index.generated.ts +1 -1
  47. package/src/mcp/oauth-discovery.ts +8 -3
  48. package/src/mcp/oauth-flow.ts +12 -5
  49. package/src/mcp/transports/stdio.ts +139 -3
  50. package/src/modes/components/assistant-message.ts +28 -6
  51. package/src/modes/components/custom-editor.ts +69 -9
  52. package/src/modes/components/transcript-container.ts +77 -25
  53. package/src/modes/controllers/input-controller.ts +1 -1
  54. package/src/modes/controllers/mcp-command-controller.ts +2 -2
  55. package/src/sdk.ts +138 -56
  56. package/src/ssh/ssh-executor.ts +60 -4
  57. package/src/task/executor.ts +19 -0
  58. package/src/task/index.ts +4 -0
  59. package/src/tools/fetch.ts +22 -5
  60. package/src/tools/image-gen.ts +33 -11
  61. package/src/tools/index.ts +21 -2
  62. package/src/tools/report-tool-issue.ts +7 -1
  63. package/src/tui/hyperlink.ts +27 -3
  64. package/src/web/kagi.ts +5 -2
  65. package/src/web/parallel.ts +7 -3
  66. package/src/web/search/providers/anthropic.ts +5 -1
  67. package/src/web/search/providers/base.ts +2 -1
  68. package/src/web/search/providers/brave.ts +5 -2
  69. package/src/web/search/providers/codex.ts +6 -2
  70. package/src/web/search/providers/exa.ts +91 -8
  71. package/src/web/search/providers/gemini.ts +6 -0
  72. package/src/web/search/providers/jina.ts +15 -5
  73. package/src/web/search/providers/kagi.ts +9 -2
  74. package/src/web/search/providers/kimi.ts +18 -4
  75. package/src/web/search/providers/parallel.ts +6 -2
  76. package/src/web/search/providers/perplexity.ts +7 -4
  77. package/src/web/search/providers/searxng.ts +6 -2
  78. package/src/web/search/providers/synthetic.ts +9 -5
  79. package/src/web/search/providers/tavily.ts +4 -2
  80. package/src/web/search/providers/zai.ts +15 -4
@@ -42,6 +42,42 @@ export interface SSHResult {
42
42
  artifactId?: string;
43
43
  }
44
44
 
45
+ type SSHExitEvent = { kind: "exit"; exitCode: number } | { kind: "error"; error: unknown };
46
+
47
+ function sshExitEvent(exitCode: number): SSHExitEvent {
48
+ return { kind: "exit", exitCode };
49
+ }
50
+
51
+ function sshErrorEvent(error: unknown): SSHExitEvent {
52
+ return { kind: "error", error };
53
+ }
54
+
55
+ function createAbortWaiter(
56
+ signal: AbortSignal | undefined,
57
+ streamAbort: AbortController,
58
+ ): { promise: Promise<ptree.AbortError> | undefined; cleanup: () => void } {
59
+ if (!signal) {
60
+ return { promise: undefined, cleanup: () => {} };
61
+ }
62
+
63
+ const { promise, resolve } = Promise.withResolvers<ptree.AbortError>();
64
+ const onAbort = () => {
65
+ const error = new ptree.AbortError(signal.reason, "<cancelled>");
66
+ if (!streamAbort.signal.aborted) {
67
+ streamAbort.abort(error);
68
+ }
69
+ resolve(error);
70
+ };
71
+
72
+ if (signal.aborted) {
73
+ onAbort();
74
+ return { promise, cleanup: () => {} };
75
+ }
76
+
77
+ signal.addEventListener("abort", onAbort, { once: true });
78
+ return { promise, cleanup: () => signal.removeEventListener("abort", onAbort) };
79
+ }
80
+
45
81
  function quoteForCompatShell(command: string): string {
46
82
  if (command.length === 0) {
47
83
  return "''";
@@ -94,19 +130,37 @@ export async function executeSSH(
94
130
  maxColumns: resolveOutputMaxColumns(settings),
95
131
  });
96
132
 
97
- const streams = [child.stdout.pipeTo(sink.createInput())];
133
+ const streamAbort = new AbortController();
134
+ const abortWaiter = createAbortWaiter(options?.signal, streamAbort);
135
+ const streamOptions = { signal: streamAbort.signal };
136
+ const streams = [child.stdout.pipeTo(sink.createInput(), streamOptions)];
98
137
  if (child.stderr) {
99
- streams.push(child.stderr.pipeTo(sink.createInput()));
138
+ streams.push(child.stderr.pipeTo(sink.createInput(), streamOptions));
100
139
  }
101
- await Promise.allSettled(streams).catch(() => {});
140
+ const streamsSettled = Promise.allSettled(streams).then(() => {});
102
141
 
103
142
  try {
143
+ const exitEvent = child.exited.then(sshExitEvent, sshErrorEvent);
144
+ const abortEvent = abortWaiter.promise?.then(sshErrorEvent);
145
+ const event = await (abortEvent ? Promise.race([exitEvent, abortEvent]) : exitEvent);
146
+ if (event.kind === "error") {
147
+ throw event.error;
148
+ }
149
+
150
+ const streamEvent = await (abortEvent ? Promise.race([streamsSettled, abortEvent]) : streamsSettled);
151
+ if (streamEvent?.kind === "error") {
152
+ throw streamEvent.error;
153
+ }
104
154
  return {
105
- exitCode: await child.exited,
155
+ exitCode: event.exitCode,
106
156
  cancelled: false,
107
157
  ...(await sink.dump()),
108
158
  };
109
159
  } catch (err) {
160
+ if (!streamAbort.signal.aborted) {
161
+ streamAbort.abort(err);
162
+ }
163
+ void streamsSettled;
110
164
  if (err instanceof ptree.Exception) {
111
165
  if (err instanceof ptree.TimeoutError) {
112
166
  return {
@@ -129,5 +183,7 @@ export async function executeSSH(
129
183
  };
130
184
  }
131
185
  throw err;
186
+ } finally {
187
+ abortWaiter.cleanup();
132
188
  }
133
189
  }
@@ -8,11 +8,13 @@ import path from "node:path";
8
8
  import type { AgentEvent, AgentIdentity, AgentTelemetryConfig, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
9
9
  import { recordHandoff, resolveTelemetry } from "@oh-my-pi/pi-agent-core";
10
10
  import { logger, prompt, untilAborted } from "@oh-my-pi/pi-utils";
11
+ import type { Rule } from "../capability/rule";
11
12
  import { ModelRegistry } from "../config/model-registry";
12
13
  import { resolveModelOverrideWithAuthFallback } from "../config/model-resolver";
13
14
  import type { PromptTemplate } from "../config/prompt-templates";
14
15
  import { Settings } from "../config/settings";
15
16
  import { SETTINGS_SCHEMA, type SettingPath } from "../config/settings-schema";
17
+ import type { ToolPathWithSource } from "../extensibility/custom-tools";
16
18
  import type { CustomTool } from "../extensibility/custom-tools/types";
17
19
  import { runExtensionCompact, runExtensionSetModel } from "../extensibility/extensions/compact-handler";
18
20
  import { getSessionSlashCommands } from "../extensibility/extensions/get-commands-handler";
@@ -190,6 +192,20 @@ export interface ExecutorOptions {
190
192
  skills?: Skill[];
191
193
  promptTemplates?: PromptTemplate[];
192
194
  workspaceTree?: WorkspaceTree;
195
+ /** Parent-discovered rules, forwarded to skip rule discovery in the subagent. */
196
+ rules?: Rule[];
197
+ /**
198
+ * Parent's discovered extension source paths. Forwarded to skip the
199
+ * extension FS scan in the subagent; the subagent then re-binds each
200
+ * extension against its own `ExtensionAPI` (cwd, eventBus, runtime).
201
+ */
202
+ preloadedExtensionPaths?: string[];
203
+ /**
204
+ * Parent's discovered custom-tool source paths. Forwarded to skip the
205
+ * `.omp/tools/` FS scan in the subagent; the subagent then re-binds each
206
+ * tool against its own `CustomToolAPI` (cwd, exec, pushPendingAction, UI).
207
+ */
208
+ preloadedCustomToolPaths?: ToolPathWithSource[];
193
209
  mcpManager?: MCPManager;
194
210
  authStorage?: AuthStorage;
195
211
  modelRegistry?: ModelRegistry;
@@ -1284,6 +1300,9 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1284
1300
  skills: options.skills,
1285
1301
  promptTemplates: options.promptTemplates,
1286
1302
  workspaceTree: options.workspaceTree,
1303
+ rules: options.rules,
1304
+ preloadedExtensionPaths: options.preloadedExtensionPaths,
1305
+ preloadedCustomToolPaths: options.preloadedCustomToolPaths,
1287
1306
  systemPrompt: defaultPrompt => {
1288
1307
  const subagentPrompt = prompt.render(subagentSystemPromptTemplate, {
1289
1308
  agent: agent.systemPrompt,
package/src/task/index.ts CHANGED
@@ -990,6 +990,9 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
990
990
  autoloadSkills: resolvedAutoloadSkills,
991
991
  workspaceTree: this.session.workspaceTree,
992
992
  promptTemplates,
993
+ rules: this.session.rules,
994
+ preloadedExtensionPaths: this.session.extensionPaths,
995
+ preloadedCustomToolPaths: this.session.customToolPaths,
993
996
  localProtocolOptions,
994
997
  parentArtifactManager,
995
998
  parentHindsightSessionState: this.session.getHindsightSessionState?.(),
@@ -1048,6 +1051,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1048
1051
  autoloadSkills: resolvedAutoloadSkills,
1049
1052
  workspaceTree: this.session.workspaceTree,
1050
1053
  promptTemplates,
1054
+ rules: this.session.rules,
1051
1055
  localProtocolOptions,
1052
1056
  parentArtifactManager,
1053
1057
  parentHindsightSessionState: this.session.getHindsightSessionState?.(),
@@ -3,7 +3,7 @@ import * as fs from "node:fs/promises";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
5
  import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
6
- import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
6
+ import type { FetchImpl, ImageContent, TextContent } from "@oh-my-pi/pi-ai";
7
7
  import { htmlToMarkdown } from "@oh-my-pi/pi-natives";
8
8
  import { type Component, Text } from "@oh-my-pi/pi-tui";
9
9
  import { $which, ptree, truncate } from "@oh-my-pi/pi-utils";
@@ -637,6 +637,7 @@ export async function renderHtmlToText(
637
637
  settings: Settings,
638
638
  userSignal: AbortSignal | undefined,
639
639
  storage: AgentStorage | null,
640
+ fetchOverride?: FetchImpl,
640
641
  ): Promise<{ content: string; ok: boolean; method: string }> {
641
642
  const overallSignal = ptree.combineSignals(userSignal, timeout * 1000);
642
643
  const execOptions = {
@@ -650,6 +651,7 @@ export async function renderHtmlToText(
650
651
  // Per-attempt budget for remote endpoints so one stall cannot consume the
651
652
  // whole reader-mode budget and starve the local fallbacks.
652
653
  const remoteSignal = () => ptree.combineSignals(userSignal, remoteBudgetMs);
654
+ const fetchImpl = fetchOverride ?? fetch;
653
655
 
654
656
  const runners: Record<FetchProvider, () => Promise<string | null>> = {
655
657
  // Purely local, no network/subprocess: still works on already-loaded HTML
@@ -670,14 +672,20 @@ export async function renderHtmlToText(
670
672
  if (!findParallelApiKey(storage)) return null;
671
673
  const parallelResult = await extractWithParallel(
672
674
  [url],
673
- { objective: "Extract the main content", excerpts: true, fullContent: false, signal: remoteSignal() },
675
+ {
676
+ objective: "Extract the main content",
677
+ excerpts: true,
678
+ fullContent: false,
679
+ signal: remoteSignal(),
680
+ fetch: fetchImpl,
681
+ },
674
682
  storage,
675
683
  );
676
684
  const firstDocument = parallelResult.results[0];
677
685
  return firstDocument ? getParallelExtractContent(firstDocument) : null;
678
686
  },
679
687
  jina: async () => {
680
- const response = await fetch(`https://r.jina.ai/${url}`, {
688
+ const response = await fetchImpl(`https://r.jina.ai/${url}`, {
681
689
  headers: { Accept: "text/markdown" },
682
690
  signal: remoteSignal(),
683
691
  });
@@ -1052,6 +1060,7 @@ async function renderUrl(
1052
1060
  settings: Settings,
1053
1061
  signal: AbortSignal | undefined,
1054
1062
  storage: AgentStorage | null,
1063
+ fetchOverride?: FetchImpl,
1055
1064
  ): Promise<FetchRenderResult> {
1056
1065
  const notes: string[] = [];
1057
1066
  const fetchedAt = new Date().toISOString();
@@ -1425,7 +1434,15 @@ async function renderUrl(
1425
1434
  }
1426
1435
 
1427
1436
  // 5E: Render HTML via the reader-backend chain (native/trafilatura/lynx/parallel/jina)
1428
- const htmlResult = await renderHtmlToText(finalUrl, rawContent, timeout, settings, signal, storage);
1437
+ const htmlResult = await renderHtmlToText(
1438
+ finalUrl,
1439
+ rawContent,
1440
+ timeout,
1441
+ settings,
1442
+ signal,
1443
+ storage,
1444
+ fetchOverride,
1445
+ );
1429
1446
  if (!htmlResult.ok) {
1430
1447
  notes.push("html rendering failed (no reader backend produced usable output)");
1431
1448
 
@@ -1626,7 +1643,7 @@ async function buildReadUrlCacheEntry(
1626
1643
  }
1627
1644
 
1628
1645
  const storage = session.settings.getStorage();
1629
- const result = await renderUrl(url, effectiveTimeout, raw, session.settings, signal, storage);
1646
+ const result = await renderUrl(url, effectiveTimeout, raw, session.settings, signal, storage, session.fetch);
1630
1647
  const output = buildUrlReadOutput(result, result.content);
1631
1648
  const artifactId = options?.ensureArtifact ? await persistReadUrlArtifact(session, output) : undefined;
1632
1649
 
@@ -1,6 +1,13 @@
1
1
  import * as os from "node:os";
2
2
  import * as path from "node:path";
3
- import { type ApiKey, getAntigravityUserAgent, getEnvApiKey, type Model, withAuth } from "@oh-my-pi/pi-ai";
3
+ import {
4
+ type ApiKey,
5
+ type FetchImpl,
6
+ getAntigravityUserAgent,
7
+ getEnvApiKey,
8
+ type Model,
9
+ withAuth,
10
+ } from "@oh-my-pi/pi-ai";
4
11
  import {
5
12
  CODEX_BASE_URL,
6
13
  getCodexAccountId,
@@ -366,7 +373,11 @@ function toDataUrl(image: InlineImageData): string {
366
373
  return `data:${image.mimeType};base64,${image.data}`;
367
374
  }
368
375
 
369
- async function loadImageFromUrl(imageUrl: string, signal?: AbortSignal): Promise<InlineImageData> {
376
+ async function loadImageFromUrl(
377
+ imageUrl: string,
378
+ fetchImpl: FetchImpl,
379
+ signal?: AbortSignal,
380
+ ): Promise<InlineImageData> {
370
381
  if (imageUrl.startsWith("data:")) {
371
382
  const normalized = normalizeDataUrl(imageUrl.trim());
372
383
  if (!normalized.mimeType) {
@@ -378,7 +389,7 @@ async function loadImageFromUrl(imageUrl: string, signal?: AbortSignal): Promise
378
389
  return { data: normalized.data, mimeType: normalized.mimeType };
379
390
  }
380
391
 
381
- const response = await fetch(imageUrl, { signal });
392
+ const response = await fetchImpl(imageUrl, { signal });
382
393
  if (!response.ok) {
383
394
  const rawText = await response.text();
384
395
  throw new Error(`Image download failed (${response.status}): ${rawText}`);
@@ -850,13 +861,14 @@ async function generateOpenAIHostedImage(
850
861
  model: Model,
851
862
  params: ImageGenParams,
852
863
  inputImages: InlineImageData[],
864
+ fetchImpl: FetchImpl,
853
865
  signal: AbortSignal | undefined,
854
866
  sessionId: string | undefined,
855
867
  ): Promise<OpenAIHostedImageResult> {
856
868
  const promptText = assemblePrompt(params);
857
869
  const stream = model.api === "openai-codex-responses" || model.provider === "openai-codex";
858
870
  const requestBody = buildOpenAIHostedImageRequest(model, promptText, params, inputImages, stream);
859
- const response = await fetch(getOpenAIResponsesUrl(model), {
871
+ const response = await fetchImpl(getOpenAIResponsesUrl(model), {
860
872
  method: "POST",
861
873
  headers: buildOpenAIImageHeaders(model, apiKey, sessionId),
862
874
  body: JSON.stringify(requestBody),
@@ -1035,6 +1047,7 @@ export const imageGenTool: CustomTool<typeof imageGenSchema, ImageGenToolDetails
1035
1047
  }
1036
1048
 
1037
1049
  const requestSignal = ptree.combineSignals(signal, IMAGE_TIMEOUT);
1050
+ const fetchImpl = ctx.fetch ?? fetch;
1038
1051
 
1039
1052
  if (provider === "openai" || provider === "openai-codex") {
1040
1053
  if (!apiKey.model) {
@@ -1049,7 +1062,16 @@ export const imageGenTool: CustomTool<typeof imageGenSchema, ImageGenToolDetails
1049
1062
 
1050
1063
  const parsed = await withAuth(
1051
1064
  hostedKey,
1052
- key => generateOpenAIHostedImage(key, hostedModel, params, resolvedImages, requestSignal, sessionId),
1065
+ key =>
1066
+ generateOpenAIHostedImage(
1067
+ key,
1068
+ hostedModel,
1069
+ params,
1070
+ resolvedImages,
1071
+ fetchImpl,
1072
+ requestSignal,
1073
+ sessionId,
1074
+ ),
1053
1075
  { signal: requestSignal },
1054
1076
  );
1055
1077
 
@@ -1117,7 +1139,7 @@ export const imageGenTool: CustomTool<typeof imageGenSchema, ImageGenToolDetails
1117
1139
  resolvedImages,
1118
1140
  );
1119
1141
 
1120
- const resp = await fetch(`${ANTIGRAVITY_ENDPOINT}/v1internal:streamGenerateContent?alt=sse`, {
1142
+ const resp = await fetchImpl(`${ANTIGRAVITY_ENDPOINT}/v1internal:streamGenerateContent?alt=sse`, {
1121
1143
  method: "POST",
1122
1144
  headers: {
1123
1145
  Authorization: `Bearer ${bearer}`,
@@ -1225,7 +1247,7 @@ export const imageGenTool: CustomTool<typeof imageGenSchema, ImageGenToolDetails
1225
1247
  const xaiRawText = await withAuth(
1226
1248
  xaiKey,
1227
1249
  async key => {
1228
- const resp = await fetch(`${xaiCreds.baseURL}${xaiEndpoint}`, {
1250
+ const resp = await fetchImpl(`${xaiCreds.baseURL}${xaiEndpoint}`, {
1229
1251
  method: "POST",
1230
1252
  headers: {
1231
1253
  Authorization: `Bearer ${key}`,
@@ -1263,7 +1285,7 @@ export const imageGenTool: CustomTool<typeof imageGenSchema, ImageGenToolDetails
1263
1285
  const mimeType = parseImageMetadata(bytes)?.mimeType ?? "image/png";
1264
1286
  xaiInlineImages.push({ data: entry.b64_json, mimeType });
1265
1287
  } else if (entry.url) {
1266
- xaiInlineImages.push(await loadImageFromUrl(entry.url, requestSignal));
1288
+ xaiInlineImages.push(await loadImageFromUrl(entry.url, fetchImpl, requestSignal));
1267
1289
  }
1268
1290
  }
1269
1291
 
@@ -1309,7 +1331,7 @@ export const imageGenTool: CustomTool<typeof imageGenSchema, ImageGenToolDetails
1309
1331
  };
1310
1332
 
1311
1333
  const rawText = await withAuth(apiKey.apiKey, async key => {
1312
- const resp = await fetch("https://openrouter.ai/api/v1/chat/completions", {
1334
+ const resp = await fetchImpl("https://openrouter.ai/api/v1/chat/completions", {
1313
1335
  method: "POST",
1314
1336
  headers: {
1315
1337
  "Content-Type": "application/json",
@@ -1343,7 +1365,7 @@ export const imageGenTool: CustomTool<typeof imageGenSchema, ImageGenToolDetails
1343
1365
  const imageUrls = extractOpenRouterImageUrls(message);
1344
1366
  const inlineImages: InlineImageData[] = [];
1345
1367
  for (const imageUrl of imageUrls) {
1346
- inlineImages.push(await loadImageFromUrl(imageUrl, requestSignal));
1368
+ inlineImages.push(await loadImageFromUrl(imageUrl, fetchImpl, requestSignal));
1347
1369
  }
1348
1370
 
1349
1371
  if (inlineImages.length === 0) {
@@ -1404,7 +1426,7 @@ export const imageGenTool: CustomTool<typeof imageGenSchema, ImageGenToolDetails
1404
1426
  };
1405
1427
 
1406
1428
  const rawText = await withAuth(apiKey.apiKey, async key => {
1407
- const resp = await fetch(
1429
+ const resp = await fetchImpl(
1408
1430
  `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent`,
1409
1431
  {
1410
1432
  method: "POST",
@@ -1,12 +1,14 @@
1
1
  import type { InMemorySnapshotStore } from "@oh-my-pi/hashline";
2
2
  import type { AgentTelemetryConfig, AgentTool } from "@oh-my-pi/pi-agent-core";
3
- import type { ToolChoice } from "@oh-my-pi/pi-ai";
3
+ import type { FetchImpl, ToolChoice } from "@oh-my-pi/pi-ai";
4
4
  import { logger } from "@oh-my-pi/pi-utils";
5
5
  import type { AsyncJobManager } from "../async/job-manager";
6
+ import type { Rule } from "../capability/rule";
6
7
  import type { PromptTemplate } from "../config/prompt-templates";
7
8
  import type { Settings } from "../config/settings";
8
9
  import { EditTool } from "../edit";
9
10
  import { checkPythonKernelAvailability } from "../eval/py/kernel";
11
+ import type { ToolPathWithSource } from "../extensibility/custom-tools";
10
12
  import type { Skill } from "../extensibility/skills";
11
13
  import type { GoalModeState, GoalRuntime } from "../goals";
12
14
  import { GoalTool } from "../goals/tools/goal-tool";
@@ -142,6 +144,8 @@ export interface ToolSession {
142
144
  cwd: string;
143
145
  /** Whether UI is available */
144
146
  hasUI: boolean;
147
+ /** Optional fetch implementation injected into the URL read pipeline (tests, proxies). Defaults to global fetch. */
148
+ fetch?: FetchImpl;
145
149
  /** Skip Python kernel availability check and warmup */
146
150
  skipPythonPreflight?: boolean;
147
151
  /** Pre-loaded context files (AGENTS.md, etc) */
@@ -152,6 +156,21 @@ export interface ToolSession {
152
156
  skills?: Skill[];
153
157
  /** Pre-loaded prompt templates */
154
158
  promptTemplates?: PromptTemplate[];
159
+ /** Pre-loaded rules (forwarded to subagents to skip re-discovery). */
160
+ rules?: Rule[];
161
+ /**
162
+ * Pre-discovered extension source paths. Forwarded to subagents so they
163
+ * skip the FS scan but still re-bind extensions to their own session-scoped
164
+ * `ExtensionAPI` (cwd, eventBus, runtime). Inline extension factories
165
+ * (`<inline-N>`) are NOT included — those are session-local.
166
+ */
167
+ extensionPaths?: string[];
168
+ /**
169
+ * Pre-discovered custom-tool source paths from `.omp/tools/`, `.claude/tools/`,
170
+ * plugins, etc. Forwarded to subagents so they skip the FS scan but still
171
+ * re-bind tools to their own session-scoped `CustomToolAPI`.
172
+ */
173
+ customToolPaths?: ToolPathWithSource[];
155
174
  /** Whether LSP integrations are enabled */
156
175
  enableLsp?: boolean;
157
176
  /** Whether an edit-capable tool is available in this session (controls hashline output) */
@@ -492,7 +511,7 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
492
511
  const isToolAllowed = (name: string) => {
493
512
  if (name === "goal") return goalEnabled && goalModeActive;
494
513
  if (name === "lsp") return enableLsp && session.settings.get("lsp.enabled");
495
- if (name === "bash") return true;
514
+ if (name === "bash") return session.settings.get("bash.enabled");
496
515
  if (name === "eval") return allowEval;
497
516
  if (name === "debug") return session.settings.get("debug.enabled");
498
517
  if (name === "todo") return !includeYield && session.settings.get("todo.enabled");
@@ -22,6 +22,7 @@
22
22
  import { Database } from "bun:sqlite";
23
23
  import path from "node:path";
24
24
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
25
+ import type { FetchImpl } from "@oh-my-pi/pi-ai";
25
26
  import { $env, $flag, getAgentDir, getInstallId, logger, VERSION } from "@oh-my-pi/pi-utils";
26
27
  import * as z from "zod/v4";
27
28
  import type { Settings } from "..";
@@ -260,6 +261,10 @@ export interface FlushOptions {
260
261
  * future debug recipes); never set from the tool's auto-flush path.
261
262
  */
262
263
  bypassConsent?: boolean;
264
+ /**
265
+ * Fetch implementation for the push POST. Defaults to global fetch.
266
+ */
267
+ fetch?: FetchImpl;
263
268
  /**
264
269
  * Fires once at the start of the loop with the snapshot count of
265
270
  * unpushed rows. Subsequent inserts won't be reflected (the count is
@@ -345,6 +350,7 @@ async function performFlush(db: Database, config: PushConfig, options: FlushOpti
345
350
  const totalRow = db.prepare("SELECT COUNT(*) AS n FROM grievances WHERE pushed = 0").get() as { n: number };
346
351
  options.onStart(totalRow.n);
347
352
  }
353
+ const fetchImpl = options.fetch ?? fetch;
348
354
  let totalPushed = 0;
349
355
  for (;;) {
350
356
  const rows = selectStmt.all(FLUSH_BATCH_SIZE) as GrievanceRow[];
@@ -366,7 +372,7 @@ async function performFlush(db: Database, config: PushConfig, options: FlushOpti
366
372
 
367
373
  let response: Response;
368
374
  try {
369
- response = await fetch(config.endpoint, {
375
+ response = await fetchImpl(config.endpoint, {
370
376
  method: "POST",
371
377
  headers,
372
378
  body,
@@ -18,6 +18,7 @@ import {
18
18
 
19
19
  const OSC = "\x1b]";
20
20
  const ST = "\x1b\\";
21
+ const BEL = "\x07";
21
22
 
22
23
  /** Stable 8-char hex ID derived from a URI — hints terminals to coalesce identical adjacent links. */
23
24
  function buildLinkId(uri: string): string {
@@ -60,14 +61,18 @@ function safeHyperlinkUri(uri: string): string | undefined {
60
61
  return uri;
61
62
  }
62
63
 
63
- function wrapHyperlink(uri: string, displayText: string): string {
64
- if (!isHyperlinkEnabled()) return displayText;
64
+ function wrapHyperlinkCore(uri: string, displayText: string, terminator: typeof ST | typeof BEL): string {
65
65
  // Do not double-wrap if the text already embeds an OSC 8 sequence.
66
66
  if (displayText.includes("\x1b]8;")) return displayText;
67
67
  const safeUri = safeHyperlinkUri(uri);
68
68
  if (!safeUri) return displayText;
69
69
  const id = buildLinkId(safeUri);
70
- return `${OSC}8;id=${id};${safeUri}${ST}${displayText}${OSC}8;;${ST}`;
70
+ return `${OSC}8;id=${id};${safeUri}${terminator}${displayText}${OSC}8;;${terminator}`;
71
+ }
72
+
73
+ function wrapHyperlink(uri: string, displayText: string): string {
74
+ if (!isHyperlinkEnabled()) return displayText;
75
+ return wrapHyperlinkCore(uri, displayText, ST);
71
76
  }
72
77
 
73
78
  /**
@@ -95,6 +100,25 @@ export function urlHyperlink(url: string, displayText: string): string {
95
100
  }
96
101
  }
97
102
 
103
+ /**
104
+ * Wrap `displayText` in an OSC 8 hyperlink pointing at an HTTP(S) URL,
105
+ * bypassing terminal capability auto-detection. Used for auth prompts where
106
+ * an inert "click" label blocks login on terminals whose capabilities are
107
+ * not advertised. Still returns plain text when the user has explicitly
108
+ * opted out via `tui.hyperlinks=off`.
109
+ */
110
+ export function urlHyperlinkAlways(url: string, displayText: string): string {
111
+ if (settings.get("tui.hyperlinks") === "off") return displayText;
112
+ const normalized = url.match(/^www\./i) ? `https://${url}` : url;
113
+ try {
114
+ const parsed = new URL(normalized);
115
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return displayText;
116
+ return wrapHyperlinkCore(parsed.href, displayText, BEL);
117
+ } catch {
118
+ return displayText;
119
+ }
120
+ }
121
+
98
122
  /**
99
123
  * Wrap `displayText` in an OSC 8 hyperlink pointing at a filesystem path.
100
124
  *
package/src/web/kagi.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * through the shared {@link AuthStorage} broker (Bearer token), and responses
7
7
  * are categorized result buckets rather than the legacy flat object array.
8
8
  */
9
- import type { AuthStorage } from "@oh-my-pi/pi-ai";
9
+ import type { AuthStorage, FetchImpl } from "@oh-my-pi/pi-ai";
10
10
  import { withHardTimeout } from "./search/providers/utils";
11
11
 
12
12
  const KAGI_SEARCH_URL = "https://kagi.com/api/v1/search";
@@ -156,6 +156,7 @@ export interface KagiSearchOptions {
156
156
  recency?: "day" | "week" | "month" | "year";
157
157
  sessionId?: string;
158
158
  signal?: AbortSignal;
159
+ fetch?: FetchImpl;
159
160
  }
160
161
 
161
162
  export interface KagiSearchSource {
@@ -251,7 +252,9 @@ export async function searchWithKagi(
251
252
  throw new KagiApiError("Kagi credentials not found. Set KAGI_API_KEY or login with 'omp /login kagi'.");
252
253
  }
253
254
 
254
- const response = await fetch(KAGI_SEARCH_URL, {
255
+ const fetchImpl = options.fetch ?? fetch;
256
+
257
+ const response = await fetchImpl(KAGI_SEARCH_URL, {
255
258
  method: "POST",
256
259
  headers: {
257
260
  Authorization: `Bearer ${apiKey}`,
@@ -1,4 +1,4 @@
1
- import { getEnvApiKey } from "@oh-my-pi/pi-ai";
1
+ import { type FetchImpl, getEnvApiKey } from "@oh-my-pi/pi-ai";
2
2
  import type { AgentStorage } from "../session/agent-storage";
3
3
  import { findCredential, withHardTimeout } from "./search/providers/utils";
4
4
 
@@ -54,6 +54,7 @@ export interface ParallelSearchOptions {
54
54
  mode?: "fast" | "research";
55
55
  maxCharsPerResult?: number;
56
56
  signal?: AbortSignal;
57
+ fetch?: FetchImpl;
57
58
  }
58
59
 
59
60
  export interface ParallelExtractOptions {
@@ -62,6 +63,7 @@ export interface ParallelExtractOptions {
62
63
  excerpts?: boolean;
63
64
  fullContent?: boolean;
64
65
  signal?: AbortSignal;
66
+ fetch?: FetchImpl;
65
67
  }
66
68
 
67
69
  export class ParallelApiError extends Error {
@@ -295,7 +297,8 @@ export async function searchWithParallel(
295
297
  );
296
298
  }
297
299
 
298
- const response = await fetch(PARALLEL_SEARCH_URL, {
300
+ const fetchImpl = options.fetch ?? fetch;
301
+ const response = await fetchImpl(PARALLEL_SEARCH_URL, {
299
302
  method: "POST",
300
303
  headers: getAuthHeaders(apiKey),
301
304
  body: JSON.stringify({
@@ -328,7 +331,8 @@ export async function extractWithParallel(
328
331
  );
329
332
  }
330
333
 
331
- const response = await fetch(PARALLEL_EXTRACT_URL, {
334
+ const fetchImpl = options.fetch ?? fetch;
335
+ const response = await fetchImpl(PARALLEL_EXTRACT_URL, {
332
336
  method: "POST",
333
337
  headers: getAuthHeaders(apiKey),
334
338
  body: JSON.stringify({
@@ -13,6 +13,7 @@ import {
13
13
  buildAnthropicSearchHeaders,
14
14
  buildAnthropicSystemBlocks,
15
15
  buildAnthropicUrl,
16
+ type FetchImpl,
16
17
  stripClaudeToolPrefix,
17
18
  withAuth,
18
19
  } from "@oh-my-pi/pi-ai";
@@ -40,6 +41,7 @@ export interface AnthropicSearchParams {
40
41
  max_tokens?: number;
41
42
  temperature?: number;
42
43
  signal?: AbortSignal;
44
+ fetch?: FetchImpl;
43
45
  }
44
46
 
45
47
  /**
@@ -89,6 +91,7 @@ async function callSearch(
89
91
  maxTokens?: number,
90
92
  temperature?: number,
91
93
  signal?: AbortSignal,
94
+ fetchImpl: FetchImpl = fetch,
92
95
  ): Promise<AnthropicApiResponse> {
93
96
  const url = buildAnthropicUrl(auth);
94
97
  const headers = buildAnthropicSearchHeaders(auth);
@@ -115,7 +118,7 @@ async function callSearch(
115
118
  body.system = systemBlocks;
116
119
  }
117
120
 
118
- const response = await fetch(url, {
121
+ const response = await fetchImpl(url, {
119
122
  method: "POST",
120
123
  headers,
121
124
  body: JSON.stringify(body),
@@ -275,6 +278,7 @@ export async function searchAnthropic(
275
278
  maxTokens,
276
279
  params.temperature,
277
280
  params.signal,
281
+ params.fetch,
278
282
  ),
279
283
  {
280
284
  signal: params.signal,
@@ -1,4 +1,4 @@
1
- import type { AuthStorage } from "@oh-my-pi/pi-ai";
1
+ import type { AuthStorage, FetchImpl } from "@oh-my-pi/pi-ai";
2
2
  import type { SearchProviderId, SearchResponse } from "../types";
3
3
 
4
4
  /**
@@ -30,6 +30,7 @@ export interface SearchParams {
30
30
  recency?: "day" | "week" | "month" | "year";
31
31
  systemPrompt: string;
32
32
  signal?: AbortSignal;
33
+ fetch?: FetchImpl;
33
34
  maxOutputTokens?: number;
34
35
  numSearchResults?: number;
35
36
  temperature?: number;
@@ -4,7 +4,7 @@
4
4
  * Calls Brave's web search REST API and maps results into the unified
5
5
  * SearchResponse shape used by the web search tool.
6
6
  */
7
- import { type AuthStorage, getEnvApiKey } from "@oh-my-pi/pi-ai";
7
+ import { type AuthStorage, type FetchImpl, getEnvApiKey } from "@oh-my-pi/pi-ai";
8
8
  import type { SearchResponse, SearchSource } from "../../../web/search/types";
9
9
  import { SearchProviderError } from "../../../web/search/types";
10
10
  import { clampNumResults, dateToAgeSeconds } from "../utils";
@@ -28,6 +28,7 @@ export interface BraveSearchParams {
28
28
  num_results?: number;
29
29
  recency?: "day" | "week" | "month" | "year";
30
30
  signal?: AbortSignal;
31
+ fetch?: FetchImpl;
31
32
  }
32
33
 
33
34
  interface BraveSearchResult {
@@ -80,7 +81,8 @@ async function callBraveSearch(
80
81
  url.searchParams.set("freshness", RECENCY_MAP[params.recency]);
81
82
  }
82
83
 
83
- const response = await fetch(url, {
84
+ const fetchImpl = params.fetch ?? fetch;
85
+ const response = await fetchImpl(url, {
84
86
  headers: {
85
87
  Accept: "application/json",
86
88
  "X-Subscription-Token": apiKey,
@@ -144,6 +146,7 @@ export class BraveProvider extends SearchProvider {
144
146
  num_results: params.numSearchResults ?? params.limit,
145
147
  recency: params.recency,
146
148
  signal: params.signal,
149
+ fetch: params.fetch,
147
150
  });
148
151
  }
149
152
  }